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(); +}