Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -430,6 +442,13 @@ function App() {
onDisconnect={handleDisconnect}
log={log}
/>
) : isPortalDevice ? (
<PortalScanner
driver={connection.driver! as PortalOfPowerDriver}
deviceInfo={connection.deviceInfo}
onDisconnect={handleDisconnect}
log={log}
/>
) : isNDSSaveDevice ? (
<NDSScanner
driver={connection.driver! as NDSDeviceDriver}
Expand Down
251 changes: 251 additions & 0 deletions src/components/wizard/portal-scanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { saveFile } from "@/lib/core/file-save";
import type {
PortalOfPowerDriver,
PortalTagEvent,
} from "@/lib/drivers/portal-of-power/portal-driver";
import { diffStatus } from "@/lib/drivers/portal-of-power/portal-commands";
import {
figureFilename,
parseFigureIdentity,
type FigureIdentity,
} from "@/lib/drivers/portal-of-power/portal-figure-file";
import type { DeviceInfo } from "@/lib/types";

interface PortalScannerProps {
driver: PortalOfPowerDriver;
deviceInfo: DeviceInfo | null;
onDisconnect: () => 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<Map<number, SlotEntry>>(() => 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<number, number>();
let lastLed: Rgb | null = null;
Comment on lines +67 to +68
Comment on lines +67 to +68
Comment on lines +67 to +68

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);
};
Comment on lines +130 to +132
Comment on lines +130 to +132
Comment on lines +130 to +132
}, [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 (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between text-sm">
{deviceInfo && (
<span className="text-muted-foreground">
{deviceInfo.deviceName}
{deviceInfo.hardwareRevision
? ` · ${deviceInfo.hardwareRevision}`
: ""}
</span>
)}
<Button variant="outline" size="sm" onClick={handleDisconnect}>
Disconnect
</Button>
</div>

<Card>
<CardHeader>
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
Figures on portal
</CardTitle>
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<div className="flex items-center gap-3 py-4">
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="text-sm text-muted-foreground">
Place a Skylanders figure on the portal...
</span>
</div>
) : (
<ul className="flex flex-col gap-3 text-sm">
{rows.map((entry) => (
<SlotRow
key={entry.slot}
entry={entry}
onSave={() => handleSave(entry.slot)}
/>
))}
</ul>
)}
</CardContent>
</Card>

<p className="text-center text-[11px] text-muted-foreground">
Saved as the raw encrypted tag dump — identify or decrypt the .bin in an
external tool.
</p>
</div>
);
}

function SlotRow({ entry, onSave }: { entry: SlotEntry; onSave: () => void }) {
const label = `Slot ${entry.slot}`;
if (entry.phase === "error") {
return (
<li className="flex flex-col gap-1 border-l-2 border-destructive/60 pl-3">
<div className="flex items-center justify-between">
<span className="text-card-foreground">{label}</span>
<span className="text-[11px] text-destructive">read failed</span>
</div>
<div className="text-[11px] text-muted-foreground">{entry.error}</div>
</li>
);
}
if (entry.phase === "reading" || !entry.identity) {
return (
<li className="flex flex-col gap-1 border-l-2 border-primary/40 pl-3">
<div className="flex items-center justify-between">
<span className="text-card-foreground">{label}</span>
<span className="text-[11px] text-muted-foreground">reading…</span>
</div>
</li>
);
}
const { nuidHex, figureId, variantId } = entry.identity;
return (
<li className="flex flex-col gap-1 border-l-2 border-primary/60 pl-3">
<div className="flex items-center justify-between">
<span className="text-card-foreground">{label}</span>
<span className="text-[11px] text-muted-foreground">
figure {figureId} · {variantHex(variantId)}
</span>
</div>
<div className="font-mono text-[11px] text-muted-foreground">
NUID: {nuidHex}
</div>
<div>
<Button size="sm" onClick={onSave}>
Save .bin
</Button>
</div>
</li>
);
}
16 changes: 16 additions & 0 deletions src/lib/core/connection-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -91,6 +93,20 @@ export const CONNECTION_ENTRIES: Record<string, ConnectionEntry> = {
`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 }) =>
Expand Down
13 changes: 13 additions & 0 deletions src/lib/core/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,19 @@ export const DEVICES: Record<string, DeviceDef> = {
"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
Expand Down
Loading