From 7e7ad7b59a02c5c7ecb7551cd88b87e0063a87da Mon Sep 17 00:00:00 2001 From: Pat Hawks Date: Sun, 31 May 2026 16:12:20 -0500 Subject: [PATCH] Add Skylanders Portal of Power support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read NFC figures placed on the Skylanders portal over WebHID and back each one up as its raw 1024-byte tag dump, following the same scanner-style pattern as the Disney Infinity base. The portal firmware performs the MIFARE Classic authentication itself, so the driver only issues per-block QUERY reads — no keys, no decryption. Encrypted application blocks are saved verbatim; only the plaintext identity (NUID, figure id, variant id) is parsed, for display and the dump filename. No character database ships. Works in Chrome on macOS and Windows. Linux is unsupported: the portal only accepts host writes via SET_REPORT Output, which Chrome on Linux can't issue; the device description notes this. --- src/App.tsx | 23 +- src/components/wizard/portal-scanner.tsx | 251 ++++++++++++ src/lib/core/connection-registry.ts | 16 + src/lib/core/devices.ts | 13 + .../portal-of-power/portal-commands.test.ts | 70 ++++ .../portal-of-power/portal-commands.ts | 197 +++++++++ .../drivers/portal-of-power/portal-driver.ts | 373 ++++++++++++++++++ .../portal-figure-file.test.ts | 46 +++ .../portal-of-power/portal-figure-file.ts | 60 +++ 9 files changed, 1047 insertions(+), 2 deletions(-) create mode 100644 src/components/wizard/portal-scanner.tsx create mode 100644 src/lib/drivers/portal-of-power/portal-commands.test.ts create mode 100644 src/lib/drivers/portal-of-power/portal-commands.ts create mode 100644 src/lib/drivers/portal-of-power/portal-driver.ts create mode 100644 src/lib/drivers/portal-of-power/portal-figure-file.test.ts create mode 100644 src/lib/drivers/portal-of-power/portal-figure-file.ts diff --git a/src/App.tsx b/src/App.tsx index 17080c5..aadedc5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,8 +21,10 @@ import { Ps1SystemHandler } from "@/lib/systems/ps1/ps1-system-handler"; import { NOINTRO_SYSTEM_NAMES } from "@/lib/core/nointro"; import { AmiiboScanner } from "@/components/wizard/amiibo-scanner"; import { InfinityScanner } from "@/components/wizard/infinity-scanner"; +import { PortalScanner } from "@/components/wizard/portal-scanner"; import { NDSScanner } from "@/components/wizard/nds-scanner"; import type { InfinityDriver } from "@/lib/drivers/infinity/infinity-driver"; +import type { PortalOfPowerDriver } from "@/lib/drivers/portal-of-power/portal-driver"; import type { NDSDeviceDriver } from "@/lib/systems/nds/nds-header"; import type { DeviceDriver, @@ -188,7 +190,9 @@ function App() { ? `Detected: ${result.cartInfo.title ?? "Unknown"} (${result.cartInfo.mapper.name ?? "unknown mapper"})` : `Detected: ${result.cartInfo.title ?? "Unknown"}`, ); - const cap = drv.capabilities.find((c) => c.systemId === result.systemId); + const cap = drv.capabilities.find( + (c) => c.systemId === result.systemId, + ); const prefilled = prefillFromCartInfo( system, result.cartInfo, @@ -213,6 +217,7 @@ function App() { (c) => c.systemId === "amiibo" || c.systemId === "disney-infinity" || + c.systemId === "skylanders" || c.systemId === "nds_save", ); if (!isScanner) autoDetectSystem(_driver); @@ -248,6 +253,9 @@ function App() { connection.driver?.capabilities.some( (c) => c.systemId === "disney-infinity", ) ?? false; + const isPortalDevice = + connection.driver?.capabilities.some((c) => c.systemId === "skylanders") ?? + false; const isNDSSaveDevice = connection.driver?.capabilities.some((c) => c.systemId === "nds_save") ?? false; @@ -291,7 +299,11 @@ function App() { ? `Detected: ${info.title ?? "Unknown"} (${info.mapper.name ?? "unknown mapper"})` : `Detected: ${info.title ?? "Unknown"}`, ); - prefilled = prefillFromCartInfo(system, info, hasSeparateSaveRead(cap)); + prefilled = prefillFromCartInfo( + system, + info, + hasSeparateSaveRead(cap), + ); } else { log("No cartridge detected", "warn"); } @@ -430,6 +442,13 @@ function App() { onDisconnect={handleDisconnect} log={log} /> + ) : isPortalDevice ? ( + ) : isNDSSaveDevice ? ( void; + log: (msg: string, level?: "info" | "warn" | "error") => void; +} + +type Rgb = [number, number, number]; + +// Portal LEDs render blue far brighter than the other channels, so these are +// damped toward red/green to read as intended on real hardware. +const LED_OFF: Rgb = [0x00, 0x00, 0x00]; +const LED_READING: Rgb = [0x00, 0x40, 0xff]; +const LED_ERROR: Rgb = [0xff, 0x10, 0x00]; +const LED_DONE: Rgb = [0x00, 0xc0, 0x40]; + +interface SlotEntry { + slot: number; + phase: "reading" | "done" | "error"; + data?: Uint8Array; + identity?: FigureIdentity; + error?: string; +} + +const variantHex = (v: number) => `0x${v.toString(16).padStart(4, "0")}`; + +export function PortalScanner({ + driver, + deviceInfo, + onDisconnect, + log, +}: PortalScannerProps) { + const [slots, setSlots] = useState>(() => new Map()); + const slotsRef = useRef(slots); + + // Replace-or-delete a slot entry; keep the ref in sync for async readers. + const setSlot = useCallback((slot: number, entry: SlotEntry | null) => { + setSlots((prev) => { + const next = new Map(prev); + if (entry === null) next.delete(slot); + else next.set(slot, entry); + slotsRef.current = next; + return next; + }); + }, []); + + useEffect(() => { + // Per-slot generation: bumped on every add/remove so a read that finishes + // after its figure was lifted (or replaced) can detect it's stale and + // discard its result instead of resurrecting the slot. + const readGen = new Map(); + let lastLed: Rgb | null = null; + + const setLed = async (rgb: Rgb) => { + if (lastLed && lastLed.every((c, i) => c === rgb[i])) return; + lastLed = rgb; + try { + await driver.setColor(rgb[0], rgb[1], rgb[2]); + } catch (e) { + log(`LED set failed: ${(e as Error).message}`, "warn"); + } + }; + + const refreshLed = () => { + const entries = [...slotsRef.current.values()]; + if (entries.length === 0) return void setLed(LED_OFF); + if (entries.some((e) => e.phase === "error")) + return void setLed(LED_ERROR); + if (entries.some((e) => e.phase === "reading")) + return void setLed(LED_READING); + void setLed(LED_DONE); + }; + + const readSlot = async (slot: number, gen: number) => { + try { + const data = await driver.readFigure(slot); + if (readGen.get(slot) !== gen) return; // lifted / replaced mid-read + const identity = parseFigureIdentity(data); + setSlot(slot, { slot, phase: "done", data, identity }); + log( + `Slot ${slot}: figure ${identity.figureId} / ${variantHex(identity.variantId)} · NUID ${identity.nuidHex}`, + ); + } catch (e) { + if (readGen.get(slot) !== gen) return; + const msg = (e as Error).message; + setSlot(slot, { slot, phase: "error", error: msg }); + log(`Slot ${slot} read error: ${msg}`, "error"); + } finally { + refreshLed(); + } + }; + + const handleEvent = (ev: PortalTagEvent) => { + const gen = (readGen.get(ev.slot) ?? 0) + 1; + readGen.set(ev.slot, gen); + if (ev.kind === "removed") { + setSlot(ev.slot, null); + log(`Figure removed from slot ${ev.slot}`); + refreshLed(); + return; + } + setSlot(ev.slot, { slot: ev.slot, phase: "reading" }); + refreshLed(); + void readSlot(ev.slot, gen); + }; + + driver.onTagEvent(handleEvent); + // Seed from any figures already on the pad at connect: diffing against a + // null prior yields an `added` for every currently-occupied slot. + const status = driver.currentStatus; + if (status) for (const ev of diffStatus(null, status)) handleEvent(ev); + void setLed(LED_OFF); + + return () => { + driver.onTagEvent(null); + }; + }, [driver, log, setSlot]); + + const handleDisconnect = useCallback(async () => { + // Turn the LED off and deactivate while the transport is still open — + // dispose() runs after it closes, too late to send commands. + await driver.shutdown(); + onDisconnect(); + }, [driver, onDisconnect]); + + const handleSave = useCallback( + async (slot: number) => { + const entry = slotsRef.current.get(slot); + if (entry?.phase !== "done" || !entry.data || !entry.identity) return; + try { + await saveFile(entry.data, figureFilename(entry.identity), [".bin"]); + } catch (e) { + log(`Couldn't save figure: ${(e as Error).message}`, "error"); + } + }, + [log], + ); + + const rows = [...slots.values()].sort((a, b) => a.slot - b.slot); + + return ( +
+
+ {deviceInfo && ( + + {deviceInfo.deviceName} + {deviceInfo.hardwareRevision + ? ` · ${deviceInfo.hardwareRevision}` + : ""} + + )} + +
+ + + + + Figures on portal + + + + {rows.length === 0 ? ( +
+ + + Place a Skylanders figure on the portal... + +
+ ) : ( +
    + {rows.map((entry) => ( + handleSave(entry.slot)} + /> + ))} +
+ )} +
+
+ +

+ Saved as the raw encrypted tag dump — identify or decrypt the .bin in an + external tool. +

+
+ ); +} + +function SlotRow({ entry, onSave }: { entry: SlotEntry; onSave: () => void }) { + const label = `Slot ${entry.slot}`; + if (entry.phase === "error") { + return ( +
  • +
    + {label} + read failed +
    +
    {entry.error}
    +
  • + ); + } + if (entry.phase === "reading" || !entry.identity) { + return ( +
  • +
    + {label} + reading… +
    +
  • + ); + } + const { nuidHex, figureId, variantId } = entry.identity; + return ( +
  • +
    + {label} + + figure {figureId} · {variantHex(variantId)} + +
    +
    + NUID: {nuidHex} +
    +
    + +
    +
  • + ); +} diff --git a/src/lib/core/connection-registry.ts b/src/lib/core/connection-registry.ts index ef84b9c..b2d76ab 100644 --- a/src/lib/core/connection-registry.ts +++ b/src/lib/core/connection-registry.ts @@ -8,6 +8,8 @@ import { PowerSave3DSDriver } from "@/lib/drivers/powersave-3ds/powersave-3ds-dr import { DEVICE_FILTERS as POWERSAVE_3DS_FILTERS } from "@/lib/drivers/powersave-3ds/powersave-3ds-commands"; import { InfinityDriver } from "@/lib/drivers/infinity/infinity-driver"; import { DEVICE_FILTERS as INFINITY_FILTERS } from "@/lib/drivers/infinity/infinity-commands"; +import { PortalOfPowerDriver } from "@/lib/drivers/portal-of-power/portal-driver"; +import { DEVICE_FILTERS as PORTAL_FILTERS } from "@/lib/drivers/portal-of-power/portal-commands"; import { Ps3McaDriver } from "@/lib/drivers/ps3-mca/ps3-mca-driver"; import { DEVICE_FILTERS as PS3_MCA_FILTERS } from "@/lib/drivers/ps3-mca/ps3-mca-commands"; import { SMS4Driver } from "@/lib/drivers/sms4/sms4-driver"; @@ -91,6 +93,20 @@ export const CONNECTION_ENTRIES: Record = { `Connected: ${info.deviceName} (fw: ${info.firmwareVersion})`, }, + PORTAL_OF_POWER: { + createTransport: () => new HidTransport(PORTAL_FILTERS), + connect: (t, { authorized }) => + authorized + ? (t as HidTransport).connectWithDevice(authorized as HIDDevice) + : (t as HidTransport).connect(), + createDriver: (t) => new PortalOfPowerDriver(t as HidTransport), + preInitLog: "Activating portal...", + postInitLog: (info) => + info.hardwareRevision + ? `Connected: ${info.deviceName} (${info.hardwareRevision})` + : `Connected: ${info.deviceName}`, + }, + PS3_MCA: { createTransport: () => new UsbTransport(PS3_MCA_FILTERS), connect: (t, { authorized }) => diff --git a/src/lib/core/devices.ts b/src/lib/core/devices.ts index 1b94793..ef3a996 100644 --- a/src/lib/core/devices.ts +++ b/src/lib/core/devices.ts @@ -76,6 +76,19 @@ export const DEVICES: Record = { "Logic3 / PDP Disney Infinity Base. Reads Disney Infinity figures " + "(Wii / Wii U / PS3 / PS4 / PC variant).", }, + PORTAL_OF_POWER: { + id: "PORTAL_OF_POWER", + name: "Skylanders Portal of Power", + vendorId: 0x1430, + productId: 0x0150, + transport: "webhid", + systems: [{ id: "skylanders", name: "Skylanders Figures" }], + description: + "Activision Skylanders portal. Reads the NFC figures placed on it. " + + "Works in Chrome on macOS and Windows; Linux Chrome cannot drive it — " + + "the portal only accepts writes through a control request the Linux " + + "HID stack won't issue (no udev rule changes this).", + }, // The adapter performs an SIO-level identification challenge before // reporting a card type. First-party PS1 cards reply with the expected // ID bytes (0x5A 0x5D) on the first request; multi-page clone cards diff --git a/src/lib/drivers/portal-of-power/portal-commands.test.ts b/src/lib/drivers/portal-of-power/portal-commands.test.ts new file mode 100644 index 0000000..8b1f10f --- /dev/null +++ b/src/lib/drivers/portal-of-power/portal-commands.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { + decodeStatus, + diffStatus, + slotToIdx, + SlotState, + type StatusReport, +} from "./portal-commands"; + +function mkStatus(states: Record): StatusReport { + const slots: SlotState[] = new Array(16).fill(SlotState.EMPTY); + for (const [k, v] of Object.entries(states)) slots[Number(k)] = v; + return { slots, counter: 0, activated: true }; +} + +describe("slotToIdx", () => { + it("ORs the slot into the high-nibble-1 wire form", () => { + expect(slotToIdx(0)).toBe(0x10); + expect(slotToIdx(5)).toBe(0x15); + expect(slotToIdx(15)).toBe(0x1f); + }); +}); + +describe("decodeStatus", () => { + it("unpacks the 2-bits-per-slot bitmap, counter, and activated flag", () => { + const bytes = new Uint8Array(32); + bytes[0] = 0x53; + bytes[1] = 0b0000_0111; // slot 0 = ADDED (11), slot 1 = PRESENT (01) + bytes[5] = 5; + bytes[6] = 1; + const status = decodeStatus(bytes); + expect(status.slots).toHaveLength(16); + expect(status.slots[0]).toBe(SlotState.ADDED); + expect(status.slots[1]).toBe(SlotState.PRESENT); + expect(status.slots[2]).toBe(SlotState.EMPTY); + expect(status.counter).toBe(5); + expect(status.activated).toBe(true); + }); + + it("reads slot 15 from the top two bits without sign-extension corruption", () => { + const bytes = new Uint8Array(32); + bytes[0] = 0x53; + bytes[4] = 0b1100_0000; // slot 15 occupies bits 30..31 of the u32 + const status = decodeStatus(bytes); + expect(status.slots[15]).toBe(SlotState.ADDED); + expect(status.activated).toBe(false); + }); +}); + +describe("diffStatus", () => { + it("treats a null prior as 'every occupied slot was just added'", () => { + const next = mkStatus({ 0: SlotState.PRESENT, 3: SlotState.ADDED }); + expect(diffStatus(null, next)).toEqual([ + { slot: 0, kind: "added" }, + { slot: 3, kind: "added" }, + ]); + }); + + it("emits a removal when an occupied slot empties", () => { + const prev = mkStatus({ 2: SlotState.PRESENT }); + const next = mkStatus({}); + expect(diffStatus(prev, next)).toEqual([{ slot: 2, kind: "removed" }]); + }); + + it("treats PRESENT and ADDED as the same occupied state — no spurious event", () => { + const prev = mkStatus({ 1: SlotState.ADDED }); + const next = mkStatus({ 1: SlotState.PRESENT }); + expect(diffStatus(prev, next)).toEqual([]); + }); +}); diff --git a/src/lib/drivers/portal-of-power/portal-commands.ts b/src/lib/drivers/portal-of-power/portal-commands.ts new file mode 100644 index 0000000..e0e9952 --- /dev/null +++ b/src/lib/drivers/portal-of-power/portal-commands.ts @@ -0,0 +1,197 @@ +// Skylanders Portal of Power — HID protocol constants. +// +// Protocol reference: Dolphin Emulator's Skylander.cpp (GPL-2.0-or-later) +// https://github.com/dolphin-emu/dolphin/blob/master/Source/Core/Core/IOS/USB/Emulated/Skylanders/Skylander.cpp +// +// Wire format: +// Every HID report, both directions, is exactly 32 bytes. Byte 0 is an +// ASCII opcode letter; the remaining bytes are either arguments (host → +// device) or a status/response payload (device → host). There is no +// length prefix, no sequence number, and no checksum — dispatch is keyed +// purely on byte 0. +// +// The portal sends unsolicited `S` (0x53) status reports at ~10 Hz while +// open. Command responses echo the command byte (e.g. `Q` request → `Q` +// response), so with commands serialized one-at-a-time we can match a +// response to its request simply by filtering status reports out of the +// input stream. + +/** All host→device and device→host reports are exactly 32 bytes. */ +export const PACKET_SIZE = 32; + +export const DEVICE_FILTERS: HIDDeviceFilter[] = [ + // Wireless Wii dongle, and every wired Giants/SwapForce/Trap Team/ + // SuperChargers/Imaginators portal. The 2011 wired portal uses a + // different product id and is left out until it can be tested. + { vendorId: 0x1430, productId: 0x0150 }, +]; + +/** + * Host → device opcodes. + * + * `A`, `C`, `Q`, `R`, `S`, `W` work on every portal model. `J`, `L`, `M` + * are Trap Team and later; older hardware silently drops unknown opcodes, + * but sending them is wasted traffic. Kept here to document the surface. + */ +export const CMD = { + /** Enable/disable figure scanning. `A 01` on, `A 00` off. */ + ACTIVATE: 0x41, + /** Set the center-ring LED color. `C RR GG BB`. No response. */ + COLOR: 0x43, + /** + * Set a sided LED with fade (Trap Team +). + * `J RR GG BB ` + * side: 0x00 = right, 0x02 = left. + */ + LED_FADE: 0x4a, + /** + * Set a zoned LED (Trap Team +). + * `L RR GG BB` + * pos: 0x00 = right, 0x01 = trap LED (white-only), 0x02 = left. + */ + LED_ZONE: 0x4c, + /** Query audio firmware (Trap Team wired only, portals with a speaker). */ + AUDIO_FIRMWARE: 0x4d, + /** + * Read one 16-byte tag block. + * Request: `Q ` where `idx = 0x10 | slot` (slot 0–15). + * Response success: `Q <16 bytes data>`. + * Response failure: `Q 0x01 …` (byte 1 is 0x01 instead of idx). + */ + QUERY_BLOCK: 0x51, + /** Re-scan the portal. Response: `R 02 …` — a cheap probe. */ + RESET: 0x52, + /** Ask the portal to emit one Status report immediately. */ + STATUS: 0x53, + /** Write a 16-byte tag block. `W <16 bytes>`. */ + WRITE_BLOCK: 0x57, +} as const; + +/** + * Device → host opcodes. Most echo the host opcode; `S` is the only + * unsolicited channel. + */ +export const RESP = { + ACTIVATE: 0x41, + QUERY_BLOCK: 0x51, + RESET: 0x52, + /** Unsolicited ~10 Hz presence/state report. */ + STATUS: 0x53, + WRITE_BLOCK: 0x57, + /** + * Wireless-only: emitted by the dongle when the wireless base goes out + * of range. Does not occur on wired portals. + */ + OUT_OF_RANGE: 0x5a, +} as const; + +/** Number of simultaneous figure slots the portal tracks. */ +export const MAX_SLOTS = 16; + +/** MIFARE Classic 1K — 64 blocks × 16 bytes. */ +export const BLOCKS_PER_FIGURE = 64; +export const BLOCK_SIZE = 16; +export const FIGURE_SIZE = BLOCKS_PER_FIGURE * BLOCK_SIZE; // 1024 + +/** + * Byte offsets within tag block 1 (figure identity block, plaintext). + * figure_id — u16 LE at 0x00–0x01, identifies the character model. + * variant_id — u16 LE at 0x0C–0x0D, distinguishes reposes/Legendary/etc. + */ +export const BLOCK1_FIGURE_ID_OFFSET = 0x00; +export const BLOCK1_VARIANT_ID_OFFSET = 0x0c; + +/** + * Portal-assigned slot index as transmitted on the wire. + * The low nibble is the actual slot (0..15); the high nibble is always 1. + */ +export function slotToIdx(slot: number): number { + return 0x10 | (slot & 0x0f); +} + +/** + * Per-slot state codes used in the Status bitmap. The portal transmits + * `ADDED` and `REMOVED` as one-shot transitions — the next Status report + * will promote them to `PRESENT` or `EMPTY` respectively. + */ +export const SlotState = { + EMPTY: 0b00, + PRESENT: 0b01, + REMOVED: 0b10, + ADDED: 0b11, +} as const; +export type SlotState = (typeof SlotState)[keyof typeof SlotState]; + +/** Decoded Status report. */ +export interface StatusReport { + /** Per-slot state for all 16 slots, index 0 = slot 0. */ + slots: SlotState[]; + /** Monotonic u8 that wraps; increments on every Status the portal emits. */ + counter: number; + /** Whether the portal is currently scanning (reflects the last `A` state). */ + activated: boolean; +} + +/** + * Decode a 32-byte Status report. + * + * Layout: + * byte 0 : 0x53 'S' + * bytes 1..4 : little-endian u32, 2 bits per slot × 16 slots + * byte 5 : interrupt counter (u8) + * byte 6 : 0x01 if activated, 0x00 otherwise + * bytes 7+ : 0x00 padding + */ +export function decodeStatus(bytes: Uint8Array): StatusReport { + const bitmap = + (bytes[1] | (bytes[2] << 8) | (bytes[3] << 16) | (bytes[4] << 24)) >>> 0; + const slots: SlotState[] = new Array(MAX_SLOTS); + for (let i = 0; i < MAX_SLOTS; i++) { + slots[i] = ((bitmap >>> (i * 2)) & 0b11) as SlotState; + } + return { + slots, + counter: bytes[5], + activated: bytes[6] === 0x01, + }; +} + +export interface SlotEvent { + slot: number; + kind: "added" | "removed"; +} + +/** + * Diff two consecutive Status reports and synthesize placed/removed events. + * + * The portal itself reports one-shot ADDED/REMOVED codes, but relying on + * them alone means we'd miss transitions that landed between our polling + * windows (e.g. figure placed then lifted faster than a Status round-trip). + * Walking the slot-state delta is authoritative. Passing `prev = null` + * yields an `added` for every currently-occupied slot — handy for seeding + * a freshly-mounted scanner from `currentStatus`. + */ +export function diffStatus( + prev: StatusReport | null, + next: StatusReport, +): SlotEvent[] { + const events: SlotEvent[] = []; + for (let i = 0; i < MAX_SLOTS; i++) { + const prevOccupied = + prev !== null && + (prev.slots[i] === SlotState.PRESENT || + prev.slots[i] === SlotState.ADDED); + const nextOccupied = + next.slots[i] === SlotState.PRESENT || next.slots[i] === SlotState.ADDED; + if (!prevOccupied && nextOccupied) events.push({ slot: i, kind: "added" }); + else if (prevOccupied && !nextOccupied) + events.push({ slot: i, kind: "removed" }); + } + return events; +} + +/** Wait time for a command response before giving up. */ +export const COMMAND_TIMEOUT_MS = 2000; + +/** Fail-fast timeout for the initial Reset probe. */ +export const RESET_TIMEOUT_MS = 1000; diff --git a/src/lib/drivers/portal-of-power/portal-driver.ts b/src/lib/drivers/portal-of-power/portal-driver.ts new file mode 100644 index 0000000..c0f9c21 --- /dev/null +++ b/src/lib/drivers/portal-of-power/portal-driver.ts @@ -0,0 +1,373 @@ +// Skylanders Portal of Power driver. +// +// Wire format: fixed 32-byte HID reports in both directions. Host→device +// commands are one ASCII opcode byte (byte 0) followed by argument bytes; +// device→host reports echo the opcode byte for command responses, plus an +// unsolicited ~10 Hz `S` (Status) report stream. See portal-commands.ts. +// +// Platform support — Chrome on macOS and Windows; NOT Linux. +// The portal firmware STALLs its interrupt-OUT endpoint and only accepts +// host→device data via the SET_REPORT Output class request +// (bmRequestType=0x21 bRequest=0x09 wValue=0x0200). Chrome's WebHID +// `sendReport()` issues SET_REPORT through the OS HID stack on macOS and +// Windows, so it works there. On Linux `sendReport()` writes via +// `/dev/hidraw`, which targets the broken interrupt-OUT endpoint and +// fails with EPROTO; `sendFeatureReport()` (SET_REPORT Feature) is ignored +// by the firmware, and WebUSB can't claim a HID-class interface — so there +// is no in-browser Linux path. The device description flags this so Linux +// users aren't surprised by a connect failure. + +import type { + DeviceDriver, + DeviceDriverEvents, + DeviceCapability, + DeviceInfo, + CartridgeInfo, + ReadConfig, + DumpProgress, + SystemId, + DetectSystemResult, +} from "@/lib/types"; +import type { HidTransport } from "@/lib/transport/hid-transport"; +import { + BLOCKS_PER_FIGURE, + BLOCK_SIZE, + CMD, + COMMAND_TIMEOUT_MS, + decodeStatus, + diffStatus, + FIGURE_SIZE, + MAX_SLOTS, + PACKET_SIZE, + RESET_TIMEOUT_MS, + RESP, + slotToIdx, + type SlotEvent, + type StatusReport, +} from "./portal-commands"; + +/** Placed/removed slot events the driver synthesizes from Status deltas. */ +export type PortalTagEvent = SlotEvent; + +export class PortalOfPowerDriver implements DeviceDriver { + readonly id = "PORTAL_OF_POWER"; + readonly name = "Skylanders Portal of Power"; + // Scanner-style device: figures are read on demand through the portal + // scanner UI (readFigure), not the generic cartridge dump path. + readonly capabilities: DeviceCapability[] = [ + { systemId: "skylanders", operations: [], autoDetect: false }, + ]; + + transport: HidTransport; + private events: Partial = {}; + private tagEventHandler: ((event: PortalTagEvent) => void) | null = null; + + /** Last observed Status so we can emit diff events. */ + private lastStatus: StatusReport | null = null; + + /** + * Serialize commands so the one-slot response matching (next non-Status + * report) can't be trampled by an interleaving caller. + */ + private commandChain: Promise = Promise.resolve(); + + constructor(transport: HidTransport) { + this.transport = transport; + this.transport.setInputListener(this.handleInputReport); + } + + // ─── DeviceDriver interface ──────────────────────────────────────────── + + async initialize(): Promise { + // Reset is the cleanest probe: the portal responds with `52 02 ` + // and re-announces any figures on the pad via fresh ADDED codes. Safe to + // send even if the portal has never been activated. + let hwRev: number | null = null; + try { + const reset = await this.sendAndReceive(CMD.RESET, [], RESP.RESET, { + timeoutMs: RESET_TIMEOUT_MS, + }); + hwRev = reset[2] ?? null; + } catch (e) { + // Some models NAK the reset if the portal is mid-boot; fall through and + // let activation surface the real problem. + this.log(`Reset probe failed: ${(e as Error).message}`, "warn"); + } + + await this.sendAndReceive(CMD.ACTIVATE, [0x01], RESP.ACTIVATE); + + return { + firmwareVersion: "", + hardwareRevision: + hwRev !== null + ? `rev 0x${hwRev.toString(16).padStart(2, "0")}` + : undefined, + deviceName: this.name, + capabilities: this.capabilities, + // Figures come and go freely; the portal pushes presence over Status. + hotSwap: true, + }; + } + + async detectSystem(): Promise { + return { systemId: "skylanders", cartInfo: {} }; + } + + async detectCartridge(_systemId: SystemId): Promise { + return null; + } + + async readROM( + _config: ReadConfig, + _signal?: AbortSignal, + ): Promise { + throw new Error( + "Skylanders figures are read through the portal scanner, not the generic dump path", + ); + } + + async readSave( + _config: ReadConfig, + _signal?: AbortSignal, + ): Promise { + throw new Error("Skylanders figures do not have separate save data"); + } + + async writeSave( + _data: Uint8Array, + _config: ReadConfig, + _signal?: AbortSignal, + ): Promise { + throw new Error("Skylanders figure writing not implemented"); + } + + on( + event: K, + handler: DeviceDriverEvents[K], + ): void { + this.events[event] = handler; + } + + dispose(): void { + if (this.pending) { + clearTimeout(this.pending.timer); + this.pending = null; + } + this.tagEventHandler = null; + this.transport.setInputListener(null); + } + + // ─── Portal-specific API ─────────────────────────────────────────────── + + /** Subscribe to placed/removed slot events synthesized from Status deltas. */ + onTagEvent(handler: ((event: PortalTagEvent) => void) | null): void { + this.tagEventHandler = handler; + } + + /** Most recent decoded Status, or null before the first one arrives. */ + get currentStatus(): StatusReport | null { + return this.lastStatus; + } + + /** + * Set the center-ring LED. The `C` command is the legacy single-zone + * control that every portal model honors. Values are 0..255 per channel. + * + * Hardware LEDs don't render web RGB faithfully — blue bleeds heavily into + * other hues — so saturated reds/greens benefit from a damped blue channel. + * Callers that care about fidelity should do that remapping. + */ + async setColor(r: number, g: number, b: number): Promise { + // `C` has no response — fire-and-forget, but keep it serialized so it + // can't jump the queue ahead of a pending Q/W and race the portal. + await this.sendNoReply(CMD.COLOR, [r & 0xff, g & 0xff, b & 0xff]); + } + + /** + * Read a single 16-byte tag block from the figure in `slot`. + * + * Throws if the portal reports a read failure (byte 1 = 0x01 instead of the + * echoed slot index) or returns a truncated reply — typically because the + * slot is empty or the figure was lifted mid-read. + */ + async readBlock(slot: number, block: number): Promise { + if (slot < 0 || slot >= MAX_SLOTS) { + throw new Error(`Slot ${slot} out of range (0..${MAX_SLOTS - 1})`); + } + if (block < 0 || block >= BLOCKS_PER_FIGURE) { + throw new Error( + `Block ${block} out of range (0..${BLOCKS_PER_FIGURE - 1})`, + ); + } + const idx = slotToIdx(slot); + const resp = await this.sendAndReceive( + CMD.QUERY_BLOCK, + [idx, block], + RESP.QUERY_BLOCK, + ); + // Failure form: resp[1] === 0x01 instead of the echoed idx (also catches + // an empty/garbled reply, where resp[1] is undefined). + if (resp[1] !== idx) { + throw new Error( + `Block read failed: slot ${slot}, block ${block} (portal returned idx 0x${(resp[1] ?? 0).toString(16).padStart(2, "0")})`, + ); + } + // A short success reply would let readFigure zero-pad a corrupt block + // into the dump — reject it loudly instead. + if (resp.length < 3 + BLOCK_SIZE) { + throw new Error( + `Portal returned a short block (${resp.length} bytes) for slot ${slot}, block ${block}`, + ); + } + return resp.slice(3, 3 + BLOCK_SIZE); + } + + /** Dump all 64 blocks of a figure into a single 1024-byte buffer. */ + async readFigure(slot: number, signal?: AbortSignal): Promise { + const data = new Uint8Array(FIGURE_SIZE); + for (let block = 0; block < BLOCKS_PER_FIGURE; block++) { + if (signal?.aborted) throw new Error("Aborted"); + const buf = await this.readBlock(slot, block); + data.set(buf, block * BLOCK_SIZE); + this.emitProgress("rom", (block + 1) * BLOCK_SIZE, FIGURE_SIZE); + } + return data; + } + + /** Best-effort LED-off + deactivate. Failures are ignored. */ + async shutdown(): Promise { + try { + await this.setColor(0, 0, 0); + } catch { + /* ignore */ + } + try { + await this.sendAndReceive(CMD.ACTIVATE, [0x00], RESP.ACTIVATE); + } catch { + /* ignore */ + } + } + + // ─── Protocol layer ──────────────────────────────────────────────────── + + /** + * Pending-response slot. Because commands serialize via `commandChain`, + * only one caller can be waiting at a time. + */ + private pending: { + expectedByte: number; + resolve: (data: Uint8Array) => void; + reject: (err: Error) => void; + timer: ReturnType; + } | null = null; + + private sendAndReceive( + command: number, + args: number[], + expectedResponseByte: number, + opts: { timeoutMs?: number } = {}, + ): Promise { + const run = () => + new Promise((resolve, reject) => { + const packet = buildPacket(command, args); + const timer = setTimeout(() => { + this.pending = null; + reject( + new Error( + `Command 0x${command.toString(16)} timed out after ${opts.timeoutMs ?? COMMAND_TIMEOUT_MS}ms`, + ), + ); + }, opts.timeoutMs ?? COMMAND_TIMEOUT_MS); + + this.pending = { + expectedByte: expectedResponseByte, + resolve, + reject, + timer, + }; + + this.transport.send(packet).catch((err) => { + clearTimeout(timer); + this.pending = null; + reject(err); + }); + }); + + const next = this.commandChain.then(run, run); + this.commandChain = next.catch(() => {}); // keep chain alive past errors + return next; + } + + private sendNoReply(command: number, args: number[]): Promise { + const run = () => this.transport.send(buildPacket(command, args)); + const next = this.commandChain.then(run, run); + this.commandChain = next.catch(() => {}); + return next; + } + + private handleInputReport = (data: Uint8Array): void => { + if (data.length === 0) return; + const opcode = data[0]; + + // Status reports are unsolicited and independent of any outstanding + // command. Always dispatch them regardless of pending state. + if (opcode === RESP.STATUS) { + this.handleStatus(data); + return; + } + + // Wireless out-of-range notice: log and move on. Not a command response. + if (opcode === RESP.OUT_OF_RANGE) { + this.log("Wireless portal went out of range", "warn"); + return; + } + + // Anything else should be the response to our one pending command. + if (this.pending && this.pending.expectedByte === opcode) { + const { resolve, timer } = this.pending; + this.pending = null; + clearTimeout(timer); + resolve(data); + } + // Unmatched reports (e.g. stray bytes during boot) are ignored; a command + // that never sees its response fails via the timeout above. + }; + + private handleStatus(data: Uint8Array): void { + const status = decodeStatus(data); + const events = diffStatus(this.lastStatus, status); + this.lastStatus = status; + + if (this.tagEventHandler) { + for (const ev of events) this.tagEventHandler(ev); + } + } + + private emitProgress( + phase: DumpProgress["phase"], + bytesRead: number, + totalBytes: number, + ): void { + this.events.onProgress?.({ + phase, + bytesRead, + totalBytes, + fraction: bytesRead / totalBytes, + }); + } + + private log( + message: string, + level: "info" | "warn" | "error" = "info", + ): void { + this.events.onLog?.(message, level); + } +} + +/** Build a 32-byte outbound packet with opcode in byte 0 and args following. */ +function buildPacket(command: number, args: number[]): Uint8Array { + const packet = new Uint8Array(PACKET_SIZE); + packet[0] = command; + for (let i = 0; i < args.length; i++) packet[1 + i] = args[i]; + return packet; +} diff --git a/src/lib/drivers/portal-of-power/portal-figure-file.test.ts b/src/lib/drivers/portal-of-power/portal-figure-file.test.ts new file mode 100644 index 0000000..efb4286 --- /dev/null +++ b/src/lib/drivers/portal-of-power/portal-figure-file.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { + figureFilename, + isFullDump, + parseFigureIdentity, +} from "./portal-figure-file"; + +/** A 1024-byte dump with known plaintext block 0 / block 1 fields. */ +function mkDump(): Uint8Array { + const data = new Uint8Array(1024); + data.set([0x1a, 0x2b, 0x3c, 0x4d], 0); // NUID, block 0 + data[16 + 0x00] = 0x34; // figure_id LE low + data[16 + 0x01] = 0x12; // figure_id LE high -> 0x1234 + data[16 + 0x0c] = 0x05; // variant_id LE low + data[16 + 0x0d] = 0x00; // variant_id LE high -> 0x0005 + return data; +} + +describe("parseFigureIdentity", () => { + it("extracts the NUID and the LE figure/variant ids", () => { + const id = parseFigureIdentity(mkDump()); + expect(Array.from(id.nuid)).toEqual([0x1a, 0x2b, 0x3c, 0x4d]); + expect(id.nuidHex).toBe("1A2B3C4D"); + expect(id.figureId).toBe(0x1234); + expect(id.variantId).toBe(0x0005); + }); + + it("rejects a dump too short to hold blocks 0 and 1", () => { + expect(() => parseFigureIdentity(new Uint8Array(16))).toThrow(); + }); +}); + +describe("isFullDump", () => { + it("is true only for a full 1024-byte MIFARE 1K dump", () => { + expect(isFullDump(new Uint8Array(1024))).toBe(true); + expect(isFullDump(new Uint8Array(1023))).toBe(false); + }); +}); + +describe("figureFilename", () => { + it("names the file by NUID with a .bin extension", () => { + const name = figureFilename(parseFigureIdentity(mkDump())); + expect(name.startsWith("Skylanders - 1A2B3C4D - ")).toBe(true); + expect(name.endsWith(".bin")).toBe(true); + }); +}); diff --git a/src/lib/drivers/portal-of-power/portal-figure-file.ts b/src/lib/drivers/portal-of-power/portal-figure-file.ts new file mode 100644 index 0000000..9ca1f8e --- /dev/null +++ b/src/lib/drivers/portal-of-power/portal-figure-file.ts @@ -0,0 +1,60 @@ +// Identify a dumped Skylanders figure from the plaintext tag blocks. +// +// Only block 0 (manufacturer / NUID) and block 1 (figure identity) are +// readable as plaintext. The remaining blocks are AES-128-ECB encrypted with +// a per-tag key the portal never exposes, so the saved .bin keeps them in +// their encrypted on-tag form — a faithful raw dump, not a decrypted one. + +import { + BLOCK_SIZE, + BLOCK1_FIGURE_ID_OFFSET, + BLOCK1_VARIANT_ID_OFFSET, + FIGURE_SIZE, +} from "./portal-commands"; + +export interface FigureIdentity { + /** 4-byte NUID — block 0 bytes 0..3. Unique per physical tag. */ + nuid: Uint8Array; + /** Uppercase hex of `nuid`, e.g. "0A1B2C3D". */ + nuidHex: string; + /** u16 LE from block 1 — identifies the character model. */ + figureId: number; + /** u16 LE from block 1 — distinguishes repose / wave / Legendary / etc. */ + variantId: number; +} + +const BLOCK1_OFFSET = BLOCK_SIZE; // block 1 starts at byte 16 + +/** Parse the plaintext identity fields from a figure dump. */ +export function parseFigureIdentity(data: Uint8Array): FigureIdentity { + if (data.length < BLOCK1_OFFSET + BLOCK_SIZE) { + throw new Error( + `Figure data too short: ${data.length} bytes (need ${BLOCK1_OFFSET + BLOCK_SIZE})`, + ); + } + const nuid = data.slice(0, 4); + const figureId = + data[BLOCK1_OFFSET + BLOCK1_FIGURE_ID_OFFSET] | + (data[BLOCK1_OFFSET + BLOCK1_FIGURE_ID_OFFSET + 1] << 8); + const variantId = + data[BLOCK1_OFFSET + BLOCK1_VARIANT_ID_OFFSET] | + (data[BLOCK1_OFFSET + BLOCK1_VARIANT_ID_OFFSET + 1] << 8); + return { nuid, nuidHex: toHex(nuid), figureId, variantId }; +} + +/** True when `data` is a full MIFARE 1K dump (all 64 blocks present). */ +export function isFullDump(data: Uint8Array): boolean { + return data.length === FIGURE_SIZE; +} + +/** Filename convention for downloaded figure dumps. */ +export function figureFilename(identity: FigureIdentity): string { + const date = new Date().toISOString().slice(0, 10); + return `Skylanders - ${identity.nuidHex} - ${date}.bin`; +} + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")) + .join("") + .toUpperCase(); +}