diff --git a/THIRD-PARTY-LICENSES b/THIRD-PARTY-LICENSES index 8d7d892..50ed3fc 100644 --- a/THIRD-PARTY-LICENSES +++ b/THIRD-PARTY-LICENSES @@ -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 diff --git a/src/hooks/use-connection.test.ts b/src/hooks/use-connection.test.ts new file mode 100644 index 0000000..a004050 --- /dev/null +++ b/src/hooks/use-connection.test.ts @@ -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); + }); +}); diff --git a/src/hooks/use-connection.ts b/src/hooks/use-connection.ts index 1fe10a3..cd2d15a 100644 --- a/src/hooks/use-connection.ts +++ b/src/hooks/use-connection.ts @@ -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> { const available = new Set(); @@ -30,13 +71,10 @@ async function probeAvailableDevices(): Promise> { 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); } } @@ -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()) ?? []; @@ -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) { @@ -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", () => { @@ -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; } diff --git a/src/lib/core/connection-registry.ts b/src/lib/core/connection-registry.ts index 56cc659..70ac9c3 100644 --- a/src/lib/core/connection-registry.ts +++ b/src/lib/core/connection-registry.ts @@ -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, @@ -80,6 +82,17 @@ export const CONNECTION_ENTRIES: Record = { `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 }) => diff --git a/src/lib/core/devices.ts b/src/lib/core/devices.ts index b5b2f45..5d6fecb 100644 --- a/src/lib/core/devices.ts +++ b/src/lib/core/devices.ts @@ -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[]; @@ -57,6 +65,19 @@ export const DEVICES: Record = { "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", diff --git a/src/lib/drivers/inl/inl-device.ts b/src/lib/drivers/inl/inl-device.ts index bab5a63..8b002aa 100644 --- a/src/lib/drivers/inl/inl-device.ts +++ b/src/lib/drivers/inl/inl-device.ts @@ -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 ───────────────────────────────────────────────── diff --git a/src/lib/drivers/inl/inl-driver.test.ts b/src/lib/drivers/inl/inl-driver.test.ts index f129463..57e5b47 100644 --- a/src/lib/drivers/inl/inl-driver.test.ts +++ b/src/lib/drivers/inl/inl-driver.test.ts @@ -1,24 +1,34 @@ import { describe, it, expect } from "vitest"; import type { InlTransport } from "./inl-transport"; import { INLDriver } from "./inl-driver"; -import { BUFFER, IO, OPER, STATUS } from "./inl-opcodes"; +import { BUFFER, IO, OPER, PINPORT, STATUS } from "./inl-opcodes"; +import { + M2_IDLE_GATED_MAPPERS, + unsupportedMappersFor, +} from "./unsupported-mappers"; /** - * Driver-level coverage of the readROM/readSave exit invariant: every exit — - * clean or mid-dump fault — must leave the dump-operation engine reset - * (SET_OPERATION RESET + RAW_BUFFER_RESET, from dumpRegion's finally) and - * then reset the I/O layer (IO_RESET, from the driver's finally), so the - * next dump can't inherit engine or bus state. + * Driver-level coverage of: + * - the readROM/readSave exit invariant: every exit — clean or mid-dump + * fault — must leave the dump-operation engine reset (SET_OPERATION + * RESET + RAW_BUFFER_RESET, from dumpRegion's finally) and then reset + * the I/O layer (IO_RESET, from the driver's finally), so the next dump + * can't inherit engine or bus state; + * - the M2-idle-high firmware feature gate: initialize() probes the M2 + * pin level once after NES init and gates the SMD172-family CPLD + * mappers (see ./unsupported-mappers) on the result. */ interface Call { - m: "io" | "nes" | "operation" | "buffer" | "payloadIn"; + m: "io" | "nes" | "operation" | "buffer" | "payloadIn" | "pinport"; op?: number; operand?: number; } class FakeInlDevice { calls: Call[] = []; + /** M2 pin level the CTL_RD probe reads back; "error" makes it throw. */ + m2Level: number | "error" = 0; private payloadCalls = 0; private readonly failOnPayloadCall?: number; @@ -30,6 +40,14 @@ class FakeInlDevice { this.calls.push({ m: "io", op, operand }); return null; } + async pinport(op: number, operand = 0): Promise { + this.calls.push({ m: "pinport", op, operand }); + if (op === PINPORT.CTL_RD && operand === PINPORT.M2) { + if (this.m2Level === "error") throw new Error("Device error 0xff"); + return this.m2Level; + } + return 0; + } async nes(op: number, operand = 0): Promise { this.calls.push({ m: "nes", op, operand }); return 0; @@ -98,15 +116,17 @@ describe("INLDriver teardown", () => { }); it.each([ - [268, 2048], // CPLD refuses this device's writes (hardware-classified) - [470, 1024], // same refusal family (see UNSUPPORTED_MAPPERS) + [268, 2048], // CPLD needs M2 idling high; stock firmware idles it low + [470, 1024], // same board family (see M2_IDLE_GATED_MAPPERS) ])( - "pre-flight-rejects mapper %i without touching the device", + "pre-flight-rejects mapper %i on stock (M2-low) firmware without touching the cart", async (mapper, prgKB) => { // The driver must reject before any cart traffic rather than // produce a boot-bank-mirrored garbage dump. - const fake = new FakeInlDevice(); + const fake = new FakeInlDevice(); // m2Level = 0 → stock firmware const driver = makeDriver(fake); + await driver.initialize(); + fake.calls = []; await expect( driver.readROM({ @@ -118,6 +138,26 @@ describe("INLDriver teardown", () => { }, ); + it.each([268, 470])( + "pre-flight-rejects mapper %i in readSave too, before any cart traffic", + async (mapper) => { + // readSave must gate identically to readROM. Force prgRamSizeBytes > 0 so + // the M2-idle gate — not the "No SRAM to read" guard — is what rejects. + const fake = new FakeInlDevice(); // m2Level = 0 → stock firmware + const driver = makeDriver(fake); + await driver.initialize(); + fake.calls = []; + + await expect( + driver.readSave({ + systemId: "nes", + params: { mapper, prgRamSizeBytes: 8192 }, + }), + ).rejects.toThrow(/INL Retro/); + expect(fake.calls).toHaveLength(0); + }, + ); + it("readSave (default path) ends with the engine reset and I/O reset", async () => { const fake = new FakeInlDevice(); const driver = makeDriver(fake); @@ -138,3 +178,91 @@ describe("INLDriver teardown", () => { expectTeardownTail(fake.calls); }); }); + +/** + * The M2-idle-high firmware feature gate. initialize() probes the M2 pin + * level once, right after NES init (PINPORT CTL_RD, operand M2): low = + * stock firmware → the SMD172-family CPLD mappers stay gated; high = + * m2-idle-high firmware → fully enabled; probe error = treated as stock. + */ +describe("INLDriver M2-idle-high feature gate", () => { + const gatedIds = [...M2_IDLE_GATED_MAPPERS.keys()]; + // Effective unsupported list = the always-unsupported mappers (e.g. 413, + // gated on a firmware feature the M2 probe never lifts) plus the M2-gated + // ones on stock firmware only. + const stockUnsupported = [...unsupportedMappersFor(false).keys()]; + const highUnsupported = [...unsupportedMappersFor(true).keys()]; + + it("probes the M2 pin exactly once, after NES init", async () => { + const fake = new FakeInlDevice(); + const driver = makeDriver(fake); + + await driver.initialize(); + + expect(fake.calls).toEqual([ + { m: "io", op: IO.IO_RESET, operand: 0 }, + { m: "io", op: IO.NES_INIT, operand: 0 }, + { m: "pinport", op: PINPORT.CTL_RD, operand: PINPORT.M2 }, + ]); + }); + + it("keeps the gated mappers unsupported when M2 reads low (stock firmware)", async () => { + const fake = new FakeInlDevice(); + fake.m2Level = 0; + const driver = makeDriver(fake); + + await driver.initialize(); + + expect(driver.m2IdleHigh).toBe(false); + expect(driver.capabilities[0].unsupportedMappers).toEqual(stockUnsupported); + }); + + it("enables the gated mappers when M2 reads high (m2-idle-high firmware)", async () => { + const fake = new FakeInlDevice(); + fake.m2Level = 1; + const driver = makeDriver(fake); + + await driver.initialize(); + + expect(driver.m2IdleHigh).toBe(true); + // The M2-gated mappers are lifted; only the always-unsupported ones (413) + // remain — an M2-idle-high firmware does not address its missing memtype. + expect(driver.capabilities[0].unsupportedMappers).toEqual(highUnsupported); + for (const id of gatedIds) { + expect(driver.capabilities[0].unsupportedMappers).not.toContain(id); + } + + // A formerly-gated mapper now dumps end to end (one 16 KiB outer bank). + const data = await driver.readROM({ + systemId: "nes", + params: { mapper: 268, prgSizeBytes: 16384, chrSizeBytes: 0 }, + }); + expect(data.length).toBe(16384); + }); + + it("treats a probe error as stock firmware (gate stays closed)", async () => { + const fake = new FakeInlDevice(); + fake.m2Level = "error"; + const driver = makeDriver(fake); + + await driver.initialize(); // must not throw + + expect(driver.m2IdleHigh).toBe(false); + expect(driver.capabilities[0].unsupportedMappers).toEqual(stockUnsupported); + }); + + it("gates the mappers before initialize() has probed (stock-equivalent default)", async () => { + const fake = new FakeInlDevice(); + const driver = makeDriver(fake); + + expect(driver.m2IdleHigh).toBe(false); + expect(driver.capabilities[0].unsupportedMappers).toEqual(stockUnsupported); + await expect( + driver.readROM({ + systemId: "nes", + params: { mapper: 268, prgSizeBytes: 16384, chrSizeBytes: 0 }, + }), + ).rejects.toThrow(/INL Retro/); + expect(fake.calls).toHaveLength(0); + }); +}); diff --git a/src/lib/drivers/inl/inl-driver.ts b/src/lib/drivers/inl/inl-driver.ts index bccb1a0..49a933f 100644 --- a/src/lib/drivers/inl/inl-driver.ts +++ b/src/lib/drivers/inl/inl-driver.ts @@ -19,15 +19,17 @@ import type { } from "@/lib/types"; import type { INLDevice } from "./inl-device"; import type { InlTransport } from "./inl-transport"; -import { IO, MEM, MAPVAR } from "./inl-opcodes"; +import { IO, MEM, MAPVAR, PINPORT } from "./inl-opcodes"; import { dumpRegion } from "./inl-dump"; import { InlNesBus } from "./inl-nes-bus"; import { detectCiramMirroring } from "./detect-mirroring"; import { getNesMapper } from "@/lib/systems/nes/mappers"; -// Catalog mappers whose CPLD refuses this device's synthesized writes, with -// the full hardware account of why. Used both to grey them out in the config -// UI and to pre-flight-reject them in readROM. -import { UNSUPPORTED_MAPPERS } from "./unsupported-mappers"; +// Catalog mappers gated on the firmware's M2 idle level (the SMD172-family +// CPLD boards need M2 to idle high; stock firmware idles it low). The gate +// is feature-detected per connection in initialize(); when closed, the +// affected mappers are greyed out in the config UI and pre-flight-rejected +// in readROM. +import { unsupportedMappersFor } from "./unsupported-mappers"; export class INLDriver implements DeviceDriver { readonly id = "inl-retro"; @@ -38,11 +40,25 @@ export class INLDriver implements DeviceDriver { operations: ["dump_rom"], autoDetect: true, // Greys these mappers out in the config UI; readROM pre-flight- - // rejects them too. See ./unsupported-mappers for why. - unsupportedMappers: [...UNSUPPORTED_MAPPERS.keys()], + // rejects them too. Pre-probe default assumes stock firmware (M2 + // idles low); initialize() re-derives this from the M2 probe. + unsupportedMappers: [...unsupportedMappersFor(false).keys()], }, ]; + /** + * Whether the connected firmware parks M2 high between bus operations + * (the feature/m2-idle-high branch of INL-retro-progdump) — the feature + * the SMD172-family CPLD mappers require. Probed once per connection in + * initialize(); false (stock-equivalent) until then. + */ + private _m2IdleHigh = false; + get m2IdleHigh(): boolean { + return this._m2IdleHigh; + } + /** Effective unsupported-mapper map for this session (see ./unsupported-mappers). */ + private unsupportedMappers = unsupportedMappersFor(false); + private events: Partial = {}; /** * The connection transport, exposed so the generic connection lifecycle @@ -78,6 +94,26 @@ export class INLDriver implements DeviceDriver { await this.inlDevice.io(IO.IO_RESET); await this.inlDevice.io(IO.NES_INIT); + // Feature-detect the firmware's M2 idle level by reading the pin once: + // stock firmware leaves M2/PC0 low after NES init; the m2-idle-high + // build (feature/m2-idle-high branch of INL-retro-progdump) parks it + // high. The probe (PINPORT CTL_RD, operand M2) exists in stock firmware + // too; any probe error is treated as stock. + try { + this._m2IdleHigh = + (await this.inlDevice.pinport(PINPORT.CTL_RD, PINPORT.M2)) !== 0; + } catch { + this._m2IdleHigh = false; + } + this.unsupportedMappers = unsupportedMappersFor(this._m2IdleHigh); + const nesCapability = this.capabilities.find((c) => c.systemId === "nes"); + if (nesCapability) { + nesCapability.unsupportedMappers = [...this.unsupportedMappers.keys()]; + } + if (this._m2IdleHigh) { + this.log("Firmware idles M2 high — M2-gated CPLD mappers enabled"); + } + this.log("Device ready"); return { @@ -118,22 +154,32 @@ export class INLDriver implements DeviceDriver { return result?.cartInfo ?? null; } - async readROM(config: ReadConfig, signal?: AbortSignal): Promise { - const mapperId = (config.params.mapper as number) ?? 0; - const prgKB = ((config.params.prgSizeBytes as number) ?? 32768) / 1024; - const chrKB = ((config.params.chrSizeBytes as number) ?? 8192) / 1024; - const miscKB = ((config.params.miscSizeBytes as number) ?? 0) / 1024; - + /** + * Resolve a mapper id to its catalog implementation, rejecting anything the + * connected firmware can't dump (the M2-idle gate + always-unsupported set, + * built per-session in initialize()). Pure pre-flight — no device I/O — so + * both readROM and readSave can call it before any bus setup. + */ + private resolveMapper(mapperId: number) { const mapper = getNesMapper(mapperId); if (!mapper) throw new Error(`Unsupported mapper: ${mapperId}`); - const unsupportedReason = UNSUPPORTED_MAPPERS.get(mapperId); + const unsupportedReason = this.unsupportedMappers.get(mapperId); if (unsupportedReason) { throw new Error( - `Mapper ${mapperId} (${mapper.name}) can't be dumped with the INL Retro: ` + - `${unsupportedReason}. The cart itself is fine — use a dumper this ` + - "board accepts.", + `Mapper ${mapperId} (${mapper.name}) can't be dumped with this INL ` + + `Retro firmware: ${unsupportedReason}. The cart itself is fine.`, ); } + return mapper; + } + + async readROM(config: ReadConfig, signal?: AbortSignal): Promise { + const mapperId = (config.params.mapper as number) ?? 0; + const prgKB = ((config.params.prgSizeBytes as number) ?? 32768) / 1024; + const chrKB = ((config.params.chrSizeBytes as number) ?? 8192) / 1024; + const miscKB = ((config.params.miscSizeBytes as number) ?? 0) / 1024; + + const mapper = this.resolveMapper(mapperId); // Each mapper drives the cart through the bus; `bus.setup()` (issued // inside the mapper before each region) handles the reset/init. The @@ -240,8 +286,7 @@ export class INLDriver implements DeviceDriver { if (sramKB <= 0) throw new Error("No SRAM to read"); - const mapper = getNesMapper(mapperId); - if (!mapper) throw new Error(`Unsupported mapper: ${mapperId}`); + const mapper = this.resolveMapper(mapperId); const bus = new InlNesBus(this.inlDevice, signal); this.log(`Reading ${sramKB}KB SRAM...`); diff --git a/src/lib/drivers/inl/inl-nes-bus.ts b/src/lib/drivers/inl/inl-nes-bus.ts index 54820af..5047c9f 100644 --- a/src/lib/drivers/inl/inl-nes-bus.ts +++ b/src/lib/drivers/inl/inl-nes-bus.ts @@ -27,6 +27,7 @@ export class InlNesBus implements NesBus { } async writeCpu(addr: number, value: number): Promise { + this.signal?.throwIfAborted(); await this.device.nes(NES.NES_CPU_WR, addr, value); } @@ -37,6 +38,7 @@ export class InlNesBus implements NesBus { // it takes for the shared `mmc1` mapper to clock the shift register // through firmware instead of per-bit writes. async writeSerialRegister(addr: number, value: number): Promise { + this.signal?.throwIfAborted(); await this.device.nes(NES.NES_MMC1_WR, addr, value); } diff --git a/src/lib/drivers/inl/inl-opcodes.ts b/src/lib/drivers/inl/inl-opcodes.ts index 454f22b..5ee3740 100644 --- a/src/lib/drivers/inl/inl-opcodes.ts +++ b/src/lib/drivers/inl/inl-opcodes.ts @@ -25,6 +25,9 @@ export const PINPORT = { CTL_RD: 6, // RL=4 — read a control pin ADDR_SET: 17, // set address bus value // Control pin operand IDs for CTL_RD + // M2/phi2 CPU clock (PC0) — its post-NES_INIT idle level feature-detects + // stock vs M2-idle-high firmware (see ./unsupported-mappers). + M2: 0, CIA10: 11, // CIRAM A10 (nametable mirroring) CICE: 6, // CIRAM /CE } as const; @@ -53,11 +56,16 @@ export const NES = { NES_DUALPORT_WR: 0x05, // Flash-program a byte on an MMC3 board: three JEDEC unlock writes // ($D555/$AAAA/$D555), then the (operand, misc) write, then $8000<-2 and - // a stability poll-read, all inside one USB transaction. This is one of - // the firmware's flash-PROGRAM opcodes — nabu is a read-only dumper and - // never programs a cart, so `INLDevice.nes` hard-rejects the whole family - // (see NES_FLASH_WRITE_OPCODES below). Kept declared because it documents - // the device surface and is the one a one-off experiment once misused. + // a stability poll-read — ALL inside one USB transaction, each a full + // nes_cpu_wr M2 cycle microseconds apart. This is one of the firmware's + // flash-PROGRAM opcodes — nabu is a read-only dumper and never programs a + // cart, so `INLDevice.nes` hard-rejects the whole family (see + // NES_FLASH_WRITE_OPCODES below). Kept declared because it documents the + // device surface and is the one a one-off experiment once misused: the tail + // poll re-reads the WRITTEN address until two consecutive reads agree, so a + // target whose reads flicker (e.g. $5xxx on the mapper-268 board) spins the + // firmware until a physical replug — which is also why it is NOT used as an + // M2-burst write primitive (see M2_IDLE_GATED_MAPPERS in ./unsupported-mappers). MMC3_PRG_FLASH_WR: 0x07, SET_CUR_BANK: 0x20, SET_BANK_TABLE: 0x21, diff --git a/src/lib/drivers/inl/unsupported-mappers.ts b/src/lib/drivers/inl/unsupported-mappers.ts index 4002d7d..1366c1e 100644 --- a/src/lib/drivers/inl/unsupported-mappers.ts +++ b/src/lib/drivers/inl/unsupported-mappers.ts @@ -1,99 +1,89 @@ /** - * Mappers the INL Retro Programmer cannot drive, even though they exist in - * the shared, device-agnostic NES catalog. + * Catalog mappers gated on an INL firmware feature: M2 idling HIGH. * - * These boards reimplement their mapper in a CPLD that refuses the INL - * firmware's synthesized bus writes: the firmware idles M2 and emits a - * single pulse per write, while the CPLD's reset detector wants sustained - * M2 clocking (a real console runs M2 continuously at 1.79 MHz) before it - * will latch a register. Reads are unaffected — the firmware's page-read - * loop issues many back-to-back bus cycles inside one USB transaction — so - * a dump that ignored this would return a plausible-length file that is - * really the boot bank mirrored across every slot. + * The SMD172-family CPLD reissue boards (mapper 268 CoolBoy / Mindkids, + * mapper 470 INX_007T_V01) require the M2/phi2 clock to idle high between + * bus operations. Sustained M2-low reads as console-off/reset: register + * writes are ignored or reverted, while reads are unaffected. Stock INL + * firmware drives M2 low after NES init and leaves it low between USB + * transactions, so every bank-select write is silently undone and a dump + * would return a plausible-length file that is really the boot bank + * mirrored across every slot. * - * `UNSUPPORTED_MAPPERS` maps each such mapper id to the reason shown in - * the driver's pre-flight rejection, worded to its own evidence basis. The - * driver rejects these ids before any cart traffic (so no garbage dump is - * produced) and feeds the key set to `capability.unsupportedMappers`, which - * greys the options out in the config UI. The mappers stay in the catalog — - * they are implemented, spec-tested, and (mapper 268) dumpable on devices - * whose bus drives the CPLD; the INL is simply the wrong tool. + * The fix is firmware, not host protocol: a build that parks M2 high at + * init and at the exit of every bus primitive (the feature/m2-idle-high + * branch of INL-retro-progdump) dumps these boards. Hardware-verified + * 2026-06-10: a 2 MiB mapper-268 multicart dumped byte-perfect against the + * reference on that firmware, and a conventional MMC3 cart (256 KiB PRG + + * 128 KiB CHR) dumped byte-perfect on the same build — the new idle level + * does not disturb ordinary mappers. Mapper 470 is the same board family + * but has not yet been separately verified on it. * - * ── Mapper 268 (CoolBoy / Mindkids MMC3-clone multicart) ── + * The driver feature-detects which firmware is connected: after IO_RESET + + * NES_INIT it reads the M2 pin level once (PINPORT CTL_RD, operand M2 — + * present in stock firmware too, so the probe itself is universal). Low → + * stock: these mappers stay pre-flight-rejected and greyed out via + * `capability.unsupportedMappers`. High → m2-idle-high build: both fully + * enabled. A probe error counts as low (stock-equivalent). This map is the + * single source of which mapper ids are M2-idle-gated; + * `unsupportedMappersFor` applies the probe result. The mappers stay in the + * shared catalog regardless — they are implemented, spec-tested, and + * dumpable on devices whose bus the CPLD accepts. * - * Classified on hardware 2026-06-07 with the mapper's built-in - * failure-classification pass (see `coolboy.ts`), verdict - * `writes-not-landing` with the inner-MMC3 discrimination probe also - * negative: + * Historical note: a 2026-06-07 hardware classification of mapper 268 on + * stock firmware measured 0/1024 register writes landing (with reads + * flawless) and concluded the CPLD needs *sustained M2 clocking* that this + * AVR could never provide alongside V-USB. The measurements were real but + * mis-attributed: a write that latches and is then reverted when M2 idles + * low afterwards is indistinguishable, at probe time, from a write that + * never latched. The actual requirement — M2 parked high between + * operations — is a few-line firmware change, not an architectural wall. * - * - The read path is flawless. All 128 GNROM bank reads returned the - * cart's power-on window byte-perfect (it matches flash offset 0 of a - * reference dump made on other hardware), 256 times in a row — contacts, - * power, and read timing are not in question. - * - Zero of 1,024 outer-register writes ($5000-$5003, the - * hardware-verified two-phase menu-mimicking sequence with 5 ms settles) - * latched. - * - The inner MMC3 registers ($8000/$8001) — the very write path real MMC3 - * ASICs accept from this device — did not latch either. + * (Don't probe these boards via MMC3_PRG_FLASH_WR — its tail polls the + * written address until two consecutive reads agree, and $5xxx reads flicker + * on the mapper-268 board, spinning the firmware until a physical replug.) * - * A follow-up experiment (also 2026-06-07) bounded it from the other side: - * ten back-to-back M2 read+write cycles inside ONE USB transaction - * (`NES_MMC1_WR`'s burst, microsecond gaps) still did not latch the inner R6 - * register, so no stock-firmware write primitive can cross the threshold. - * Only a firmware modification that keeps M2 running through the write could, - * and on this board even that is architecturally hostile: M2 (PC0) has no - * timer output on the ATmega164A, so continuous M2 must be bit-banged by the - * same core that has to keep answering V-USB's cycle-critical INT0 - * interrupts mid-operation — gapless M2 and a live USB stack are mutually - * exclusive here. The vendor's STM32-based boards (hardware USB, real - * timers) would be the realistic platform. Consistent with all of this, a - * dumper that drives denser sustained bus activity latches these registers - * with only occasional stochastic dropouts; discrete logic and real mapper - * ASICs (everything else in the catalog) latch fine from single M2 pulses. + * ── Mapper 413 (BATMAP) — a different gate, never lifted by M2 idle ── * - * (Don't retry the probe via MMC3_PRG_FLASH_WR: its tail polls the written - * address until two consecutive reads agree, and $5xxx reads flicker on this - * cart — the firmware spins until a physical replug.) - * - * ── Mapper 470 (INX_007T_V01 reissue board) ── - * - * Same refusal family, weaker evidence basis (calibrate accordingly): an - * April 2026 session on other hardware recorded "clock-reset blocks bank - * progression" for this exact cart on this device class (recalled, the - * session itself was lost), and the board's bank latch is demonstrably - * cadence-sensitive — it defeated even a dumper whose writes this CPLD - * generation otherwise accepts, until the vendor's per-chunk re-latch recipe - * was matched (see `inx007t.ts`). Given the formal mapper-268 classification - * of this family on this device, pre-flight-rejecting 470 is the honest - * default; an instrumented attempt could overturn it. - * - * ── Mapper 413 (BATMAP) ── - * - * A different reason from the boards above: the INL drives this mapper's - * registers fine and its PRG/CHR dump correctly. The block is the 8 MiB - * serial sample flash, which the CPLD clocks from M2 — reading it needs - * the firmware's NESCPU_SPI413 memtype to pace eight cart-ROM clock reads - * per byte (see InlNesBus.readSpiDataPort). That memtype is not in a - * released INL firmware yet, so the flash reads as solid 0xFF and the cart - * cannot be fully dumped. The read path is implemented and left dormant - * behind this entry — delete the 413 row below to re-enable it once the - * upstream firmware lands: + * Not M2-idle-gated: the INL drives this mapper's registers fine and dumps + * its PRG/CHR correctly, so an M2-idle-high firmware does NOT enable it. + * The block is the 8 MiB serial sample flash, which the CPLD clocks from + * M2 — reading it needs the firmware's NESCPU_SPI413 memtype to pace eight + * cart-ROM clock reads per byte (see InlNesBus.readSpiDataPort). That memtype + * is not in a released INL firmware yet, so the flash reads as solid 0xFF and + * the cart cannot be fully dumped. The read path is implemented and left + * dormant behind ALWAYS_UNSUPPORTED_MAPPERS below — delete the 413 entry + * there to re-enable it once the upstream firmware lands: * https://gitlab.com/InfiniteNesLives/INL-retro-progdump/-/merge_requests/45 */ -/** INL-unsupported mapper id → reason shown in the driver's pre-flight error. */ -export const UNSUPPORTED_MAPPERS = new Map([ +/** M2-idle-gated mapper id → reason shown in the stock-firmware pre-flight error. */ +export const M2_IDLE_GATED_MAPPERS: ReadonlyMap = new Map([ [ 268, - "the board's CPLD ignores this device's register writes, so every bank " + - "reads back as the boot menu (hardware-verified)", + "this firmware idles the M2 clock low between bus cycles, which the " + + "board's CPLD treats as console-off — register writes are reverted " + + "and every bank reads back as the boot menu. An M2-idle-high " + + "firmware build (feature/m2-idle-high branch of INL-retro-progdump) " + + "enables this mapper", ], [ 470, - "the board family refuses this device's synthesized writes (same CPLD " + - "family as mapper 268, plus a recorded failure of this exact cart on " + - "this device class)", + "this firmware idles the M2 clock low between bus cycles, which this " + + "CPLD board family (same family as mapper 268) treats as console-off " + + "— register writes are reverted. An M2-idle-high firmware build " + + "(feature/m2-idle-high branch of INL-retro-progdump) enables this " + + "mapper", ], +]); + +/** + * Mappers the INL cannot dump on ANY current firmware, for reasons unrelated + * to the M2 idle level — so the M2 probe never lifts them. Mapper 413 (BATMAP) + * needs the unreleased NESCPU_SPI413 memtype to read its serial sample flash + * (see the BATMAP note above). + */ +const ALWAYS_UNSUPPORTED_MAPPERS: ReadonlyMap = new Map([ [ 413, "its 8 MiB serial sample flash needs the NESCPU_SPI413 firmware memtype " + @@ -101,3 +91,18 @@ export const UNSUPPORTED_MAPPERS = new Map([ "flash reads as 0xFF until then)", ], ]); + +/** + * The effective unsupported-mapper map for a session, given the probed + * firmware M2 idle level. The always-unsupported mappers apply regardless; + * the M2-idle-gated ones are added only on stock (M2-low) firmware and lifted + * by an M2-idle-high build. + */ +export function unsupportedMappersFor( + m2IdleHigh: boolean, +): ReadonlyMap { + return new Map([ + ...ALWAYS_UNSUPPORTED_MAPPERS, + ...(m2IdleHigh ? [] : M2_IDLE_GATED_MAPPERS), + ]); +} diff --git a/src/lib/drivers/kazzo/detect-mirroring.ts b/src/lib/drivers/kazzo/detect-mirroring.ts new file mode 100644 index 0000000..42951aa --- /dev/null +++ b/src/lib/drivers/kazzo/detect-mirroring.ts @@ -0,0 +1,18 @@ +/** + * Nametable-mirroring detection for the Kazzo dumper. + * + * Kazzo exposes a single hardware probe (VRAM_CONNECTION) that reports how + * the cartridge wires PPU A10/A11 to CIRAM A10. Like the reference host + * (anago), we only resolve vertical vs horizontal from it — single-screen + * mirroring is mapper-controlled and not visible to the probe. + */ + +import type { KazzoDevice } from "./kazzo-device"; +import { VRAM_VERTICAL } from "./kazzo-opcodes"; + +export async function detectKazzoMirroring( + device: KazzoDevice, +): Promise { + const pattern = await device.vramConnection(); + return pattern === VRAM_VERTICAL ? "vertical" : "horizontal"; +} diff --git a/src/lib/drivers/kazzo/firmware-m2.test.ts b/src/lib/drivers/kazzo/firmware-m2.test.ts new file mode 100644 index 0000000..af0163a --- /dev/null +++ b/src/lib/drivers/kazzo/firmware-m2.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "vitest"; +import { classifyKazzoFirmware } from "./firmware-m2"; +import { VERSION_STRING_SIZE } from "./kazzo-opcodes"; + +/** + * The firmware-era classifier behind the M2-idle gate. Fail-safe is the + * invariant under test: only a self-identifying version string — a + * NUL-terminated pre-flip "kazzo16 0.1.0".."0.1.2", or the "0.1.3+m2" fork — + * opens the gate. Every other shape classifies as idle-low, including the + * clipped build's erased version section (32 × 0xFF): capable in practice, + * but a blank section is not an identity, so it gates with the rest. + */ + +/** A NUL-terminated, zero-padded 32-byte version section. */ +function versionBytes(s: string): Uint8Array { + const out = new Uint8Array(VERSION_STRING_SIZE); + out.set(new TextEncoder().encode(s)); + return out; +} + +describe("classifyKazzoFirmware", () => { + it("gates 32 bytes of 0xFF (blank version section is not an identity)", () => { + const r = classifyKazzoFirmware( + new Uint8Array(VERSION_STRING_SIZE).fill(0xff), + ); + expect(r.m2IdleHigh).toBe(false); + expect(r.label).toMatch(/unidentified build.*0\.1\.3\+m2/); + }); + + it.each([ + ["kazzo16 0.1.3+m2 / Jun 10 2026"], + ["kazzo16 0.1.3+m2"], + ])("classifies the m2-idle-high fork %j as M2 idle high", (text) => { + const r = classifyKazzoFirmware(versionBytes(text)); + expect(r.m2IdleHigh).toBe(true); + expect(r.label).toBe(text); + }); + + it.each([["kazzo16 0.1.3+m2x / x"], ["kazzo16 0.1.3+m22"]])( + "does not let the fork-lookalike %j open the gate", + (text) => { + expect(classifyKazzoFirmware(versionBytes(text)).m2IdleHigh).toBe(false); + }, + ); + + it.each(["kazzo16 0.1.0", "kazzo16 0.1.1", "kazzo16 0.1.2"])( + "classifies the pre-flip version %j as M2 idle high", + (v) => { + expect(classifyKazzoFirmware(versionBytes(v))).toEqual({ + m2IdleHigh: true, + label: v, + }); + }, + ); + + it.each([ + "kazzo16 0.1.3", // the 2010-01-25 polarity flip's release, and beyond + "kazzo16 0.2.0", + "kazzo^8 0.1.2", // different firmware family + "anago", + ])("gates the post-flip / unrecognized version %j", (v) => { + expect(classifyKazzoFirmware(versionBytes(v))).toEqual({ + m2IdleHigh: false, + label: v, + }); + }); + + it("does not let a hypothetical 0.1.2x version open the gate", () => { + expect( + classifyKazzoFirmware(versionBytes("kazzo16 0.1.20")).m2IdleHigh, + ).toBe(false); + }); + + it("gates non-printable garbage and labels it by hex fingerprint", () => { + const bytes = new Uint8Array(VERSION_STRING_SIZE).fill(0x01); + const r = classifyKazzoFirmware(bytes); + expect(r.m2IdleHigh).toBe(false); + expect(r.label).toMatch(/^unknown \(0101/); + }); + + it("gates a printable pre-flip string that lacks the NUL terminator", () => { + // Not the known fingerprint shape: the real version section is + // NUL-terminated. An untrusted shape never opens the gate. + const bytes = new Uint8Array(VERSION_STRING_SIZE).fill(0x20); + bytes.set(new TextEncoder().encode("kazzo16 0.1.2")); + expect(classifyKazzoFirmware(bytes).m2IdleHigh).toBe(false); + }); + + it("gates a short read, even one that is all 0xFF", () => { + const r = classifyKazzoFirmware(new Uint8Array(16).fill(0xff)); + expect(r.m2IdleHigh).toBe(false); + expect(r.label).toMatch(/short version read/); + }); + + it("gates an empty response", () => { + expect(classifyKazzoFirmware(new Uint8Array(0)).m2IdleHigh).toBe(false); + }); + + it("gates a failed transfer (null)", () => { + const r = classifyKazzoFirmware(null); + expect(r.m2IdleHigh).toBe(false); + expect(r.label).toMatch(/version read failed/); + }); +}); diff --git a/src/lib/drivers/kazzo/firmware-m2.ts b/src/lib/drivers/kazzo/firmware-m2.ts new file mode 100644 index 0000000..aca04cf --- /dev/null +++ b/src/lib/drivers/kazzo/firmware-m2.ts @@ -0,0 +1,82 @@ +/** + * Kazzo firmware M2-idle classification. + * + * The FIRMWARE_VERSION request (a benign control-IN read of the version + * section at flash 0x3780, present in every firmware era — never to be + * confused with FIRMWARE_PROGRAM, which nabu refuses outright) returns a + * 32-byte response that fingerprints the build, and with it the firmware's + * M2 idle level (see ./unsupported-mappers for why that level matters): + * + * - NUL-terminated ASCII "kazzo16 0.1.0" / "0.1.1" / "0.1.2" — pre-flip + * builds (2009-11-01 through 2010-01-24) → M2 idles HIGH. + * - NUL-terminated ASCII "kazzo16 0.1.3+m2 …" — the maintained + * m2-idle-high firmware branch, which restores the pre-flip idle + * polarity and names the capability in its version string → HIGH. + * - Anything else → assume M2 idles LOW. Fail-safe: only builds that + * positively identify as idle-high open the gate. That includes + * 32 bytes of 0xFF (a build whose version section is blank — e.g. the + * historical clipped distribution of the 2010-01 firmware): it happens + * to be capable, but an erased version section is not an identity, so + * it gates with a label pointing at the self-identifying build. + */ + +import { VERSION_STRING_SIZE } from "./kazzo-opcodes"; + +export interface KazzoFirmwareClass { + /** True when this firmware era idles M2 high between bus cycles. */ + m2IdleHigh: boolean; + /** Human-readable build identity — panel-safe, used as the firmware version. */ + label: string; +} + +/** Pre-flip version strings; the boundary stops "0.1.2" matching "0.1.20". */ +const IDLE_HIGH_VERSIONS = /^kazzo16 0\.1\.[0-2](?!\d)/; +/** The m2-idle-high firmware branch; boundary keeps "+m2x" lookalikes out. */ +const IDLE_HIGH_FORK = /^kazzo16 0\.1\.3\+m2(?=[ /]|$)/; + +/** The bytes up to the NUL as printable ASCII, or null if absent/empty/unprintable. */ +function printableVersionString(bytes: Uint8Array): string | null { + const nul = bytes.indexOf(0); + if (nul <= 0) return null; // no terminator, or empty — not a version string + const text = bytes.subarray(0, nul); + if (!text.every((b) => b >= 0x20 && b <= 0x7e)) return null; + return String.fromCharCode(...text); +} + +/** + * Classify a FIRMWARE_VERSION response. `null` means the transfer itself + * failed; every unrecognized shape classifies as idle-LOW (gated). + */ +export function classifyKazzoFirmware( + bytes: Uint8Array | null, +): KazzoFirmwareClass { + if (bytes === null) { + return { m2IdleHigh: false, label: "unknown (version read failed)" }; + } + if (bytes.length !== VERSION_STRING_SIZE) { + return { m2IdleHigh: false, label: "unknown (short version read)" }; + } + if (bytes.every((b) => b === 0xff)) { + // Capable in practice (the historical clipped 2010-01 distribution), + // but a blank version section is not an identity — gate, and point at + // the build that says what it is. + return { + m2IdleHigh: false, + label: + "unidentified build (version section blank) — flash the " + + "kazzo16 0.1.3+m2 build for CPLD-cart support", + }; + } + + const text = printableVersionString(bytes); + if (text === null) { + const hex = Array.from(bytes.subarray(0, 8), (b) => + b.toString(16).padStart(2, "0"), + ).join(""); + return { m2IdleHigh: false, label: `unknown (${hex}…)` }; + } + return { + m2IdleHigh: IDLE_HIGH_VERSIONS.test(text) || IDLE_HIGH_FORK.test(text), + label: text, + }; +} diff --git a/src/lib/drivers/kazzo/kazzo-device.test.ts b/src/lib/drivers/kazzo/kazzo-device.test.ts new file mode 100644 index 0000000..9e974f5 --- /dev/null +++ b/src/lib/drivers/kazzo/kazzo-device.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect } from "vitest"; +import { KazzoDevice } from "./kazzo-device"; +import { + REQUEST, + REFUSED_REQUESTS, + WRITE_XOR_MASK, + VRAM_VERTICAL, + VERSION_STRING_SIZE, +} from "./kazzo-opcodes"; + +interface OutCall { + request: number; + value: number; + index: number; + data: Uint8Array; +} +interface InCall { + request: number; + value: number; + index: number; + length: number; +} + +/** + * Fake USBDevice recording every control transfer. IN transfers are served + * by `respond` (keyed off the request) so reads can be scripted. + */ +function fakeUsb(respond: (c: InCall) => Uint8Array) { + const outCalls: OutCall[] = []; + const inCalls: InCall[] = []; + const usb = { + opened: true, + async controlTransferIn( + setup: { request: number; value: number; index: number }, + length: number, + ): Promise { + const c = { ...setup, length }; + inCalls.push(c); + const bytes = respond(c); + return { + data: new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength), + status: "ok", + } as USBInTransferResult; + }, + async controlTransferOut( + setup: { request: number; value: number; index: number }, + data: BufferSource, + ): Promise { + const bytes = new Uint8Array(data as ArrayBuffer); + outCalls.push({ ...setup, data: bytes }); + return { status: "ok", bytesWritten: bytes.length } as USBOutTransferResult; + }, + }; + return { usb, outCalls, inCalls }; +} + +function makeDevice(respond: (c: InCall) => Uint8Array = () => new Uint8Array(1)) { + const inl = new KazzoDevice(); + const fake = fakeUsb(respond); + (inl as unknown as { device: unknown }).device = fake.usb; + return { device: inl, ...fake }; +} + +describe("KazzoDevice writes", () => { + it("XOR-masks outgoing CPU write bytes with 0xA5", async () => { + const { device, outCalls } = makeDevice(); + await device.cpuWrite(0x8000, 0x42); + expect(outCalls).toHaveLength(1); + expect(outCalls[0].request).toBe(REQUEST.CPU_WRITE_6502); + expect(outCalls[0].value).toBe(0x8000); + // The single data byte is transmitted XORed; firmware un-masks it. + expect(Array.from(outCalls[0].data)).toEqual([0x42 ^ WRITE_XOR_MASK]); + }); + + it("masks a 0xFF byte (the run-length case the XOR exists for)", async () => { + const { device, outCalls } = makeDevice(); + await device.cpuWrite(0xa000, 0xff); + expect(outCalls[0].data[0]).toBe(0xff ^ WRITE_XOR_MASK); // 0x5a + }); + + it("cpuWriteBytes sends the whole payload to one address in ONE transfer (XOR-masked)", async () => { + const { device, outCalls } = makeDevice(); + // The MMC1 serial-load shape: five bytes to one register address. + await device.cpuWriteBytes(0xe000, new Uint8Array([0, 1, 0, 0, 1])); + expect(outCalls).toHaveLength(1); + expect(outCalls[0].request).toBe(REQUEST.CPU_WRITE_6502); + expect(outCalls[0].value).toBe(0xe000); + expect(Array.from(outCalls[0].data)).toEqual( + [0, 1, 0, 0, 1].map((b) => b ^ WRITE_XOR_MASK), + ); + }); +}); + +describe("KazzoDevice reads", () => { + it("reassembles a multi-page read across 256-byte chunks", async () => { + // Serve a ramp byte = low byte of the requested address, so the + // reassembled buffer is verifiable against the addresses requested. + const { device, inCalls } = makeDevice((c) => { + const out = new Uint8Array(c.length); + for (let i = 0; i < c.length; i++) out[i] = (c.value + i) & 0xff; + return out; + }); + const data = await device.cpuRead(0x8000, 0x250); // 592 bytes = 2x256 + 80 + expect(data).toHaveLength(0x250); + expect(inCalls.map((c) => [c.value, c.length])).toEqual([ + [0x8000, 0x100], + [0x8100, 0x100], + [0x8200, 0x50], + ]); + expect(data[0]).toBe(0x00); // $8000 low byte + expect(data[0x100]).toBe(0x00); // $8100 low byte + expect(data[0x24f]).toBe((0x8200 + 0x4f) & 0xff); + }); + + it("reports progress at each page boundary", async () => { + const { device } = makeDevice((c) => new Uint8Array(c.length)); + const seen: Array<[number, number]> = []; + await device.cpuRead(0x8000, 0x200, (read, total) => seen.push([read, total])); + expect(seen).toEqual([ + [0x100, 0x200], + [0x200, 0x200], + ]); + }); + + it("throws a short read rather than returning a truncated buffer", async () => { + const { device } = makeDevice(() => new Uint8Array(10)); // always short + await expect(device.cpuRead(0x8000, 0x100)).rejects.toThrow(/short read/i); + }); + + it("aborts between pages when the signal fires, stopping early", async () => { + const controller = new AbortController(); + let pages = 0; + const { device, inCalls } = makeDevice((c) => { + if (++pages === 2) controller.abort(); + return new Uint8Array(c.length); + }); + // 0x400 = four 256-byte pages; the abort lands while serving page 2. + await expect( + device.cpuRead(0x8000, 0x400, undefined, controller.signal), + ).rejects.toThrow(/abort/i); + // It threw on the pre-page-3 abort check, so only two pages reached the + // wire — the read stopped early rather than finishing all four. + expect(inCalls).toHaveLength(2); + }); +}); + +describe("KazzoDevice domain helpers", () => { + it("parses a NUL-terminated firmware version string", async () => { + const { device } = makeDevice((c) => { + expect(c.request).toBe(REQUEST.FIRMWARE_VERSION); + const s = new TextEncoder().encode("kazzo 1.2"); + const buf = new Uint8Array(c.length); + buf.set(s); + return buf; // remainder is 0x00 — the terminator + }); + expect(await device.fetchFirmwareVersion()).toBe("kazzo 1.2"); + // Cached — a second call must not re-issue the request. + expect(await device.fetchFirmwareVersion()).toBe("kazzo 1.2"); + }); + + it("exposes the raw version bytes (clipped build: all 0xFF, no terminator)", async () => { + // The clipped 2010-01 build's version section is erased flash; the + // decoded string can't represent it, so the driver classifies the raw + // bytes (see ./firmware-m2). + const raw = new Uint8Array(VERSION_STRING_SIZE).fill(0xff); + const { device, inCalls } = makeDevice(() => raw); + + expect(device.firmwareVersionBytes).toBeNull(); // until fetched + await device.fetchFirmwareVersion(); + + expect(inCalls).toHaveLength(1); + expect(inCalls[0].request).toBe(REQUEST.FIRMWARE_VERSION); + expect(inCalls[0].length).toBe(VERSION_STRING_SIZE); + expect(device.firmwareVersionBytes).toEqual(raw); + }); + + it("maps the VRAM pattern (0x05 = vertical)", async () => { + const { device } = makeDevice(() => new Uint8Array([VRAM_VERTICAL])); + expect(await device.vramConnection()).toBe(VRAM_VERTICAL); + }); + + it("issues PHI2_INIT on setup", async () => { + const { device, inCalls } = makeDevice(); + await device.phi2Init(); + expect(inCalls[0].request).toBe(REQUEST.PHI2_INIT); + }); +}); + +describe("KazzoDevice flash-write guard (read-only dumper)", () => { + // The guard is the project's most safety-critical invariant, so exercise it + // against the LIVE set — not a hand-copied subset that could drift behind it. + it("refuses every REFUSED_REQUESTS opcode before any transfer", async () => { + const { device, outCalls, inCalls } = makeDevice(); + for (const req of REFUSED_REQUESTS) { + await expect( + device.controlOut(req, 0, 0, new Uint8Array([0])), + ).rejects.toThrow(/never programs/i); + await expect(device.controlIn(req, 0, 0, 1)).rejects.toThrow( + /never programs/i, + ); + } + // Nothing reached the wire. + expect(outCalls).toHaveLength(0); + expect(inCalls).toHaveLength(0); + }); + + // Pin the exact membership so neither a dropped opcode (a write could then + // reach the device) nor an added read opcode (a legit read wrongly refused) + // slips in unnoticed — the set-driven test above can't catch either drift. + it("guards exactly the flash/firmware-program + flash-probe opcodes", () => { + const byValue = (a: number, b: number) => a - b; + expect([...REFUSED_REQUESTS].sort(byValue)).toEqual( + [ + REQUEST.CPU_WRITE_FLASH, + REQUEST.FLASH_STATUS, + REQUEST.FLASH_CONFIG_SET, + REQUEST.FLASH_PROGRAM, + REQUEST.FLASH_ERASE, + REQUEST.FLASH_DEVICE, + REQUEST.DISK_WRITE, + REQUEST.FIRMWARE_PROGRAM, + REQUEST.FIRMWARE_DOWNLOAD, + ].sort(byValue), + ); + }); +}); diff --git a/src/lib/drivers/kazzo/kazzo-device.ts b/src/lib/drivers/kazzo/kazzo-device.ts new file mode 100644 index 0000000..61c5b75 --- /dev/null +++ b/src/lib/drivers/kazzo/kazzo-device.ts @@ -0,0 +1,378 @@ +/** + * Kazzo — WebUSB device wrapper. + * + * Low-level protocol primitives over vendor control transfers. The Kazzo + * firmware has no dictionary layer — each `bRequest` is the opcode, and + * wValue / wIndex carry operands directly. Reads buffer a page at a time + * in firmware (256 B per transfer); larger regions are chunked here. + * + * Reimplemented from the documented protocol (see kazzo-opcodes.ts); the + * reference host is anago's reader_kazzo.c. + */ + +import { + KAZZO_DEVICE_FILTER, + REFUSED_REQUESTS, + READ_PACKET_SIZE, + REQUEST, + INDEX, + VERSION_STRING_SIZE, + WRITE_XOR_MASK, +} from "./kazzo-opcodes"; + +/** CPU bus address ceiling (64 KiB) — reads must not run past it. */ +const CPU_ADDR_CEILING = 0x10000; +/** PPU/CHR bus address ceiling (8 KiB). */ +const PPU_ADDR_CEILING = 0x2000; + +/** Progress callback fired at 256-byte page boundaries inside a read. */ +export type KazzoProgressCb = (bytesRead: number, totalBytes: number) => void; + +export class KazzoDevice { + private device: USBDevice | null = null; + private onDisconnect?: () => void; + private _firmwareVersion: string | null = null; + private _firmwareVersionBytes: Uint8Array | null = null; + + get connected(): boolean { + return this.device?.opened ?? false; + } + + get productName(): string { + return this.device?.productName ?? "kazzo"; + } + + /** Firmware version string from FIRMWARE_VERSION, or "unknown" until fetched. */ + get firmwareVersion(): string { + return this._firmwareVersion ?? "unknown"; + } + + /** + * Raw FIRMWARE_VERSION response bytes, or null until fetched. The driver + * fingerprints these to classify the firmware era (see ./firmware-m2): + * the INL-distributed clipped build has the version section erased (all + * 0xFF, no terminator), which the decoded string can't represent. + */ + get firmwareVersionBytes(): Uint8Array | null { + return this._firmwareVersionBytes; + } + + /** Prompt user to select device. */ + async connect(): Promise { + const device = await navigator.usb!.requestDevice({ + filters: [KAZZO_DEVICE_FILTER], + }); + await this.openDevice(device); + } + + /** Reconnect to a previously authorized device. */ + async connectWithDevice(device: USBDevice): Promise { + await this.openDevice(device); + } + + private async openDevice(device: USBDevice): Promise { + await device.open(); + await device.selectConfiguration(1); + await device.claimInterface(0); + this.device = device; + navigator.usb!.addEventListener("disconnect", this.handleDisconnect); + } + + async disconnect(): Promise { + navigator.usb!.removeEventListener("disconnect", this.handleDisconnect); + if (this.device?.opened) { + try { + await this.device.releaseInterface(0); + } catch { + /* best-effort */ + } + try { + await this.device.close(); + } catch { + /* best-effort */ + } + } + this.device = null; + this._firmwareVersion = null; + this._firmwareVersionBytes = null; + } + + onDisconnected(handler: () => void): void { + this.onDisconnect = handler; + } + + private handleDisconnect = (event: USBConnectionEvent) => { + 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 — tear + // down the same state here: drop the global listener (the gone device + // can't be released/closed) and clear the firmware caches so they can't + // outlive the device. + navigator.usb!.removeEventListener("disconnect", this.handleDisconnect); + this.device = null; + this._firmwareVersion = null; + this._firmwareVersionBytes = null; + this.onDisconnect?.(); + }; + + // ─── Raw control transfers ────────────────────────────────────────────── + + /** + * Hardware safety: nabu is a read-only dumper and must never program a + * cartridge's flash, firmware, or disk — nor drive flash command cycles onto + * the cart bus. Refuse every such request the firmware exposes (see + * REFUSED_REQUESTS) before it goes out. + */ + private assertReadOnly(request: number): void { + if (REFUSED_REQUESTS.has(request)) { + throw new Error( + `Refusing Kazzo flash/firmware request ${request}: nabu only ` + + `reads cartridges and never programs them.`, + ); + } + } + + /** Issue a vendor IN control transfer and return the response bytes. */ + async controlIn( + request: number, + value: number, + index: number, + length: number, + ): Promise { + this.assertReadOnly(request); + if (!this.device) throw new Error("Device not connected"); + + const result = await this.device.controlTransferIn( + { + requestType: "vendor", + recipient: "device", + request, + value: value & 0xffff, + index: index & 0xffff, + }, + length, + ); + + if (result.status !== "ok") { + throw new Error( + `Kazzo control IN failed (request=${request}, status=${result.status})`, + ); + } + + // A status-ok transfer can still carry fewer bytes than requested. Every + // caller wants exactly the length it asked for (pages, the version section, + // the 1-byte status/VRAM reads), so surface a short read as an error rather + // than fabricating empty/undefined data downstream. + const received = result.data?.byteLength ?? 0; + if (received < length) { + throw new Error( + `Kazzo control IN short read (request=${request}, addr=$${(value & 0xffff).toString(16)}, got ${received}, expected ${length})`, + ); + } + return new Uint8Array( + result.data!.buffer, + result.data!.byteOffset, + result.data!.byteLength, + ); + } + + /** + * Issue a vendor OUT control transfer. The payload is XORed with + * {@link WRITE_XOR_MASK} before transmission to work around V-USB losing + * bits on long runs of 0xFF; the firmware un-masks on receive. + */ + async controlOut( + request: number, + value: number, + index: number, + data: Uint8Array, + ): Promise { + this.assertReadOnly(request); + if (!this.device) throw new Error("Device not connected"); + + const masked = new Uint8Array(data.length); + for (let i = 0; i < data.length; i++) { + masked[i] = data[i] ^ WRITE_XOR_MASK; + } + + const result = await this.device.controlTransferOut( + { + requestType: "vendor", + recipient: "device", + request, + value: value & 0xffff, + index: index & 0xffff, + }, + masked, + ); + + if (result.status !== "ok" || result.bytesWritten !== data.length) { + throw new Error( + `Kazzo control OUT failed (request=${request}, status=${result.status}, wrote=${result.bytesWritten}/${data.length})`, + ); + } + } + + // ─── Chunked bus-access helpers ───────────────────────────────────────── + + /** + * Read `length` bytes starting at `address` using `request`, looping + * 256-byte page transfers. Mirrors reader_kazzo.c's `read_main`. Checks + * the abort signal and reports progress at each page boundary. + */ + private async readChunked( + request: number, + address: number, + length: number, + ceiling: number, + onProgress?: KazzoProgressCb, + signal?: AbortSignal, + ): Promise { + // Fail loud rather than wrap: the control-transfer wValue is 16-bit, so a + // region running past the bus's address ceiling would silently alias to + // low addresses and reassemble a wrong-but-plausible dump. + if (address < 0 || address + length > ceiling) { + throw new Error( + `Kazzo read out of range: $${address.toString(16)}+${length} exceeds $${ceiling.toString(16)}`, + ); + } + + const result = new Uint8Array(length); + let offset = 0; + let addr = address; + + while (offset < length) { + signal?.throwIfAborted(); + const n = Math.min(length - offset, READ_PACKET_SIZE); + // controlIn throws on a short read, so each chunk is exactly `n` bytes. + const chunk = await this.controlIn(request, addr, INDEX.IMPLIED, n); + result.set(chunk, offset); + offset += n; + addr += n; + onProgress?.(offset, length); + } + + return result; + } + + // ─── Domain methods ───────────────────────────────────────────────────── + + /** + * The firmware's ECHO opcode: returns `[value_lo, value_hi, index_lo, + * index_hi]`. Unused — init's connectivity touch is fetchFirmwareVersion(); + * kept to document the opcode surface. + */ + async echo(value: number, index: number): Promise { + return this.controlIn(REQUEST.ECHO, value, index, 4); + } + + /** + * Drive PHI2 (the CPU clock) before bus access — the firmware needs it + * initialized to synthesize read/write cycles. Issued once per dump + * region by the bus adapter's `setup()`. + */ + async phi2Init(): Promise { + await this.controlIn(REQUEST.PHI2_INIT, 0, 0, 1); + } + + /** + * Read PRG (CPU bus) starting at `address` for `length` bytes. The + * firmware synthesizes NES bus cycles per byte; mapper banking is the + * host's responsibility (poke registers via {@link cpuWrite} first). + */ + async cpuRead( + address: number, + length: number, + onProgress?: KazzoProgressCb, + signal?: AbortSignal, + ): Promise { + return this.readChunked( + REQUEST.CPU_READ, + address, + length, + CPU_ADDR_CEILING, + onProgress, + signal, + ); + } + + /** Read CHR (PPU bus) starting at `address` for `length` bytes. */ + async ppuRead( + address: number, + length: number, + onProgress?: KazzoProgressCb, + signal?: AbortSignal, + ): Promise { + return this.readChunked( + REQUEST.PPU_READ, + address, + length, + PPU_ADDR_CEILING, + onProgress, + signal, + ); + } + + /** + * Write every byte of `bytes` to CPU-bus `address` in a single + * CPU_WRITE_6502 transfer — the firmware runs one 6502 write cycle per byte + * at the same address. This is how a serially-loaded register (MMC1's shift + * register) is clocked atomically: all five shifted bytes ride in one USB + * transfer instead of five. `controlOut` applies the 0xA5 mask to the whole + * payload. + */ + async cpuWriteBytes(address: number, bytes: Uint8Array): Promise { + await this.controlOut(REQUEST.CPU_WRITE_6502, address, INDEX.IMPLIED, bytes); + } + + /** + * Write one byte to the CPU bus at `address` via the firmware's + * 6502-style write cycle. Used to poke mapper registers (banking, + * UxROM/MMC3 latches, etc.). + */ + async cpuWrite(address: number, byte: number): Promise { + await this.cpuWriteBytes(address, new Uint8Array([byte & 0xff])); + } + + /** Write one byte to the PPU bus at `address`. */ + async ppuWrite(address: number, byte: number): Promise { + await this.controlOut( + REQUEST.PPU_WRITE, + address, + INDEX.IMPLIED, + new Uint8Array([byte & 0xff]), + ); + } + + /** + * Query and cache the firmware version string. Returns the same value on + * subsequent calls; safe to call once at init. + */ + async fetchFirmwareVersion(): Promise { + if (this._firmwareVersion !== null) return this._firmwareVersion; + + const bytes = await this.controlIn( + REQUEST.FIRMWARE_VERSION, + 0, + 0, + VERSION_STRING_SIZE, + ); + this._firmwareVersionBytes = bytes; + const nul = bytes.indexOf(0); + const end = nul === -1 ? bytes.length : nul; + this._firmwareVersion = new TextDecoder("ascii").decode( + bytes.subarray(0, end), + ); + return this._firmwareVersion; + } + + /** + * Probe nametable mirroring via the firmware's VRAM A10 check. Returns + * the 4-bit pattern documented at VRAM_CONNECTION. + */ + async vramConnection(): Promise { + const bytes = await this.controlIn(REQUEST.VRAM_CONNECTION, 0, 0, 1); + return bytes[0] & 0x0f; + } +} diff --git a/src/lib/drivers/kazzo/kazzo-driver.test.ts b/src/lib/drivers/kazzo/kazzo-driver.test.ts new file mode 100644 index 0000000..97590fd --- /dev/null +++ b/src/lib/drivers/kazzo/kazzo-driver.test.ts @@ -0,0 +1,538 @@ +import { describe, it, expect, vi } from "vitest"; +import { KazzoDriver } from "./kazzo-driver"; +import type { KazzoTransport } from "./kazzo-transport"; +import type { KazzoDevice } from "./kazzo-device"; +import type { ReadConfig } from "@/lib/types"; +import { VRAM_VERTICAL, VERSION_STRING_SIZE } from "./kazzo-opcodes"; +import { M2_IDLE_GATED_MAPPERS } from "./unsupported-mappers"; + +/** + * Driver-level coverage exercised entirely through a fake `KazzoDevice` — no + * hardware. It proves the dump paths drive the shared mapper catalog over the + * bus correctly: NROM reads flat, an MMC3 cart is banked + reassembled, the + * M2-idle-gated mappers are classified per firmware and pre-flight-rejected + * before any cart traffic, and detect/init/save take their expected shapes. + */ + +interface Call { + m: + | "phi2Init" + | "cpuWrite" + | "cpuWriteBytes" + | "cpuRead" + | "ppuRead" + | "vram" + | "firmware"; + addr?: number; + value?: number; + length?: number; +} + +interface FakeOptions { + /** Bytes a cpuRead at (addr,len) yields. Default: zero-fill. */ + cpuRead?: (addr: number, len: number) => Uint8Array; + /** Bytes a ppuRead at (addr,len) yields. Default: zero-fill. */ + ppuRead?: (addr: number, len: number) => Uint8Array; + vram?: number; + /** + * FIRMWARE_VERSION response: a string (NUL-padded to 32 bytes), raw bytes + * (e.g. the clipped build's all-0xFF section), or "transfer-error" to make + * the fetch itself throw. + */ + firmware?: string | Uint8Array | "transfer-error"; +} + +/** A NUL-terminated, zero-padded 32-byte version section. */ +function versionBytes(s: string): Uint8Array { + const out = new Uint8Array(VERSION_STRING_SIZE); + out.set(new TextEncoder().encode(s)); + return out; +} + +/** A fake KazzoDevice that records every call the bus/driver makes. */ +function fakeKazzo(opts: FakeOptions = {}) { + const calls: Call[] = []; + const firmware = opts.firmware ?? "kazzo 1.2"; + let fwBytes: Uint8Array | null = null; + const device = { + productName: "kazzo", + get firmwareVersionBytes() { + return fwBytes; + }, + async fetchFirmwareVersion() { + calls.push({ m: "firmware" }); + if (firmware === "transfer-error") { + throw new Error("Kazzo control IN failed"); + } + fwBytes = typeof firmware === "string" ? versionBytes(firmware) : firmware; + return ""; + }, + async phi2Init() { + calls.push({ m: "phi2Init" }); + }, + async cpuWrite(addr: number, value: number) { + calls.push({ m: "cpuWrite", addr, value }); + }, + async cpuWriteBytes(addr: number, bytes: Uint8Array) { + calls.push({ m: "cpuWriteBytes", addr, length: bytes.length }); + }, + async cpuRead( + addr: number, + length: number, + onProgress?: (r: number, t: number) => void, + signal?: AbortSignal, + ) { + signal?.throwIfAborted(); + calls.push({ m: "cpuRead", addr, length }); + onProgress?.(length, length); + return opts.cpuRead?.(addr, length) ?? new Uint8Array(length); + }, + async ppuRead( + addr: number, + length: number, + onProgress?: (r: number, t: number) => void, + signal?: AbortSignal, + ) { + signal?.throwIfAborted(); + calls.push({ m: "ppuRead", addr, length }); + onProgress?.(length, length); + return opts.ppuRead?.(addr, length) ?? new Uint8Array(length); + }, + async vramConnection() { + calls.push({ m: "vram" }); + return opts.vram ?? 0; + }, + }; + return { device: device as unknown as KazzoDevice, calls }; +} + +function makeDriver(device: KazzoDevice): KazzoDriver { + return new KazzoDriver({ device } as unknown as KazzoTransport); +} + +const romConfig = (params: Record): ReadConfig => ({ + systemId: "nes", + params, +}); + +describe("KazzoDriver capabilities", () => { + it("advertises NES ROM dumping and greys out the unsupported mappers", () => { + const { device } = fakeKazzo(); + const cap = makeDriver(device).capabilities; + expect(cap).toHaveLength(1); + expect(cap[0].systemId).toBe("nes"); + expect(cap[0].operations).toContain("dump_rom"); + expect(cap[0].autoDetect).toBe(true); + // Pre-probe default: the M2-idle-gated CPLD mappers are greyed out + // until initialize() classifies the firmware (fail-safe). + expect(cap[0].unsupportedMappers).toEqual([...M2_IDLE_GATED_MAPPERS.keys()]); + }); +}); + +describe("KazzoDriver.initialize", () => { + it("fetches the firmware version and reports device info", async () => { + const { device, calls } = fakeKazzo({ firmware: "kazzo16 0.1.3" }); + const info = await makeDriver(device).initialize(); + expect(info.firmwareVersion).toBe("kazzo16 0.1.3"); + expect(info.deviceName).toBe("kazzo"); + expect(info.capabilities[0].systemId).toBe("nes"); + expect(calls.some((c) => c.m === "firmware")).toBe(true); + }); +}); + +/** + * The M2-idle firmware gate. initialize() classifies the connected build + * from its FIRMWARE_VERSION fingerprint (see ./firmware-m2): pre-flip + * "kazzo16 0.1.0"–"0.1.2" strings and the self-identifying "0.1.3+m2" + * fork idle M2 high (all-0xFF — a blank version section — gates: capable + * in practice but not an identity) + * → the SMD172-family CPLD mappers are enabled; anything else (including a + * failed read, and before any probe at all) leaves them gated. + */ +describe("KazzoDriver M2-idle firmware gate", () => { + const gatedIds = [...M2_IDLE_GATED_MAPPERS.keys()]; + const clipped = () => new Uint8Array(VERSION_STRING_SIZE).fill(0xff); + + it("enables the CPLD mappers on the m2-idle-high fork build and dumps mapper 268", async () => { + const { device, calls } = fakeKazzo({ + firmware: "kazzo16 0.1.3+m2 / Jun 10 2026", + }); + const driver = makeDriver(device); + + await driver.initialize(); + + expect(driver.m2IdleHigh).toBe(true); + expect(driver.capabilities[0].unsupportedMappers).toEqual([]); + + // A formerly-gated mapper now dumps end to end (one 16 KiB outer bank). + calls.length = 0; // ignore the init traffic; assert on the dump only + const data = await driver.readROM( + romConfig({ mapper: 268, prgSizeBytes: 16384, chrSizeBytes: 0 }), + ); + expect(data).toHaveLength(16384); + // Prove it actually drove the mapper, not a flat read: the Mindkids + // outer registers live at $5000, and the walk pokes them to select the + // bank before each read. + expect(calls.some((c) => c.m === "cpuWrite" && c.addr === 0x5000)).toBe(true); + // And the bank is read under consensus (≥2 reads of the same $8000 + // window), not a single trusting read. + expect( + calls.filter((c) => c.m === "cpuRead" && c.addr === 0x8000).length, + ).toBeGreaterThanOrEqual(2); + }); + + it("gates the CPLD mappers on the clipped (all-0xFF) build — blank version is no identity", async () => { + const { device, calls } = fakeKazzo({ firmware: clipped() }); + const driver = makeDriver(device); + await driver.initialize(); + expect(driver.m2IdleHigh).toBe(false); + calls.length = 0; + await expect( + driver.readROM( + romConfig({ mapper: 268, prgSizeBytes: 16384, chrSizeBytes: 0 }), + ), + ).rejects.toThrow(/Kazzo firmware/); + expect(calls).toHaveLength(0); + }); + + it("enables the CPLD mappers on a pre-flip version string", async () => { + const { device } = fakeKazzo({ firmware: "kazzo16 0.1.2" }); + const driver = makeDriver(device); + + await driver.initialize(); + + expect(driver.m2IdleHigh).toBe(true); + expect(driver.capabilities[0].unsupportedMappers).toEqual([]); + }); + + it.each([268, 470])( + "pre-flight-rejects mapper %i on post-flip firmware without touching the cart", + async (mapper) => { + // The driver must reject before any cart traffic rather than produce + // a boot-bank-mirrored garbage dump. + const { device, calls } = fakeKazzo({ firmware: "kazzo16 0.1.3" }); + const driver = makeDriver(device); + await driver.initialize(); + calls.length = 0; + + await expect( + driver.readROM( + romConfig({ mapper, prgSizeBytes: 16384, chrSizeBytes: 0 }), + ), + ).rejects.toThrow(/Kazzo firmware/); + expect(calls).toHaveLength(0); + }, + ); + + it("treats a version-read failure as gated (fail-safe)", async () => { + const { device } = fakeKazzo({ firmware: "transfer-error" }); + const driver = makeDriver(device); + + await driver.initialize(); // must not throw + + expect(driver.m2IdleHigh).toBe(false); + expect(driver.capabilities[0].unsupportedMappers).toEqual(gatedIds); + }); + + it("gates the mappers before initialize() has classified (fail-safe default)", async () => { + const { device, calls } = fakeKazzo(); + const driver = makeDriver(device); + + expect(driver.m2IdleHigh).toBe(false); + expect(driver.capabilities[0].unsupportedMappers).toEqual(gatedIds); + await expect( + driver.readROM( + romConfig({ mapper: 268, prgSizeBytes: 16384, chrSizeBytes: 0 }), + ), + ).rejects.toThrow(/Kazzo firmware/); + expect(calls).toHaveLength(0); + }); + + it("logs exactly one firmware-classification line", async () => { + const { device } = fakeKazzo({ firmware: "kazzo16 0.1.3+m2 / Jun 10 2026" }); + const driver = makeDriver(device); + const logs: string[] = []; + driver.on("onLog", (message) => logs.push(message)); + + await driver.initialize(); + + const fwLines = logs.filter((l) => l.includes("M2 idles")); + expect(fwLines).toHaveLength(1); + expect(fwLines[0]).toBe( + "Firmware kazzo16 0.1.3+m2 / Jun 10 2026: " + + "M2 idles high — CPLD mappers (268/470) enabled", + ); + }); + + it("the classification line names the gated mappers as unavailable on a post-flip build", async () => { + const { device } = fakeKazzo({ firmware: "kazzo16 0.1.3" }); + const driver = makeDriver(device); + const logs: string[] = []; + driver.on("onLog", (message) => logs.push(message)); + + await driver.initialize(); + + const fwLines = logs.filter((l) => l.includes("M2 idles")); + expect(fwLines).toHaveLength(1); + expect(fwLines[0]).toBe( + "Firmware kazzo16 0.1.3: M2 idles low — CPLD mappers (268/470) unavailable", + ); + }); +}); + +describe("KazzoDriver.detectSystem", () => { + it("maps the vertical VRAM pattern to vertical mirroring", async () => { + const { device } = fakeKazzo({ vram: VRAM_VERTICAL }); + const result = await makeDriver(device).detectSystem(); + expect(result?.systemId).toBe("nes"); + expect(result?.cartInfo?.summary).toBe("NES cartridge (mirroring: vertical)"); + expect(result?.cartInfo?.meta).toEqual({ mirroring: "vertical" }); + }); + + it("maps any other VRAM pattern to horizontal mirroring", async () => { + const { device } = fakeKazzo({ vram: 0x00 }); + const result = await makeDriver(device).detectSystem(); + expect(result?.cartInfo?.summary).toBe( + "NES cartridge (mirroring: horizontal)", + ); + }); + + it("detectCartridge returns the cart info for nes and null otherwise", async () => { + const { device } = fakeKazzo({ vram: VRAM_VERTICAL }); + const driver = makeDriver(device); + expect((await driver.detectCartridge("nes"))?.meta).toEqual({ + mirroring: "vertical", + }); + expect(await driver.detectCartridge("gb")).toBeNull(); + }); +}); + +describe("KazzoDriver.readROM — NROM (flat, unbanked)", () => { + it("reads PRG off the CPU bus and CHR off the PPU bus, concatenated", async () => { + // Ramp content keyed off the byte's bus address, so the reassembled + // image is verifiable against the regions the mapper read. + const { device, calls } = fakeKazzo({ + cpuRead: (addr, len) => + Uint8Array.from({ length: len }, (_, i) => (addr + i) & 0xff), + ppuRead: (addr, len) => + Uint8Array.from({ length: len }, (_, i) => (addr + i) & 0xff), + }); + + const data = await makeDriver(device).readROM( + romConfig({ mapper: 0, prgSizeBytes: 32768, chrSizeBytes: 8192 }), + ); + + expect(data).toHaveLength(40960); // 32K PRG + 8K CHR + // PRG: bus address $8000.. → low byte ramp. + expect(data[0]).toBe(0x00); // $8000 + expect(data[1]).toBe(0x01); + expect(data[0x7fff]).toBe(0xff); + // CHR begins at offset 32768, PPU address $0000.. → ramp from 0. + expect(data[32768]).toBe(0x00); + expect(data[32768 + 0x1fff]).toBe(0xff); + + // NROM is a single CPU read then a single PPU read, each prefaced by a + // PHI2_INIT from the mapper's setup(). + expect(calls.filter((c) => c.m === "cpuRead")).toEqual([ + { m: "cpuRead", addr: 0x8000, length: 32768 }, + ]); + expect(calls.filter((c) => c.m === "ppuRead")).toEqual([ + { m: "ppuRead", addr: 0x0000, length: 8192 }, + ]); + expect(calls.filter((c) => c.m === "cpuWrite")).toHaveLength(0); + }); + + it("omits CHR when the cart uses CHR-RAM (0 KB CHR-ROM)", async () => { + const { device, calls } = fakeKazzo(); + const data = await makeDriver(device).readROM( + romConfig({ mapper: 0, prgSizeBytes: 16384, chrSizeBytes: 0 }), + ); + expect(data).toHaveLength(16384); + expect(calls.some((c) => c.m === "ppuRead")).toBe(false); + }); +}); + +describe("KazzoDriver.readROM — MMC3 (banked)", () => { + /** + * Models just enough of the MMC3 register file to make each bank's read + * distinct: R6 (PRG $8000) and R0 (CHR $0000) drive the fill byte, so the + * reassembled image proves the driver actually re-banked between reads. + */ + function mmc3Fake() { + let selected = 0; + let prgR6 = 0; + let chrR0 = 0; + const fake = fakeKazzo({ + cpuRead: (_addr, len) => new Uint8Array(len).fill(prgR6 & 0xff), + ppuRead: (_addr, len) => new Uint8Array(len).fill(chrR0 & 0xff), + }); + const dev = fake.device as unknown as { + cpuWrite: (addr: number, value: number) => Promise; + }; + const inner = dev.cpuWrite; + dev.cpuWrite = async (addr: number, value: number) => { + if (addr === 0x8000) selected = value & 7; + else if (addr === 0x8001) { + if (selected === 6) prgR6 = value; + else if (selected === 0) chrR0 = value; + } + await inner(addr, value); + }; + return fake; + } + + it("walks PRG via R6 and CHR via R0, one bank per read", async () => { + const { device, calls } = mmc3Fake(); + // 256K PRG = 32×8K banks; 128K CHR = 32×4K outer iterations. + const data = await makeDriver(device).readROM( + romConfig({ mapper: 4, prgSizeBytes: 262144, chrSizeBytes: 131072 }), + ); + + expect(data).toHaveLength(262144 + 131072); + // One read per bank — the dropout retry never fires (bank 0 is a uniform + // fill, which disables the bank-0 comparison). + expect(calls.filter((c) => c.m === "cpuRead")).toHaveLength(32); + expect(calls.filter((c) => c.m === "ppuRead")).toHaveLength(32); + + // PRG bank i was selected (R6=i) before its read → 8K of byte i. + for (let bank = 0; bank < 32; bank++) { + expect(data[bank * 8192]).toBe(bank); + } + // CHR outer i set R0 = (i*2)<<1 = i*4 → 4K of byte (i*4)&0xff. + for (let i = 0; i < 32; i++) { + expect(data[262144 + i * 4096]).toBe((i * 4) & 0xff); + } + }); +}); + +describe("KazzoDriver.readROM — unsupported mappers", () => { + // The M2-idle-gated mappers (268/470) are covered by the firmware-gate + // describe above. resolveMapper additionally rejects ids that aren't in + // the shared catalog at all: + it("rejects a mapper that isn't in the catalog at all", async () => { + const { device } = fakeKazzo(); + await expect( + makeDriver(device).readROM(romConfig({ mapper: 999 })), + ).rejects.toThrow(/Unsupported mapper: 999/); + }); +}); + +describe("KazzoDriver.readROM — abort", () => { + it("an already-aborted signal stops the dump before any read", async () => { + const { device, calls } = fakeKazzo(); + const controller = new AbortController(); + controller.abort(); + await expect( + makeDriver(device).readROM( + romConfig({ mapper: 0, prgSizeBytes: 32768, chrSizeBytes: 8192 }), + controller.signal, + ), + ).rejects.toThrow(); + expect(calls.some((c) => c.m === "cpuRead")).toBe(false); + }); + + it("a mid-dump abort stops before the next region read", async () => { + const controller = new AbortController(); + const { device, calls } = fakeKazzo({ + // Abort the moment the PRG read is requested; the CHR read must not run. + cpuRead: (_addr, len) => { + controller.abort(); + return new Uint8Array(len); + }, + }); + await expect( + makeDriver(device).readROM( + romConfig({ mapper: 0, prgSizeBytes: 32768, chrSizeBytes: 8192 }), + controller.signal, + ), + ).rejects.toThrow(/abort/i); + // The signal — threaded driver → KazzoNesBus → device — tripped after the + // PRG read so the CHR read never issued. + expect(calls.some((c) => c.m === "cpuRead")).toBe(true); + expect(calls.some((c) => c.m === "ppuRead")).toBe(false); + }); +}); + +describe("KazzoDriver.readSave", () => { + it("reads the $6000 PRG-RAM window after PHI2_INIT (default path)", async () => { + const { device, calls } = fakeKazzo({ + cpuRead: (addr, len) => + Uint8Array.from({ length: len }, (_, i) => (addr + i) & 0xff), + }); + const data = await makeDriver(device).readSave( + romConfig({ mapper: 0, prgRamSizeBytes: 8192 }), + ); + + expect(data).toHaveLength(8192); + expect(data[0]).toBe(0x00); // $6000 low byte + // PHI2_INIT (setup) precedes the single $6000 SRAM read. + expect(calls.map((c) => c.m)).toEqual(["phi2Init", "cpuRead"]); + expect(calls.find((c) => c.m === "cpuRead")?.addr).toBe(0x6000); + }); + + it("takes the mapper's dumpSave path when it defines one (MMC3)", async () => { + const { device, calls } = fakeKazzo({ + // Byte-diverse so MMC3's open-bus check passes on the first $6000 read + // and it returns without probing the MMC6 fallback windows. + cpuRead: (addr, len) => + Uint8Array.from({ length: len }, (_, i) => (addr + i) & 0xff), + }); + const data = await makeDriver(device).readSave( + romConfig({ mapper: 4, prgRamSizeBytes: 8192 }), + ); + + expect(data).toHaveLength(8192); + // dumpSave brackets the read with PRG-RAM-enable register writes — the + // bare default path issues none — and reads the $6000 window. + expect(calls.some((c) => c.m === "cpuWrite")).toBe(true); + expect(calls.some((c) => c.m === "cpuRead" && c.addr === 0x6000)).toBe(true); + }); + + it("runs enableSram before the read for a mapper that defines one (MMC1)", async () => { + const { device, calls } = fakeKazzo(); + const data = await makeDriver(device).readSave( + romConfig({ mapper: 1, prgRamSizeBytes: 8192 }), + ); + + expect(data).toHaveLength(8192); + // enableSram pokes the MMC1 registers (a reset writeCpu + serial loads) + // before the $6000 read; the NROM default path pokes nothing. + const firstRead = calls.findIndex((c) => c.m === "cpuRead"); + expect(firstRead).toBeGreaterThanOrEqual(0); + expect( + calls + .slice(0, firstRead) + .some((c) => c.m === "cpuWrite" || c.m === "cpuWriteBytes"), + ).toBe(true); + expect(calls[firstRead].addr).toBe(0x6000); + }); + + it("throws when the cart has no SRAM", async () => { + const { device } = fakeKazzo(); + await expect( + makeDriver(device).readSave(romConfig({ mapper: 0, prgRamSizeBytes: 0 })), + ).rejects.toThrow(/No SRAM/); + }); +}); + +describe("KazzoDriver.writeSave", () => { + it("refuses — nabu is a read-only dumper", async () => { + const { device } = fakeKazzo(); + await expect( + makeDriver(device).writeSave( + new Uint8Array(8), + romConfig({ mapper: 0, prgRamSizeBytes: 8192 }), + ), + ).rejects.toThrow(/not yet implemented/); + }); +}); + +describe("KazzoDriver.detectSystem — probe failure", () => { + it("falls back to unknown mirroring when the VRAM probe throws", async () => { + const { device } = fakeKazzo(); + (device as unknown as { vramConnection: () => Promise }).vramConnection = + vi.fn().mockRejectedValue(new Error("probe not supported")); + const result = await makeDriver(device).detectSystem(); + expect(result?.cartInfo?.summary).toBe("NES cartridge (mirroring: unknown)"); + }); +}); diff --git a/src/lib/drivers/kazzo/kazzo-driver.ts b/src/lib/drivers/kazzo/kazzo-driver.ts new file mode 100644 index 0000000..55a75e8 --- /dev/null +++ b/src/lib/drivers/kazzo/kazzo-driver.ts @@ -0,0 +1,274 @@ +/** + * Kazzo — NES/Famicom device driver. + * + * Drives the shared, device-agnostic NES mapper catalog + * (`@/lib/systems/nes/mappers`) through `KazzoNesBus`, which adapts the + * generic CPU/PPU bus primitives to the Kazzo firmware's per-byte + * read/write requests. Runs on kazzo hardware or on an AVR-based INL Retro + * board (v1.x, pre-2018) reflashed with the Kazzo firmware. + * + * The device/protocol layer is reimplemented from the documented Kazzo + * protocol. Hardware-validated over WebUSB on the INL-distributed clipped + * build of the 2010-01 firmware (NROM, MMC3, and — that build idles M2 + * high — the CPLD mappers 268 and 470, byte-perfect against references). + */ + +import type { + DeviceDriver, + DeviceDriverEvents, + DeviceInfo, + DeviceCapability, + DetectSystemResult, + CartridgeInfo, + ReadConfig, + DumpProgress, + SystemId, +} from "@/lib/types"; +import type { KazzoDevice } from "./kazzo-device"; +import type { KazzoTransport } from "./kazzo-transport"; +import { KazzoNesBus } from "./kazzo-nes-bus"; +import { detectKazzoMirroring } from "./detect-mirroring"; +// Catalog mappers gated on the firmware's M2 idle level (the SMD172-family +// CPLD boards need M2 to idle high; post-2010-01-24 Kazzo builds idle it +// low). The gate is classified per connection from the firmware version +// fingerprint in initialize(); when closed, the affected mappers are greyed +// out in the config UI and pre-flight-rejected in readROM/readSave. +import { + M2_IDLE_GATED_MAPPERS, + unsupportedMappersFor, +} from "./unsupported-mappers"; +import { classifyKazzoFirmware } from "./firmware-m2"; +import { getNesMapper } from "@/lib/systems/nes/mappers"; + +/** $6000-$7FFF — the battery-backed PRG-RAM (SRAM) window on the CPU bus. */ +const SRAM_BASE = 0x6000; + +export class KazzoDriver implements DeviceDriver { + readonly id = "kazzo"; + readonly name = "Kazzo"; + readonly capabilities: DeviceCapability[] = [ + { + systemId: "nes", + operations: ["dump_rom"], + autoDetect: true, + // Greys these mappers out in the config UI; readROM pre-flight- + // rejects them too. Pre-probe default assumes an M2-idle-low build; + // initialize() re-derives this from the firmware classification. + unsupportedMappers: [...unsupportedMappersFor(false).keys()], + }, + ]; + + /** + * Whether the connected firmware idles M2 high between bus operations — + * the feature the SMD172-family CPLD mappers require. Classified once per + * connection in initialize() from the FIRMWARE_VERSION fingerprint (see + * ./firmware-m2); false (gated) until then. + */ + private _m2IdleHigh = false; + get m2IdleHigh(): boolean { + return this._m2IdleHigh; + } + /** Effective unsupported-mapper map for this session (see ./unsupported-mappers). */ + private unsupportedMappers = unsupportedMappersFor(false); + + private events: Partial = {}; + /** + * The connection transport, exposed so the generic connection lifecycle + * (Disconnect, page unload) can close the device. `kazzoDevice` is its + * underlying control-transfer wrapper, which the dump paths drive. + */ + readonly transport: KazzoTransport; + readonly kazzoDevice: KazzoDevice; + + constructor(transport: KazzoTransport) { + this.transport = transport; + this.kazzoDevice = transport.device; + } + + on( + event: K, + handler: DeviceDriverEvents[K], + ): void { + this.events[event] = handler; + } + + private log(message: string, level: "info" | "warn" | "error" = "info") { + this.events.onLog?.(message, level); + } + + private progress(p: DumpProgress) { + this.events.onProgress?.(p); + } + + async initialize(): Promise { + this.log("Initializing Kazzo..."); + + // Classify the firmware's M2 idle level from its version fingerprint. + // FIRMWARE_VERSION is a benign flash read present in every firmware + // era; a failed read classifies as idle-low, keeping the gate closed. + let versionBytes: Uint8Array | null = null; + try { + await this.kazzoDevice.fetchFirmwareVersion(); + versionBytes = this.kazzoDevice.firmwareVersionBytes; + } catch { + versionBytes = null; + } + const firmware = classifyKazzoFirmware(versionBytes); + this._m2IdleHigh = firmware.m2IdleHigh; + this.unsupportedMappers = unsupportedMappersFor(firmware.m2IdleHigh); + const nesCapability = this.capabilities.find((c) => c.systemId === "nes"); + if (nesCapability) { + nesCapability.unsupportedMappers = [...this.unsupportedMappers.keys()]; + } + const gatedIds = [...M2_IDLE_GATED_MAPPERS.keys()].join("/"); + this.log( + `Firmware ${firmware.label}: M2 idles ${ + firmware.m2IdleHigh + ? `high — CPLD mappers (${gatedIds}) enabled` + : `low — CPLD mappers (${gatedIds}) unavailable` + }`, + ); + + this.log("Device ready"); + + return { + firmwareVersion: firmware.label, + deviceName: this.kazzoDevice.productName, + capabilities: this.capabilities, + }; + } + + async detectSystem(): Promise { + this.log("Detecting cartridge..."); + + let mirroring = "unknown"; + try { + mirroring = await detectKazzoMirroring(this.kazzoDevice); + } catch (e) { + this.log( + `Mirroring detection not supported (${(e as Error).message})`, + "warn", + ); + } + + return { + systemId: "nes", + cartInfo: { + // NES carts carry no self-reported title; describe what detection + // found and let the app emit the single "Detected: ..." log line. + summary: `NES cartridge (mirroring: ${mirroring})`, + meta: { mirroring }, + }, + }; + } + + async detectCartridge(systemId: SystemId): Promise { + if (systemId !== "nes") return null; + const result = await this.detectSystem(); + return result?.cartInfo ?? null; + } + + private resolveMapper(mapperId: number) { + const mapper = getNesMapper(mapperId); + if (!mapper) throw new Error(`Unsupported mapper: ${mapperId}`); + const unsupportedReason = this.unsupportedMappers.get(mapperId); + if (unsupportedReason) { + throw new Error( + `Mapper ${mapperId} (${mapper.name}) can't be dumped with this ` + + `Kazzo firmware: ${unsupportedReason}. The cart itself is fine.`, + ); + } + return mapper; + } + + async readROM(config: ReadConfig, signal?: AbortSignal): Promise { + const mapperId = (config.params.mapper as number) ?? 0; + const prgKB = ((config.params.prgSizeBytes as number) ?? 32768) / 1024; + const chrKB = ((config.params.chrSizeBytes as number) ?? 8192) / 1024; + + const mapper = this.resolveMapper(mapperId); + + // The mapper drives the cart through the bus; `bus.setup()` (issued + // inside the mapper before each region) runs PHI2_INIT. The signal rides + // along so an abort interrupts per 256-byte page, not per region. + const bus = new KazzoNesBus(this.kazzoDevice, signal); + const startTime = Date.now(); + const totalBytes = (prgKB + chrKB) * 1024; + + this.log(`Reading ${prgKB}KB PRG-ROM...`); + signal?.throwIfAborted(); + + const prgData = await mapper.dumpPrgRom(bus, prgKB, (bytesRead) => { + const elapsed = (Date.now() - startTime) / 1000; + this.progress({ + phase: "rom", + bytesRead, + totalBytes, + fraction: bytesRead / totalBytes, + speed: elapsed > 0 ? bytesRead / elapsed : undefined, + }); + }); + + let chrData: Uint8Array = new Uint8Array(0); + if (chrKB > 0) { + this.log(`Reading ${chrKB}KB CHR-ROM...`); + signal?.throwIfAborted(); + + chrData = await mapper.dumpChrRom(bus, chrKB, (bytesRead) => { + const elapsed = (Date.now() - startTime) / 1000; + const totalRead = prgKB * 1024 + bytesRead; + this.progress({ + phase: "rom", + bytesRead: totalRead, + totalBytes, + fraction: totalRead / totalBytes, + speed: elapsed > 0 ? totalRead / elapsed : undefined, + }); + }); + } + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + this.log( + `ROM read complete (${prgData.length + chrData.length} bytes in ${elapsed}s)`, + ); + + // Return PRG + CHR concatenated (system handler adds the iNES header). + const result = new Uint8Array(prgData.length + chrData.length); + result.set(prgData, 0); + result.set(chrData, prgData.length); + return result; + } + + async readSave(config: ReadConfig, signal?: AbortSignal): Promise { + const mapperId = (config.params.mapper as number) ?? 0; + const sramKB = ((config.params.prgRamSizeBytes as number) ?? 8192) / 1024; + + if (sramKB <= 0) throw new Error("No SRAM to read"); + + const mapper = this.resolveMapper(mapperId); + const bus = new KazzoNesBus(this.kazzoDevice, signal); + this.log(`Reading ${sramKB}KB SRAM...`); + + let data: Uint8Array; + if (mapper.dumpSave) { + data = await mapper.dumpSave(bus, sramKB); + } else { + // Default path: enable WRAM (where the mapper supports it) and read the + // $6000-$7FFF PRG-RAM window directly off the CPU bus. + await bus.setup(); + if (mapper.enableSram) await mapper.enableSram(bus); + data = await bus.readCpu(SRAM_BASE, sramKB * 1024); + } + + this.log(`SRAM read complete (${data.length} bytes)`); + return data; + } + + async writeSave( + _data: Uint8Array, + _config: ReadConfig, + _signal?: AbortSignal, + ): Promise { + throw new Error("Save RAM writing not yet implemented"); + } +} diff --git a/src/lib/drivers/kazzo/kazzo-nes-bus.test.ts b/src/lib/drivers/kazzo/kazzo-nes-bus.test.ts new file mode 100644 index 0000000..ac2d87a --- /dev/null +++ b/src/lib/drivers/kazzo/kazzo-nes-bus.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from "vitest"; +import { KazzoNesBus } from "./kazzo-nes-bus"; +import type { KazzoDevice } from "./kazzo-device"; + +interface Call { + m: "phi2Init" | "cpuWrite" | "cpuWriteBytes" | "cpuRead" | "ppuRead"; + addr?: number; + value?: number; + length?: number; + bytes?: number[]; +} + +/** Records the device calls a KazzoNesBus makes, serving reads from a ramp. */ +function recordingDevice() { + const calls: Call[] = []; + const device = { + async phi2Init() { + calls.push({ m: "phi2Init" }); + }, + async cpuWrite(addr: number, value: number) { + calls.push({ m: "cpuWrite", addr, value }); + }, + async cpuWriteBytes(addr: number, bytes: Uint8Array) { + calls.push({ m: "cpuWriteBytes", addr, bytes: Array.from(bytes) }); + }, + async cpuRead( + addr: number, + length: number, + onProgress?: (r: number, t: number) => void, + signal?: AbortSignal, + ) { + signal?.throwIfAborted(); + calls.push({ m: "cpuRead", addr, length }); + onProgress?.(length, length); + return new Uint8Array(length).fill(0x11); + }, + async ppuRead(addr: number, length: number) { + calls.push({ m: "ppuRead", addr, length }); + return new Uint8Array(length).fill(0x22); + }, + }; + return { device: device as unknown as KazzoDevice, calls }; +} + +describe("KazzoNesBus", () => { + it("setup() runs PHI2_INIT", async () => { + const { device, calls } = recordingDevice(); + await new KazzoNesBus(device).setup(); + expect(calls).toEqual([{ m: "phi2Init" }]); + }); + + it("writeCpu maps to a single cpuWrite", async () => { + const { device, calls } = recordingDevice(); + await new KazzoNesBus(device).writeCpu(0x8000, 0x06); + expect(calls).toEqual([{ m: "cpuWrite", addr: 0x8000, value: 0x06 }]); + }); + + it("writeSerialRegister batches the MMC1 5-bit load into one cpuWriteBytes", async () => { + const { device, calls } = recordingDevice(); + // 0x12 = 0b10010 → bit 0 of each shifted byte, LSB first: 0,1,0,0,1. + await new KazzoNesBus(device).writeSerialRegister(0xe000, 0x12); + expect(calls).toEqual([ + { m: "cpuWriteBytes", addr: 0xe000, bytes: [0, 1, 0, 0, 1] }, + ]); + }); + + it("writeSerialRegister surfaces an aborted signal before any write", async () => { + const { device, calls } = recordingDevice(); + const controller = new AbortController(); + controller.abort(); + await expect( + new KazzoNesBus(device, controller.signal).writeSerialRegister(0xe000, 0x1f), + ).rejects.toThrow(); + expect(calls).toHaveLength(0); + }); + + it("readCpu / readPpu map to the matching device reads and forward progress", async () => { + const { device, calls } = recordingDevice(); + const bus = new KazzoNesBus(device); + const seen: number[] = []; + const prg = await bus.readCpu(0x8000, 0x2000, (r) => seen.push(r)); + const chr = await bus.readPpu(0x0000, 0x1000); + expect(prg.every((b) => b === 0x11)).toBe(true); + expect(chr.every((b) => b === 0x22)).toBe(true); + expect(calls).toEqual([ + { m: "cpuRead", addr: 0x8000, length: 0x2000 }, + { m: "ppuRead", addr: 0x0000, length: 0x1000 }, + ]); + expect(seen).toEqual([0x2000]); + }); + + it("a zero-length read is a no-op (no device call)", async () => { + const { device, calls } = recordingDevice(); + const out = await new KazzoNesBus(device).readCpu(0x8000, 0); + expect(out).toHaveLength(0); + expect(calls).toHaveLength(0); + }); + + it("an aborted signal surfaces through readCpu", async () => { + const { device } = recordingDevice(); + const controller = new AbortController(); + controller.abort(); + await expect( + new KazzoNesBus(device, controller.signal).readCpu(0x8000, 0x100), + ).rejects.toThrow(); + }); +}); diff --git a/src/lib/drivers/kazzo/kazzo-nes-bus.ts b/src/lib/drivers/kazzo/kazzo-nes-bus.ts new file mode 100644 index 0000000..5a88613 --- /dev/null +++ b/src/lib/drivers/kazzo/kazzo-nes-bus.ts @@ -0,0 +1,80 @@ +/** + * `NesBus` adapter for the Kazzo dumper. Maps the generic CPU/PPU bus + * primitives the shared NES mapper catalog consumes onto the Kazzo + * firmware's per-byte read/write requests. + * + * Kazzo synthesizes real NES bus cycles per byte, so the adapter is thin: + * `readCpu`/`readPpu` are direct chunked reads (no double-buffered dump + * engine like INL needs), and `writeCpu` is a single 6502-style write. + * + * It implements one optional capability and omits two: + * - `writeSerialRegister` (implemented): MMC1's five-write serial load is + * sent as a single CPU_WRITE_6502 carrying all five shifted bytes — the + * firmware writes each to the register address in turn (one 6502 cycle + * each). This matches the reference anago `mmc1_write` (one transfer, not + * five) and removes four USB round-trips from a stateful, all-or-nothing + * load — the failure mode that's most exposed to a per-transaction hiccup. + * - `readChrBankLatched`: bus-conflict CHR mappers fall back to + * `writeCpu` + `readPpu`, which Kazzo expresses directly. + * - `readCpuBankLatched`: the fused latch+read primitive (mapper 470) the + * firmware doesn't have; the 470 walk falls back to re-latching with + * `writeCpu` before each sub-read. + */ + +import type { NesBus, BusProgressCb } from "@/lib/systems/nes/bus"; +import type { KazzoDevice } from "./kazzo-device"; + +export class KazzoNesBus implements NesBus { + private readonly device: KazzoDevice; + // Forwarded into the read loop so an abort interrupts per 256-byte page + // rather than only at region boundaries. + private readonly signal?: AbortSignal; + + constructor(device: KazzoDevice, signal?: AbortSignal) { + this.device = device; + this.signal = signal; + } + + async setup(): Promise { + // PHI2 (CPU clock) must be running before the firmware can synthesize + // bus cycles. Mappers call setup() at the start of every dump. + await this.device.phi2Init(); + } + + async writeCpu(addr: number, value: number): Promise { + this.signal?.throwIfAborted(); + await this.device.cpuWrite(addr, value); + } + + /** + * MMC1's five-write serial load, atomically: clock the low 5 bits of + * `value` (LSB first) into the shift register at `addr` as one + * CPU_WRITE_6502 transfer. Each byte's bit 0 is what MMC1 latches; the + * mapper issues the bit-7 reset via `writeCpu` first, so this is only the + * data load. Equivalent to five per-bit `writeCpu`s but in a single + * transaction (matches the reference anago `mmc1_write`). + */ + async writeSerialRegister(addr: number, value: number): Promise { + this.signal?.throwIfAborted(); + const bits = Uint8Array.from({ length: 5 }, (_, i) => (value >> i) & 0x01); + await this.device.cpuWriteBytes(addr, bits); + } + + async readCpu( + addr: number, + length: number, + onProgress?: BusProgressCb, + ): Promise { + if (length === 0) return new Uint8Array(0); + return this.device.cpuRead(addr, length, onProgress, this.signal); + } + + async readPpu( + addr: number, + length: number, + onProgress?: BusProgressCb, + ): Promise { + if (length === 0) return new Uint8Array(0); + return this.device.ppuRead(addr, length, onProgress, this.signal); + } +} diff --git a/src/lib/drivers/kazzo/kazzo-opcodes.ts b/src/lib/drivers/kazzo/kazzo-opcodes.ts new file mode 100644 index 0000000..64f1f92 --- /dev/null +++ b/src/lib/drivers/kazzo/kazzo-opcodes.ts @@ -0,0 +1,121 @@ +/** + * Kazzo NES/Famicom cartridge dumper — USB protocol constants. + * + * Kazzo is the NES dumper designed by naruko (circa 2010); anago is the + * original command-line host software. AVR-based INL Retro boards (v1.x, + * pre-2018) can be reflashed with Kazzo firmware and operated via this + * protocol as an alternative to the INL dictionary protocol. + * + * These are interface facts — vendor request numbers and the wire format — + * reimplemented from the documented USB protocol, not ported from the + * (GPL-2.0-only) firmware sources: + * github.com/sharkpp/unagi_kazzo/blob/master/firmware/usbrequest.txt + * github.com/zerkerX/anago/blob/master/kazzo/kazzo_request.h + */ + +// ─── Vendor/device USB descriptors ────────────────────────────────────────── + +/** + * V-USB's shared obdev defaults. Kazzo and INL firmwares both use this + * VID/PID pair; disambiguate by the iProduct string at device-open time. + */ +export const KAZZO_DEVICE_FILTER = { + vendorId: 0x16c0, + productId: 0x05dc, +} as const; + +/** iProduct descriptor value reported by Kazzo firmware. */ +export const KAZZO_PRODUCT_NAME = "kazzo"; + +// ─── Request opcodes (bRequest) ───────────────────────────────────────────── + +/** Vendor control-transfer request numbers (from `kazzo_request.h`). */ +export const REQUEST = { + ECHO: 0, + PHI2_INIT: 1, + CPU_READ_6502: 2, + CPU_READ: 3, + CPU_WRITE_6502: 4, + CPU_WRITE_FLASH: 5, + PPU_READ: 6, + PPU_WRITE: 7, + FLASH_STATUS: 8, + FLASH_CONFIG_SET: 9, + FLASH_PROGRAM: 10, + FLASH_ERASE: 11, + FLASH_DEVICE: 12, + VRAM_CONNECTION: 13, + DISK_STATUS_GET: 14, + DISK_READ: 15, + DISK_WRITE: 16, + FIRMWARE_VERSION: 0x80, + FIRMWARE_PROGRAM: 0x81, + FIRMWARE_DOWNLOAD: 0x82, +} as const; + +/** + * Requests nabu refuses outright: every opcode that programs or erases a + * cartridge's flash or the dumper's firmware, plus the two flash-probe opcodes + * that drive command cycles onto the cart bus (FLASH_DEVICE runs the JEDEC + * autoselect/ID sequence; FLASH_STATUS is its companion poll). A pure read-only + * dumper issues NONE of these, so KazzoDevice rejects them at the transport + * boundary before any transfer — mirroring the INL driver's flash-write guard. + */ +export const REFUSED_REQUESTS: ReadonlySet = new Set([ + REQUEST.CPU_WRITE_FLASH, + REQUEST.FLASH_STATUS, + REQUEST.FLASH_CONFIG_SET, + REQUEST.FLASH_PROGRAM, + REQUEST.FLASH_ERASE, + REQUEST.FLASH_DEVICE, + REQUEST.DISK_WRITE, + REQUEST.FIRMWARE_PROGRAM, + REQUEST.FIRMWARE_DOWNLOAD, +]); + +// ─── Region selector (wIndex) ─────────────────────────────────────────────── + +/** + * wIndex values used by memory-access opcodes to pick which bus/region the + * request targets. `IMPLIED` is used for the plain read/write opcodes that + * already imply a bus via their request number. + */ +export const INDEX = { + IMPLIED: 0, + CPU: 1, + PPU: 2, + BOTH: 3, +} as const; + +// ─── Protocol constants ───────────────────────────────────────────────────── + +/** + * Maximum bytes per single control transfer. Firmware buffers a page at a + * time; the host loops across pages for larger regions. + */ +export const READ_PACKET_SIZE = 0x100; + +/** Firmware version string length returned by FIRMWARE_VERSION. */ +export const VERSION_STRING_SIZE = 0x20; + +/** + * Outgoing write data is XORed with this byte before transfer. V-USB + * occasionally loses bits during long runs of 0xFF, so naruko masks + * outgoing bytes to break up such runs; the firmware un-masks on receive. + * Reads are NOT XORed. + */ +export const WRITE_XOR_MASK = 0xa5; + +// ─── VRAM A10 / nametable mirroring ───────────────────────────────────────── + +/** + * VRAM_CONNECTION returns a 4-bit pattern encoding how the cartridge wires + * PPU A10/A11 to CIRAM A10. Per usbrequest.txt the possible values are + * {0x00, 0x05, 0x09, 0x0F}. + * + * The reference host (anago/script_dump.c) only distinguishes 0x05 from + * everything else: 0x05 is vertical, any other value is horizontal. + * Single-screen mirroring is mapper-controlled on the carts that use it, + * so the hardware probe can't see it directly. + */ +export const VRAM_VERTICAL = 0x05; diff --git a/src/lib/drivers/kazzo/kazzo-transport.ts b/src/lib/drivers/kazzo/kazzo-transport.ts new file mode 100644 index 0000000..9fd8b1e --- /dev/null +++ b/src/lib/drivers/kazzo/kazzo-transport.ts @@ -0,0 +1,67 @@ +/** + * Adapts the Kazzo control-transfer device wrapper (`KazzoDevice`) to the + * generic `Transport` interface the connection registry threads through. + * + * Kazzo speaks its own vendor-request protocol over USB control transfers + * rather than a bulk byte stream, so `send`/`receive` are intentionally + * unsupported — the driver talks to the underlying `KazzoDevice` directly + * (exposed as `.device`) for its read/write requests. + */ + +import type { + Transport, + TransportType, + TransportEvents, + DeviceIdentity, +} from "@/lib/types"; +import { KazzoDevice } from "./kazzo-device"; + +export class KazzoTransport implements Transport { + readonly type: TransportType = "webusb"; + readonly device = new KazzoDevice(); + + get connected(): boolean { + return this.device.connected; + } + + /** Prompt the user to pick a Kazzo device, then open it. */ + async connect(): Promise { + await this.device.connect(); + return this.identity(); + } + + /** Reopen a previously authorized device (page-load reconnect). */ + async connectWithDevice(usb: USBDevice): Promise { + await this.device.connectWithDevice(usb); + return this.identity(); + } + + disconnect(): Promise { + return this.device.disconnect(); + } + + on( + event: K, + handler: TransportEvents[K], + ): void { + if (event === "onDisconnect") { + this.device.onDisconnected(handler as () => void); + } + } + + send(): Promise { + throw new Error( + "Kazzo transport speaks a control-transfer protocol, not raw send()", + ); + } + + receive(): Promise { + throw new Error( + "Kazzo transport speaks a control-transfer protocol, not raw receive()", + ); + } + + private identity(): DeviceIdentity { + return { name: this.device.productName, transport: "webusb" }; + } +} diff --git a/src/lib/drivers/kazzo/unsupported-mappers.ts b/src/lib/drivers/kazzo/unsupported-mappers.ts new file mode 100644 index 0000000..8607dc6 --- /dev/null +++ b/src/lib/drivers/kazzo/unsupported-mappers.ts @@ -0,0 +1,68 @@ +/** + * Catalog mappers gated on a Kazzo firmware feature: M2 idling HIGH. + * + * The SMD172-family CPLD reissue boards (mapper 268 CoolBoy / Mindkids, + * mapper 470 INX_007T_V01) require the M2/phi2 clock to idle high between + * bus operations. Sustained M2-low reads as console-off/reset: register + * writes are silently reverted while reads still work, so a dump on the + * wrong firmware returns a plausible-length file that is really the boot + * bank mirrored across every slot — no error, just wrong bytes. + * + * Which Kazzo firmware qualifies is an era split (audited from the firmware + * history): builds from 2009-11-01 through 2010-01-24 idle M2 HIGH (version + * strings "kazzo16 0.1.0"–"0.1.2"); the polarity flipped LOW on 2010-01-25 + * and was never restored, so released 0.1.3 and everything later idles LOW. + * Both mappers were hardware-validated on a pre-flip build (268: a 2 MB + * cart dumped byte-perfect 2026-06-08; 470: a 1 MB cart byte-perfect + * 2026-06-09, after one false alarm that turned out to be D7 floating high + * on a dirty edge connector, not a mapper limit). The historical + * INL-distributed copy of that firmware had its version section clipped + * off (FIRMWARE_VERSION reads 32 bytes of erased flash) — capable in + * practice, but a blank version section is no identity, so it deliberately + * classifies as unidentified and gates. The recognized capable builds are + * the ones that say what they are: pre-flip version strings, and the + * maintained m2-idle-high firmware branch, which reports + * "kazzo16 0.1.3+m2" (same pre-flip bus behavior, self-identifying; not + * yet separately hardware-run as of 2026-06-10). + * + * The driver classifies the connected firmware once per connection from the + * FIRMWARE_VERSION fingerprint (see ./firmware-m2) and applies the result + * via `unsupportedMappersFor`: capable era → gate lifted; 0.1.3+/garbage/ + * short read/transfer error → gated (fail-safe). The map feeds + * `capability.unsupportedMappers` (greys the config UI) and + * `KazzoDriver.resolveMapper` (pre-flight reject). The mappers stay in the + * shared catalog regardless, for devices whose bus drives the CPLD. + */ + +/** M2-idle-gated mapper id → reason shown in the idle-low pre-flight error. */ +export const M2_IDLE_GATED_MAPPERS: ReadonlyMap = new Map([ + [ + 268, + "this firmware build idles the M2 clock low between bus cycles, which " + + "the board's CPLD treats as console-off — register writes are " + + "reverted and every bank reads back as the boot menu. A Kazzo build " + + "that idles M2 high (kazzo16 0.1.0–0.1.2, or the self-identifying " + + "kazzo16 0.1.3+m2 branch) enables this mapper", + ], + [ + 470, + "this firmware build idles the M2 clock low between bus cycles, which " + + "this CPLD board family (same family as mapper 268) treats as " + + "console-off — register writes are reverted. A Kazzo build that idles " + + "M2 high (kazzo16 0.1.0–0.1.2, or the self-identifying " + + "kazzo16 0.1.3+m2 branch) enables this mapper", + ], +]); + +const NONE: ReadonlyMap = new Map(); + +/** + * The effective unsupported-mapper map for a session, given the classified + * firmware M2 idle level: idle-low (or unknown) gates the map above; an + * idle-high era build lifts the gate entirely. + */ +export function unsupportedMappersFor( + m2IdleHigh: boolean, +): ReadonlyMap { + return m2IdleHigh ? NONE : M2_IDLE_GATED_MAPPERS; +} diff --git a/src/lib/systems/nes/mappers/coolboy.ts b/src/lib/systems/nes/mappers/coolboy.ts index a8258db..f7dc6da 100644 --- a/src/lib/systems/nes/mappers/coolboy.ts +++ b/src/lib/systems/nes/mappers/coolboy.ts @@ -58,15 +58,17 @@ * Cart-side CHR is RAM only on submappers 0/1 — `dumpChrRom` returns * an empty array. * - * Hardware caveat: SMD172-family carts have an on-board reset detector - * that some forum reports claim re-locks the mapper mid-dump on generic - * dumpers (CopyNES and Tengu are reported as clean). A dumper driving - * dense back-to-back bus cycles latches these registers with occasional - * stochastic dropouts — the consensus read below exists for exactly - * that. The INL Retro, which idles M2 low and emits one M2 pulse per - * write, never latches a single register write at all; its driver - * pre-flight-rejects this mapper — see UNSUPPORTED_MAPPERS in - * drivers/inl/unsupported-mappers for the full hardware account. + * Hardware caveat: SMD172-family carts require the M2/phi2 clock to idle + * HIGH between bus operations — sustained M2-low reads as console-off and + * register writes are reverted (reads are unaffected). A dumper that + * parks M2 high latches these registers with occasional stochastic + * dropouts — the consensus read below exists for exactly that. Dumpers + * whose firmware idles M2 low (stock INL Retro builds) can read but never + * keep a register write. Both nabu drivers pre-flight-reject this mapper on + * M2-low firmware — the INL driver by feature-detecting the connected + * firmware's M2 idle level, the Kazzo driver by fingerprinting the firmware + * version — see M2_IDLE_GATED_MAPPERS in each driver's unsupported-mappers + * for the full account. * * References: * - ClusterM Sub1 (Mindkids, $5000): github.com/ClusterM/famicom-dumper-client diff --git a/src/lib/systems/nes/mappers/index.ts b/src/lib/systems/nes/mappers/index.ts index 8daf653..af748b4 100644 --- a/src/lib/systems/nes/mappers/index.ts +++ b/src/lib/systems/nes/mappers/index.ts @@ -52,17 +52,19 @@ export const NES_MAPPERS: Record = { // hardware-verified. Submapper 0 (CoolBoy, registers at $6000) shares // the implementation via createMapper268(0) but needs its own catalog // mechanism (submapper isn't part of the config UI) when a cart shows up. - // The INL driver pre-flight-rejects this id — the board's CPLD refuses - // that device's synthesized writes; see UNSUPPORTED_MAPPERS in - // drivers/inl/unsupported-mappers for the hardware-classified account. + // The board's CPLD needs M2 idling high. Both drivers pre-flight-reject this + // id on M2-low firmware: the INL driver feature-detects the connected + // firmware's M2 idle level at init, while the Kazzo driver fingerprints the + // firmware version (only known idle-high builds open the gate) — see + // M2_IDLE_GATED_MAPPERS in each driver's unsupported-mappers for the account. 268: mapper268Mindkids, // Spec-derived implementation (emulator/FPGA consensus), not yet // hardware-validated; the only mapper with a miscellaneous-ROM // section. See the data-port probe in batmap.ts. 413: mapper413, // Vendor-recipe implementation, not yet hardware-validated on a nabu - // driver (see the cadence lesson in inx007t.ts); INL pre-flight- - // rejects this id too — same CPLD-refusal family as 268. + // driver (see the cadence lesson in inx007t.ts); same M2-idle-gated + // CPLD board family as 268 on the INL. 470: mapper470, }; diff --git a/src/lib/systems/nes/mappers/inx007t.ts b/src/lib/systems/nes/mappers/inx007t.ts index ab040b9..ce37ba8 100644 --- a/src/lib/systems/nes/mappers/inx007t.ts +++ b/src/lib/systems/nes/mappers/inx007t.ts @@ -36,9 +36,13 @@ * latch happens to hold; drivers for this board family should supply * the fused capability. * - * NOT yet hardware-validated on any nabu driver. The INL Retro is - * excluded outright — this board family refuses its synthesized writes; - * see UNSUPPORTED_MAPPERS in drivers/inl/unsupported-mappers. + * Hardware-validated on the Kazzo driver (1 MiB cart byte-perfect vs the + * reference, via the split write/read path — so the inner latch survives + * inter-transaction idle on a bus whose M2 idles high). This board family + * needs M2 idling high, so both drivers pre-flight-reject this id on M2-low + * firmware — the INL driver feature-detects the firmware's M2 idle level, the + * Kazzo driver fingerprints the firmware version — see M2_IDLE_GATED_MAPPERS + * in each driver's unsupported-mappers. * * References: * - nesdev wiki: nesdev.org/wiki/NES_2.0_Mapper_470