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
16 changes: 16 additions & 0 deletions THIRD-PARTY-LICENSES
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,22 @@ See LICENSE in this repository (same license applies to nabu as a whole).

================================================================================

kazzo / anago
https://github.com/sharkpp/unagi_kazzo
https://github.com/zerkerX/anago

The Kazzo NES/Famicom dumper USB protocol — vendor request numbers, wire
format, and version handshake — in src/lib/drivers/kazzo/ was reimplemented
from the documented protocol: unagi_kazzo's firmware/usbrequest.txt and
anago's kazzo/kazzo_request.h, with anago's reader_kazzo.c as the reference
host. These are interface facts; no firmware or host source was incorporated,
so the GPL-2.0-only license of those projects does not attach to nabu.

Kazzo firmware (naruko) and anago (zerkerX) are licensed GNU General Public
License v2.0 only (GPL-2.0-only).

================================================================================

famicom-dumper-client
https://github.com/ClusterM/famicom-dumper-client

Expand Down
71 changes: 71 additions & 0 deletions src/hooks/use-connection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, it, expect } from "vitest";
import { webusbMatches } from "./use-connection";
import type { DeviceDef } from "@/lib/core/devices";

/**
* The Kazzo and INL Retro both enumerate as V-USB 16c0:05dc. Matching by
* VID/PID alone made one physical device claim both drivers (and the INL
* driver would run against kazzo firmware → Device error 0xff). `webusbMatches`
* disambiguates by the iProduct string: Kazzo claims "kazzo"; INL is the
* catch-all for the shared ID.
*/

const KAZZO: DeviceDef = {
id: "KAZZO",
name: "Kazzo",
vendorId: 0x16c0,
productId: 0x05dc,
transport: "webusb",
usbProduct: "kazzo",
systems: [],
description: "",
};
const INL: DeviceDef = {
id: "INL_RETRO",
name: "INL Retro",
vendorId: 0x16c0,
productId: 0x05dc,
transport: "webusb",
// no usbProduct → catch-all for the shared ID
systems: [],
description: "",
};
const defs = [KAZZO, INL];

const usb = (productName: string | undefined, vid = 0x16c0, pid = 0x05dc) =>
({ vendorId: vid, productId: pid, productName }) as unknown as USBDevice;

describe("webusbMatches — shared-VID/PID disambiguation", () => {
it("a 'kazzo' device matches KAZZO, not the INL catch-all", () => {
const d = usb("kazzo");
expect(webusbMatches(d, KAZZO, defs)).toBe(true);
expect(webusbMatches(d, INL, defs)).toBe(false);
});

it("an 'INL Retro-Prog' device matches the INL catch-all, not KAZZO", () => {
const d = usb("INL Retro-Prog");
expect(webusbMatches(d, KAZZO, defs)).toBe(false);
expect(webusbMatches(d, INL, defs)).toBe(true);
});

it("a device with no product string falls to the catch-all (INL), not KAZZO", () => {
const d = usb(undefined);
expect(webusbMatches(d, KAZZO, defs)).toBe(false);
expect(webusbMatches(d, INL, defs)).toBe(true);
});

it("matches the product string as a substring (tolerates suffixes)", () => {
const d = usb("kazzo r2");
expect(webusbMatches(d, KAZZO, defs)).toBe(true);
expect(webusbMatches(d, INL, defs)).toBe(false);
});

it("does not match a different VID/PID", () => {
expect(webusbMatches(usb("kazzo", 0x1234, 0x5678), KAZZO, defs)).toBe(false);
});

it("with no usbProduct siblings, a catch-all matches its VID/PID outright", () => {
const solo: DeviceDef = { ...INL };
expect(webusbMatches(usb("anything"), solo, [solo])).toBe(true);
});
});
86 changes: 72 additions & 14 deletions src/hooks/use-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,47 @@ import type { DeviceDriver, DeviceInfo, Transport } from "@/lib/types";

// ─── Device probing ──────────────────────────────────────────────────────

/**
* Whether a WebUSB device belongs to `dev`, resolving entries that share a
* VID/PID. A def with `usbProduct` matches only when the device's iProduct
* string contains it; a def without one is the catch-all for its VID/PID,
* matched only when no sibling's `usbProduct` claims this device. The Kazzo
* and INL Retro both enumerate as 16c0:05dc — the Kazzo reports iProduct
* "kazzo", so without this both drivers would claim either device and the
* INL driver would run against kazzo firmware (Device error 0xff).
*/
export function webusbMatches(
d: USBDevice,
dev: DeviceDef,
defs: DeviceDef[],
): boolean {
if (d.vendorId !== dev.vendorId || d.productId !== dev.productId) return false;
return matchesUsbProduct(d.productName ?? "", dev, defs);
}

/**
* The iProduct-substring half of {@link webusbMatches}, split out so the
* post-selection check can reuse it once only the product string is known. A
* def with `usbProduct` claims the device when its product string contains the
* substring; a def without one is the catch-all, matched only when no sibling's
* `usbProduct` claims it.
*/
function matchesUsbProduct(
productName: string,
dev: DeviceDef,
defs: DeviceDef[],
): boolean {
if (dev.usbProduct) return productName.includes(dev.usbProduct);
return !defs.some(
(o) =>
o !== dev &&
o.vendorId === dev.vendorId &&
o.productId === dev.productId &&
o.usbProduct &&
productName.includes(o.usbProduct),
);
}

/** Check all browser device APIs for previously-authorized, currently-connected devices. */
async function probeAvailableDevices(): Promise<Set<string>> {
const available = new Set<string>();
Expand All @@ -30,13 +71,10 @@ async function probeAvailableDevices(): Promise<Set<string>> {

try {
const devices = (await navigator.usb?.getDevices()) ?? [];
const defs = entries.map(([, d]) => d);
for (const d of devices) {
for (const [id, dev] of entries) {
if (
dev.transport === "webusb" &&
d.vendorId === dev.vendorId &&
d.productId === dev.productId
)
if (dev.transport === "webusb" && webusbMatches(d, dev, defs))
available.add(id);
}
}
Expand Down Expand Up @@ -82,11 +120,8 @@ async function findAuthorized(
}
case "webusb": {
const devices = (await navigator.usb?.getDevices()) ?? [];
return (
devices.find(
(d) => d.vendorId === dev.vendorId && d.productId === dev.productId,
) ?? null
);
const defs = Object.values(DEVICES);
return devices.find((d) => webusbMatches(d, dev, defs)) ?? null;
}
case "webhid": {
const devices = (await navigator.hid?.getDevices()) ?? [];
Expand Down Expand Up @@ -220,7 +255,13 @@ export function useConnection({
// device-initiated disconnect callback would otherwise see the stale
// driver === null from the closure and skip dispose().
const drv = driverRef.current;
if (drv?.transport?.connected) {
// A device-initiated disconnect can fire mid-initialize, before any driver
// is published. There's nothing to tear down or clear then — bail before
// emitting a spurious "Disconnected" and clearing caller state for a
// connection that never committed. (The Disconnect button only fires with a
// published driver, so it's unaffected.)
if (!drv) return;
if (drv.transport?.connected) {
try {
await drv.transport.disconnect();
} catch (e) {
Expand Down Expand Up @@ -311,6 +352,22 @@ export function useConnection({
return false;
}

// The interactive chooser can only filter by VID/PID, so on a shared
// VID/PID (Kazzo and INL both enumerate as 16c0:05dc) the user can pick
// the sibling unit. Verify the opened device's product string matches
// this def before initializing, so a mis-pick fails fast with a clear
// message instead of an opaque firmware error on the first opcode.
if (
dev.transport === "webusb" &&
!matchesUsbProduct(identity.name ?? "", dev, Object.values(DEVICES))
) {
await transport.disconnect().catch(() => {});
throw new Error(
`Selected USB device "${identity.name}" is not a ${dev.name}. ` +
`Pick the ${dev.name} from the device chooser.`,
);
}

log(`Opened ${transportLabel}: ${identity.name}`);

transport.on("onDisconnect", () => {
Expand All @@ -324,9 +381,10 @@ export function useConnection({
log(entry.preInitLog ?? "Initializing device...");
const info = await drv.initialize();

// Final race check — another path may have published its driver
// while we were awaiting initialize().
if (driverRef.current) {
// Final race check — another path may have published its driver, or
// the device may have been unplugged, while we were awaiting
// initialize(). Don't publish a driver wrapping a closed transport.
if (driverRef.current || !transport.connected) {
await transport.disconnect().catch(() => {});
return false;
}
Expand Down
13 changes: 13 additions & 0 deletions src/lib/core/connection-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { SMS4Driver } from "@/lib/drivers/sms4/sms4-driver";
import { DEVICE_FILTERS as SMS4_FILTERS } from "@/lib/drivers/sms4/sms4-commands";
import { InlTransport } from "@/lib/drivers/inl/inl-transport";
import { INLDriver } from "@/lib/drivers/inl/inl-driver";
import { KazzoTransport } from "@/lib/drivers/kazzo/kazzo-transport";
import { KazzoDriver } from "@/lib/drivers/kazzo/kazzo-driver";
import type {
DeviceDriver,
DeviceIdentity,
Expand Down Expand Up @@ -80,6 +82,17 @@ export const CONNECTION_ENTRIES: Record<string, ConnectionEntry> = {
`Connected: ${info.deviceName} (fw: ${info.firmwareVersion})`,
},

KAZZO: {
createTransport: () => new KazzoTransport(),
connect: (t, { authorized }) =>
authorized
? (t as KazzoTransport).connectWithDevice(authorized as USBDevice)
: (t as KazzoTransport).connect(),
createDriver: (t) => new KazzoDriver(t as KazzoTransport),
postInitLog: (info) =>
`Connected: ${info.deviceName} (fw: ${info.firmwareVersion})`,
},

POWERSAVE: {
createTransport: () => new HidTransport(POWERSAVE_FILTERS),
connect: (t, { authorized }) =>
Expand Down
21 changes: 21 additions & 0 deletions src/lib/core/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ export interface DeviceDef {
vendorId: number | null;
productId: number | null;
transport: TransportType;
/**
* Disambiguates entries that share a VID/PID: a connected device matches
* this def only when its USB iProduct string contains this substring. A def
* without it is the catch-all for its VID/PID — matched only when no
* sibling's `usbProduct` claims the device. The Kazzo and INL Retro both
* enumerate as 16c0:05dc; the Kazzo firmware reports iProduct "kazzo".
*/
usbProduct?: string;
systems: { id: string; name: string }[];
/** Known model identifiers, e.g. ["CECHZM1", "SCPH-98042"]. */
models?: string[];
Expand Down Expand Up @@ -57,6 +65,19 @@ export const DEVICES: Record<string, DeviceDef> = {
"Open-source NES/Famicom cartridge dumper by Infinite NES Lives. " +
"Protocol: gitlab.com/InfiniteNesLives/INL-retro-progdump",
},
KAZZO: {
id: "KAZZO",
name: "Kazzo",
vendorId: 0x16c0,
productId: 0x05dc,
transport: "webusb",
usbProduct: "kazzo",
systems: [{ id: "nes", name: "NES / Famicom" }],
description:
"Open-source NES/Famicom cartridge dumper by naruko (anago host). " +
"Shares the INL Retro's USB VID/PID; identified by its 'kazzo' " +
"product string.",
},
POWERSAVE: {
id: "POWERSAVE",
name: "PowerSaves for Amiibo",
Expand Down
12 changes: 8 additions & 4 deletions src/lib/drivers/inl/inl-device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,14 @@ export class INLDevice {
}

private handleDisconnect = (event: USBConnectionEvent) => {
if (event.device === this.device) {
this.device = null;
this.onDisconnect?.();
}
if (event.device !== this.device) return;
// Physical unplug. The hook's disconnect handler checks transport
// .connected (false once we null the device below) and so skips
// transport.disconnect(), meaning our disconnect() never runs — drop the
// global listener here too (the gone device can't be released/closed).
navigator.usb!.removeEventListener("disconnect", this.handleDisconnect);
this.device = null;
this.onDisconnect?.();
};

// ─── Dictionary Methods ─────────────────────────────────────────────────
Expand Down
Loading