From ab59e64b712ff24b0d0b1e9002cdd9bae388f5dd Mon Sep 17 00:00:00 2001 From: Pat Hawks Date: Sat, 13 Jun 2026 16:44:04 -0500 Subject: [PATCH 01/11] Add Kazzo NES cartridge dumper driver Kazzo is naruko's AVR + V-USB NES dumper (anago host); AVR-based INL Retro boards can be reflashed with its firmware. It shares the INL's 16c0:05dc VID/PID, disambiguated by the "kazzo" product string. The driver reuses the shared, device-agnostic NES mapper catalog through a thin NesBus adapter: per-byte 6502/PPU read/write requests over vendor control transfers, 256-byte page reads, and the XOR-0xA5 write masking the firmware expects. Mappers 268/470 are pre-flight-rejected (same CPLD-refusal family as on the INL). Reads only: every flash/firmware program or erase request is refused at the device layer. Protocol constants are reimplemented from the documented USB protocol, not ported from the GPL-2.0-only firmware. Not yet hardware-validated: the dump path is exercised only by fakes. --- THIRD-PARTY-LICENSES | 16 + src/lib/core/connection-registry.ts | 13 + src/lib/core/devices.ts | 12 + src/lib/drivers/kazzo/detect-mirroring.ts | 18 + src/lib/drivers/kazzo/kazzo-device.test.ts | 171 ++++++++++ src/lib/drivers/kazzo/kazzo-device.ts | 311 ++++++++++++++++++ src/lib/drivers/kazzo/kazzo-driver.test.ts | 327 +++++++++++++++++++ src/lib/drivers/kazzo/kazzo-driver.ts | 225 +++++++++++++ src/lib/drivers/kazzo/kazzo-nes-bus.test.ts | 84 +++++ src/lib/drivers/kazzo/kazzo-nes-bus.ts | 62 ++++ src/lib/drivers/kazzo/kazzo-opcodes.ts | 116 +++++++ src/lib/drivers/kazzo/kazzo-transport.ts | 67 ++++ src/lib/drivers/kazzo/unsupported-mappers.ts | 32 ++ 13 files changed, 1454 insertions(+) create mode 100644 src/lib/drivers/kazzo/detect-mirroring.ts create mode 100644 src/lib/drivers/kazzo/kazzo-device.test.ts create mode 100644 src/lib/drivers/kazzo/kazzo-device.ts create mode 100644 src/lib/drivers/kazzo/kazzo-driver.test.ts create mode 100644 src/lib/drivers/kazzo/kazzo-driver.ts create mode 100644 src/lib/drivers/kazzo/kazzo-nes-bus.test.ts create mode 100644 src/lib/drivers/kazzo/kazzo-nes-bus.ts create mode 100644 src/lib/drivers/kazzo/kazzo-opcodes.ts create mode 100644 src/lib/drivers/kazzo/kazzo-transport.ts create mode 100644 src/lib/drivers/kazzo/unsupported-mappers.ts 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/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..1779ac2 100644 --- a/src/lib/core/devices.ts +++ b/src/lib/core/devices.ts @@ -57,6 +57,18 @@ 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", + 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/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/kazzo-device.test.ts b/src/lib/drivers/kazzo/kazzo-device.test.ts new file mode 100644 index 0000000..3591ec7 --- /dev/null +++ b/src/lib/drivers/kazzo/kazzo-device.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from "vitest"; +import { KazzoDevice } from "./kazzo-device"; +import { REQUEST, WRITE_XOR_MASK, VRAM_VERTICAL } 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 + }); +}); + +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", async () => { + const controller = new AbortController(); + let pages = 0; + const { device } = makeDevice((c) => { + if (++pages === 2) controller.abort(); + return new Uint8Array(c.length); + }); + await expect( + device.cpuRead(0x8000, 0x400, undefined, controller.signal), + ).rejects.toThrow(); + }); +}); + +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("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)", () => { + it("refuses flash/firmware-write requests before any transfer", async () => { + const { device, outCalls, inCalls } = makeDevice(); + for (const req of [ + REQUEST.CPU_WRITE_FLASH, + REQUEST.FLASH_PROGRAM, + REQUEST.FLASH_ERASE, + REQUEST.FIRMWARE_PROGRAM, + ]) { + 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); + }); +}); diff --git a/src/lib/drivers/kazzo/kazzo-device.ts b/src/lib/drivers/kazzo/kazzo-device.ts new file mode 100644 index 0000000..189f85e --- /dev/null +++ b/src/lib/drivers/kazzo/kazzo-device.ts @@ -0,0 +1,311 @@ +/** + * 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, + FLASH_WRITE_REQUESTS, + READ_PACKET_SIZE, + REQUEST, + INDEX, + VERSION_STRING_SIZE, + WRITE_XOR_MASK, +} from "./kazzo-opcodes"; + +/** 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; + + 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"; + } + + /** 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; + } + + onDisconnected(handler: () => void): void { + this.onDisconnect = handler; + } + + private handleDisconnect = (event: USBConnectionEvent) => { + if (event.device === this.device) { + this.device = 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. Refuse every write/erase request + * the firmware exposes (see FLASH_WRITE_REQUESTS) before it goes out. + */ + private assertReadOnly(request: number): void { + if (FLASH_WRITE_REQUESTS.has(request)) { + throw new Error( + `Refusing Kazzo flash/firmware-write 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})`, + ); + } + + if (!result.data) return new Uint8Array(0); + 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, + onProgress?: KazzoProgressCb, + signal?: AbortSignal, + ): Promise { + 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); + const chunk = await this.controlIn(request, addr, INDEX.IMPLIED, n); + if (chunk.length !== n) { + throw new Error( + `Kazzo short read (got ${chunk.length}, expected ${n} at $${addr.toString(16)})`, + ); + } + result.set(chunk, offset); + offset += n; + addr += n; + onProgress?.(offset, length); + } + + return result; + } + + // ─── Domain methods ───────────────────────────────────────────────────── + + /** Connectivity handshake. Returns `[value_lo, value_hi, index_lo, index_hi]`. */ + 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, 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, onProgress, signal); + } + + /** + * Write one byte to the CPU bus at `address` via the firmware's + * 6502-style write cycle. Used to poke mapper registers (banking, + * MMC1's per-bit serial loads, etc.). + */ + async cpuWrite(address: number, byte: number): Promise { + await this.controlOut( + REQUEST.CPU_WRITE_6502, + address, + INDEX.IMPLIED, + 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, + ); + 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..1247bbc --- /dev/null +++ b/src/lib/drivers/kazzo/kazzo-driver.test.ts @@ -0,0 +1,327 @@ +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 } from "./kazzo-opcodes"; + +/** + * 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 + * unsupported mappers are pre-flight-rejected before any cart traffic, and + * detect/init/save take their expected shapes. + */ + +interface Call { + m: "phi2Init" | "cpuWrite" | "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?: string; +} + +/** 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"; + const device = { + productName: "kazzo", + firmwareVersion: firmware, + async fetchFirmwareVersion() { + calls.push({ m: "firmware" }); + return firmware; + }, + async phi2Init() { + calls.push({ m: "phi2Init" }); + }, + async cpuWrite(addr: number, value: number) { + calls.push({ m: "cpuWrite", addr, value }); + }, + 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); + // The CPLD-refusal family — same set the driver pre-flight-rejects. + expect(cap[0].unsupportedMappers).toEqual([268, 470]); + }); +}); + +describe("KazzoDriver.initialize", () => { + it("fetches the firmware version and reports device info", async () => { + const { device, calls } = fakeKazzo({ firmware: "kazzo 1.5" }); + const info = await makeDriver(device).initialize(); + expect(info.firmwareVersion).toBe("kazzo 1.5"); + expect(info.deviceName).toBe("kazzo"); + expect(info.capabilities[0].systemId).toBe("nes"); + expect(calls.some((c) => c.m === "firmware")).toBe(true); + }); +}); + +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", () => { + it.each([ + [268, 2048], + [470, 1024], + ])( + "pre-flight-rejects mapper %i without touching the device", + async (mapper, prgKB) => { + const { device, calls } = fakeKazzo(); + await expect( + makeDriver(device).readROM( + romConfig({ + mapper, + prgSizeBytes: prgKB * 1024, + chrSizeBytes: 0, + }), + ), + ).rejects.toThrow(/Kazzo/); + // Rejected before any cart traffic — not a boot-bank-mirror garbage dump. + expect(calls).toHaveLength(0); + }, + ); + + 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); + }); +}); + +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("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..a5bd305 --- /dev/null +++ b/src/lib/drivers/kazzo/kazzo-driver.ts @@ -0,0 +1,225 @@ +/** + * 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. + * + * NOT yet hardware-validated: the device/protocol layer is reimplemented + * from the documented Kazzo protocol and the dump path is exercised only by + * fakes; real-cart testing is pending. + */ + +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"; +import { UNSUPPORTED_MAPPERS } from "./unsupported-mappers"; +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 out in the config UI; readROM pre-flight-rejects them + // too. See ./unsupported-mappers for the (family-inference) reasoning. + unsupportedMappers: [...UNSUPPORTED_MAPPERS.keys()], + }, + ]; + + 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..."); + await this.kazzoDevice.fetchFirmwareVersion(); + this.log("Device ready"); + + return { + firmwareVersion: this.kazzoDevice.firmwareVersion, + 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 = UNSUPPORTED_MAPPERS.get(mapperId); + if (unsupportedReason) { + throw new Error( + `Mapper ${mapperId} (${mapper.name}) can't be dumped with Kazzo: ` + + `${unsupportedReason}. The cart itself is fine — use a dumper this ` + + "board accepts.", + ); + } + 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..f97e655 --- /dev/null +++ b/src/lib/drivers/kazzo/kazzo-nes-bus.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { KazzoNesBus } from "./kazzo-nes-bus"; +import type { KazzoDevice } from "./kazzo-device"; + +interface Call { + m: "phi2Init" | "cpuWrite" | "cpuRead" | "ppuRead"; + addr?: number; + value?: number; + length?: 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 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("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..21af3bc --- /dev/null +++ b/src/lib/drivers/kazzo/kazzo-nes-bus.ts @@ -0,0 +1,62 @@ +/** + * `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 deliberately omits the optional capabilities: + * - `writeSerialRegister`: MMC1's five-write serial load is driven as five + * plain `writeCpu`s — the kazzo-native approach (anago does the same), so + * no atomic-shift helper is needed. + * - `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; 470 is pre-flight-rejected anyway. + */ + +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); + } + + 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..0191dd7 --- /dev/null +++ b/src/lib/drivers/kazzo/kazzo-opcodes.ts @@ -0,0 +1,116 @@ +/** + * 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; + +/** + * Request numbers that PROGRAM/ERASE a cartridge's flash or firmware. + * nabu is a read-only dumper and never issues these; KazzoDevice refuses + * them, mirroring the INL driver's flash-write guard. + */ +export const FLASH_WRITE_REQUESTS: ReadonlySet = new Set([ + REQUEST.CPU_WRITE_FLASH, + REQUEST.FLASH_CONFIG_SET, + REQUEST.FLASH_PROGRAM, + REQUEST.FLASH_ERASE, + 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..c1c73e2 --- /dev/null +++ b/src/lib/drivers/kazzo/unsupported-mappers.ts @@ -0,0 +1,32 @@ +/** + * Mappers the Kazzo dumper cannot drive, even though they exist in the + * shared NES catalog. + * + * Mappers 268 (CoolBoy / Mindkids) and 470 (INX_007T_V01) reimplement their + * mapper in a CPLD whose reset detector wants sustained M2 clocking before + * it will latch a register write. Kazzo is the same AVR + V-USB lineage as + * the INL Retro — it idles M2 between transfers and emits a single pulse per + * write — and the INL was *hardware-classified* against both boards as + * unable to land a single register write (see + * drivers/inl/unsupported-mappers.ts). Kazzo has NOT been separately tested + * on these carts, so this rejection is by family inference: pre-flighting it + * is the honest default (a dump would return a boot-bank mirror), and an + * instrumented attempt could overturn it. + * + * As with INL, the driver rejects these ids before any cart traffic and + * feeds the key set to `capability.unsupportedMappers` so the config UI + * greys them out. The mappers stay in the catalog for devices whose bus + * drives the CPLD. + */ +export const UNSUPPORTED_MAPPERS: ReadonlyMap = new Map([ + [ + 268, + "this board's CPLD ignores AVR/V-USB synthesized writes (hardware-" + + "classified on the closely-related INL Retro); Kazzo is the same family", + ], + [ + 470, + "same CPLD-refusal family as mapper 268 — not separately tested on Kazzo, " + + "rejected by inference from the INL classification", + ], +]); From a898416b54acd346c340ef2c6c8d76f1b5d29350 Mon Sep 17 00:00:00 2001 From: Pat Hawks Date: Sat, 13 Jun 2026 16:44:04 -0500 Subject: [PATCH 02/11] Disambiguate INL Retro and Kazzo on their shared USB ID Both devices enumerate as 16c0:05dc; matching on VID/PID alone let one physical device claim both connection entries, and page-load auto-reconnect would run the INL driver against kazzo firmware (dictionary error 0xff). DeviceDef gains an optional usbProduct substring: a def that declares one requires it in the USB product string, and a def without one acts as the catch-all only when no sibling claims the device. --- src/hooks/use-connection.test.ts | 71 ++++++++++++++++++++++++++++++++ src/hooks/use-connection.ts | 41 +++++++++++++----- src/lib/core/devices.ts | 9 ++++ 3 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 src/hooks/use-connection.test.ts 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..9c0b772 100644 --- a/src/hooks/use-connection.ts +++ b/src/hooks/use-connection.ts @@ -6,6 +6,33 @@ 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; + const product = d.productName ?? ""; + if (dev.usbProduct) return product.includes(dev.usbProduct); + return !defs.some( + (o) => + o !== dev && + o.vendorId === dev.vendorId && + o.productId === dev.productId && + o.usbProduct && + product.includes(o.usbProduct), + ); +} + /** Check all browser device APIs for previously-authorized, currently-connected devices. */ async function probeAvailableDevices(): Promise> { const available = new Set(); @@ -30,13 +57,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 +106,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()) ?? []; diff --git a/src/lib/core/devices.ts b/src/lib/core/devices.ts index 1779ac2..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[]; @@ -63,6 +71,7 @@ export const DEVICES: Record = { 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). " + From a46c4fc1a8963af1be4dcdd4564f86dc97774461 Mon Sep 17 00:00:00 2001 From: Pat Hawks Date: Sat, 13 Jun 2026 16:44:04 -0500 Subject: [PATCH 03/11] Kazzo: batch MMC1 serial loads; enable mappers 268 and 470 writeSerialRegister sends all five serial bits in one CPU_WRITE_6502 transfer (the firmware writes each payload byte to the register address), matching the reference host's MMC1 routine - more robust than five separate USB writes for the stateful shift-register load. Both CPLD reissue mappers are hardware-validated byte-perfect on this driver against references: the inherited unsupported inference from the INL classification does not transfer to this firmware's bus behavior. --- src/lib/drivers/kazzo/kazzo-device.test.ts | 12 +++++++ src/lib/drivers/kazzo/kazzo-device.ts | 21 ++++++++---- src/lib/drivers/kazzo/kazzo-driver.test.ts | 29 ++++------------ src/lib/drivers/kazzo/kazzo-nes-bus.test.ts | 25 +++++++++++++- src/lib/drivers/kazzo/kazzo-nes-bus.ts | 25 +++++++++++--- src/lib/drivers/kazzo/unsupported-mappers.ts | 35 ++++++++++---------- 6 files changed, 95 insertions(+), 52 deletions(-) diff --git a/src/lib/drivers/kazzo/kazzo-device.test.ts b/src/lib/drivers/kazzo/kazzo-device.test.ts index 3591ec7..60d43c0 100644 --- a/src/lib/drivers/kazzo/kazzo-device.test.ts +++ b/src/lib/drivers/kazzo/kazzo-device.test.ts @@ -71,6 +71,18 @@ describe("KazzoDevice writes", () => { 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", () => { diff --git a/src/lib/drivers/kazzo/kazzo-device.ts b/src/lib/drivers/kazzo/kazzo-device.ts index 189f85e..2801c47 100644 --- a/src/lib/drivers/kazzo/kazzo-device.ts +++ b/src/lib/drivers/kazzo/kazzo-device.ts @@ -255,18 +255,25 @@ export class KazzoDevice { return this.readChunked(REQUEST.PPU_READ, address, length, 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, - * MMC1's per-bit serial loads, etc.). + * UxROM/MMC3 latches, etc.). */ async cpuWrite(address: number, byte: number): Promise { - await this.controlOut( - REQUEST.CPU_WRITE_6502, - address, - INDEX.IMPLIED, - new Uint8Array([byte & 0xff]), - ); + await this.cpuWriteBytes(address, new Uint8Array([byte & 0xff])); } /** Write one byte to the PPU bus at `address`. */ diff --git a/src/lib/drivers/kazzo/kazzo-driver.test.ts b/src/lib/drivers/kazzo/kazzo-driver.test.ts index 1247bbc..5a14d65 100644 --- a/src/lib/drivers/kazzo/kazzo-driver.test.ts +++ b/src/lib/drivers/kazzo/kazzo-driver.test.ts @@ -93,8 +93,9 @@ describe("KazzoDriver capabilities", () => { expect(cap[0].systemId).toBe("nes"); expect(cap[0].operations).toContain("dump_rom"); expect(cap[0].autoDetect).toBe(true); - // The CPLD-refusal family — same set the driver pre-flight-rejects. - expect(cap[0].unsupportedMappers).toEqual([268, 470]); + // 268/470 are TEMPORARILY ENABLED for hardware testing (see + // ./unsupported-mappers) — nothing greyed out for now. + expect(cap[0].unsupportedMappers).toEqual([]); }); }); @@ -235,27 +236,9 @@ describe("KazzoDriver.readROM — MMC3 (banked)", () => { }); describe("KazzoDriver.readROM — unsupported mappers", () => { - it.each([ - [268, 2048], - [470, 1024], - ])( - "pre-flight-rejects mapper %i without touching the device", - async (mapper, prgKB) => { - const { device, calls } = fakeKazzo(); - await expect( - makeDriver(device).readROM( - romConfig({ - mapper, - prgSizeBytes: prgKB * 1024, - chrSizeBytes: 0, - }), - ), - ).rejects.toThrow(/Kazzo/); - // Rejected before any cart traffic — not a boot-bank-mirror garbage dump. - expect(calls).toHaveLength(0); - }, - ); - + // 268 (CoolBoy) and 470 are TEMPORARILY ENABLED for hardware testing (see + // ./unsupported-mappers), so they're no longer pre-flight-rejected. + // resolveMapper still 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( diff --git a/src/lib/drivers/kazzo/kazzo-nes-bus.test.ts b/src/lib/drivers/kazzo/kazzo-nes-bus.test.ts index f97e655..ac2d87a 100644 --- a/src/lib/drivers/kazzo/kazzo-nes-bus.test.ts +++ b/src/lib/drivers/kazzo/kazzo-nes-bus.test.ts @@ -3,10 +3,11 @@ import { KazzoNesBus } from "./kazzo-nes-bus"; import type { KazzoDevice } from "./kazzo-device"; interface Call { - m: "phi2Init" | "cpuWrite" | "cpuRead" | "ppuRead"; + 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. */ @@ -19,6 +20,9 @@ function recordingDevice() { 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, @@ -51,6 +55,25 @@ describe("KazzoNesBus", () => { 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); diff --git a/src/lib/drivers/kazzo/kazzo-nes-bus.ts b/src/lib/drivers/kazzo/kazzo-nes-bus.ts index 21af3bc..92c8884 100644 --- a/src/lib/drivers/kazzo/kazzo-nes-bus.ts +++ b/src/lib/drivers/kazzo/kazzo-nes-bus.ts @@ -7,10 +7,13 @@ * `readCpu`/`readPpu` are direct chunked reads (no double-buffered dump * engine like INL needs), and `writeCpu` is a single 6502-style write. * - * It deliberately omits the optional capabilities: - * - `writeSerialRegister`: MMC1's five-write serial load is driven as five - * plain `writeCpu`s — the kazzo-native approach (anago does the same), so - * no atomic-shift helper is needed. + * 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 @@ -42,6 +45,20 @@ export class KazzoNesBus implements NesBus { 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, diff --git a/src/lib/drivers/kazzo/unsupported-mappers.ts b/src/lib/drivers/kazzo/unsupported-mappers.ts index c1c73e2..8b26ca5 100644 --- a/src/lib/drivers/kazzo/unsupported-mappers.ts +++ b/src/lib/drivers/kazzo/unsupported-mappers.ts @@ -9,24 +9,25 @@ * write — and the INL was *hardware-classified* against both boards as * unable to land a single register write (see * drivers/inl/unsupported-mappers.ts). Kazzo has NOT been separately tested - * on these carts, so this rejection is by family inference: pre-flighting it - * is the honest default (a dump would return a boot-bank mirror), and an - * instrumented attempt could overturn it. + * on these carts, so this rejection is only an inference: a dump would likely + * return a boot-bank mirror, but an instrumented attempt could overturn it. * - * As with INL, the driver rejects these ids before any cart traffic and - * feeds the key set to `capability.unsupportedMappers` so the config UI - * greys them out. The mappers stay in the catalog for devices whose bus - * drives the CPLD. + * >>> INFERENCE OVERTURNED ON HARDWARE (2026-06-08) <<< + * The Kazzo dumped a 2 MB mapper-268 (Mindkids submapper 1) cart BYTE-PERFECT, + * matching the No-Intro reference (CRC32 E7822236) — so the INL classification + * does NOT transfer: the Kazzo's write cycle drives that CPLD where the INL's + * didn't. 268 is supported here. 470 (INX_007T_V01) is now ALSO + * hardware-validated on the Kazzo (2026-06-09) — a 1 MB cart dumped + * byte-perfect against the No-Intro reference (CRC32 55AB5439). A first + * attempt mismatched only because data line D7 floated high on a dirty edge + * connector (the dump was exactly reference|0x80, low 7 bits pristine); a + * clean re-seat verified it — a contact fault, not a mapper limit. Re-add an + * id below only if a cart is actually shown un-dumpable on Kazzo. + * + * The map feeds `capability.unsupportedMappers` (greys the config UI) and + * `KazzoDriver.resolveMapper` (pre-flight reject). Mappers stay in the shared + * catalog regardless, for devices whose bus drives the CPLD. */ export const UNSUPPORTED_MAPPERS: ReadonlyMap = new Map([ - [ - 268, - "this board's CPLD ignores AVR/V-USB synthesized writes (hardware-" + - "classified on the closely-related INL Retro); Kazzo is the same family", - ], - [ - 470, - "same CPLD-refusal family as mapper 268 — not separately tested on Kazzo, " + - "rejected by inference from the INL classification", - ], + // (empty — 268 hardware-validated on Kazzo; 470 enabled pending its own test) ]); From dca536b5589c353228bc8db8a1592f13a3ba220f Mon Sep 17 00:00:00 2001 From: Pat Hawks Date: Sat, 13 Jun 2026 16:46:12 -0500 Subject: [PATCH 04/11] INL: gate mappers 268/470 on the firmware's M2 idle level The SMD172-family CPLD boards require M2 to idle high between bus operations; sustained M2-low reads as console-off and register writes are reverted (reads unaffected). Stock INL firmware idles M2 low, so these mappers cannot dump on it - but an M2-idle-high firmware build can, hardware-verified byte-perfect on a 2 MiB mapper-268 cart with an MMC3 regression pass on the same build. The driver now feature-detects the connected firmware: one PINPORT CTL_RD of the M2 pin right after NES init (present in stock firmware too). Low or probe error keeps 268/470 pre-flight-rejected and greyed out; high enables them. M2_IDLE_GATED_MAPPERS is the single source of gated ids. --- src/lib/drivers/inl/inl-driver.test.ts | 130 ++++++++++++++-- src/lib/drivers/inl/inl-driver.ts | 57 +++++-- src/lib/drivers/inl/inl-opcodes.ts | 5 +- src/lib/drivers/inl/unsupported-mappers.ts | 163 +++++++++++---------- src/lib/systems/nes/mappers/coolboy.ts | 19 +-- src/lib/systems/nes/mappers/index.ts | 11 +- src/lib/systems/nes/mappers/inx007t.ts | 10 +- 7 files changed, 276 insertions(+), 119 deletions(-) diff --git a/src/lib/drivers/inl/inl-driver.test.ts b/src/lib/drivers/inl/inl-driver.test.ts index f129463..0871a54 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({ @@ -138,3 +158,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..ca85077 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 { @@ -126,12 +162,11 @@ export class INLDriver implements DeviceDriver { 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.`, ); } diff --git a/src/lib/drivers/inl/inl-opcodes.ts b/src/lib/drivers/inl/inl-opcodes.ts index b21b64e..8f94dea 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; @@ -58,7 +61,7 @@ export const NES = { // 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 exactly why it is NOT used - // as an M2-burst write primitive (see UNSUPPORTED_MAPPERS in + // as an M2-burst write primitive (see M2_IDLE_GATED_MAPPERS in // ./unsupported-mappers). MMC3_PRG_FLASH_WR: 0x07, SET_CUR_BANK: 0x20, 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/systems/nes/mappers/coolboy.ts b/src/lib/systems/nes/mappers/coolboy.ts index a8258db..a00b538 100644 --- a/src/lib/systems/nes/mappers/coolboy.ts +++ b/src/lib/systems/nes/mappers/coolboy.ts @@ -58,15 +58,16 @@ * 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; the INL driver feature-detects the connected + * firmware's M2 idle level and pre-flight-rejects this mapper on stock + * builds — see M2_IDLE_GATED_MAPPERS in drivers/inl/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..50090e4 100644 --- a/src/lib/systems/nes/mappers/index.ts +++ b/src/lib/systems/nes/mappers/index.ts @@ -52,17 +52,18 @@ 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; the INL driver feature-detects + // the firmware's M2 idle level and pre-flight-rejects this id on stock + // (M2-low) builds — see M2_IDLE_GATED_MAPPERS in + // drivers/inl/unsupported-mappers for the full 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..87b52ac 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). On the INL Retro + * this board family needs M2 idling high; the driver feature-detects the + * firmware's M2 idle level and pre-flight-rejects this id on stock + * (M2-low) builds — see M2_IDLE_GATED_MAPPERS in + * drivers/inl/unsupported-mappers. * * References: * - nesdev wiki: nesdev.org/wiki/NES_2.0_Mapper_470 From 388f63cbdc984d2dba4ed539ac68380f2cd5196a Mon Sep 17 00:00:00 2001 From: Pat Hawks Date: Sat, 13 Jun 2026 16:46:12 -0500 Subject: [PATCH 05/11] Kazzo: gate mappers 268/470 on the firmware's M2 idle era The CPLD reissue boards need M2 idling high; only some Kazzo firmware does. The driver classifies the connected build once per session from its FIRMWARE_VERSION fingerprint: pre-2010-01-25 version strings (kazzo16 0.1.0-0.1.2) and the self-identifying m2-idle-high branch (kazzo16 0.1.3+m2) idle high and enable the mappers; 0.1.3 and later, unknown strings, blank (all-0xFF) version sections, short reads, and transfer errors all fail safe to gated, with reason strings naming the capable builds. A blank version section is deliberately not treated as an identity even though the historical clipped distribution it matches is capable in practice. The version read is the benign FIRMWARE_VERSION request only; the device layer hard-rejects the adjacent self-flash opcodes in both transfer directions. --- src/lib/drivers/kazzo/firmware-m2.test.ts | 103 +++++++++++ src/lib/drivers/kazzo/firmware-m2.ts | 82 +++++++++ src/lib/drivers/kazzo/kazzo-device.test.ts | 23 ++- src/lib/drivers/kazzo/kazzo-device.ts | 13 ++ src/lib/drivers/kazzo/kazzo-driver.test.ts | 176 +++++++++++++++++-- src/lib/drivers/kazzo/kazzo-driver.ts | 75 ++++++-- src/lib/drivers/kazzo/kazzo-nes-bus.ts | 3 +- src/lib/drivers/kazzo/unsupported-mappers.ts | 89 +++++++--- 8 files changed, 508 insertions(+), 56 deletions(-) create mode 100644 src/lib/drivers/kazzo/firmware-m2.test.ts create mode 100644 src/lib/drivers/kazzo/firmware-m2.ts 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..929376f --- /dev/null +++ b/src/lib/drivers/kazzo/firmware-m2.test.ts @@ -0,0 +1,103 @@ +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 the two known-capable fingerprints — the + * clipped build's erased version section (32 × 0xFF) and a NUL-terminated + * pre-flip version string — may open the gate; every other shape, however + * plausible, classifies as idle-low. + */ + +/** 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 index 60d43c0..05f74ef 100644 --- a/src/lib/drivers/kazzo/kazzo-device.test.ts +++ b/src/lib/drivers/kazzo/kazzo-device.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from "vitest"; import { KazzoDevice } from "./kazzo-device"; -import { REQUEST, WRITE_XOR_MASK, VRAM_VERTICAL } from "./kazzo-opcodes"; +import { + REQUEST, + WRITE_XOR_MASK, + VRAM_VERTICAL, + VERSION_STRING_SIZE, +} from "./kazzo-opcodes"; interface OutCall { request: number; @@ -148,6 +153,22 @@ describe("KazzoDevice domain helpers", () => { 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); diff --git a/src/lib/drivers/kazzo/kazzo-device.ts b/src/lib/drivers/kazzo/kazzo-device.ts index 2801c47..f691dff 100644 --- a/src/lib/drivers/kazzo/kazzo-device.ts +++ b/src/lib/drivers/kazzo/kazzo-device.ts @@ -27,6 +27,7 @@ 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; @@ -41,6 +42,16 @@ export class KazzoDevice { 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({ @@ -78,6 +89,7 @@ export class KazzoDevice { } this.device = null; this._firmwareVersion = null; + this._firmwareVersionBytes = null; } onDisconnected(handler: () => void): void { @@ -299,6 +311,7 @@ export class KazzoDevice { 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( diff --git a/src/lib/drivers/kazzo/kazzo-driver.test.ts b/src/lib/drivers/kazzo/kazzo-driver.test.ts index 5a14d65..12e4058 100644 --- a/src/lib/drivers/kazzo/kazzo-driver.test.ts +++ b/src/lib/drivers/kazzo/kazzo-driver.test.ts @@ -3,14 +3,15 @@ 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 } from "./kazzo-opcodes"; +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 - * unsupported mappers are pre-flight-rejected before any cart traffic, and - * detect/init/save take their expected shapes. + * 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 { @@ -26,19 +27,38 @@ interface FakeOptions { /** Bytes a ppuRead at (addr,len) yields. Default: zero-fill. */ ppuRead?: (addr: number, len: number) => Uint8Array; vram?: number; - firmware?: string; + /** + * 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", - firmwareVersion: firmware, + get firmwareVersionBytes() { + return fwBytes; + }, async fetchFirmwareVersion() { calls.push({ m: "firmware" }); - return 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" }); @@ -93,23 +113,151 @@ describe("KazzoDriver capabilities", () => { expect(cap[0].systemId).toBe("nes"); expect(cap[0].operations).toContain("dump_rom"); expect(cap[0].autoDetect).toBe(true); - // 268/470 are TEMPORARILY ENABLED for hardware testing (see - // ./unsupported-mappers) — nothing greyed out for now. - expect(cap[0].unsupportedMappers).toEqual([]); + // 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: "kazzo 1.5" }); + const { device, calls } = fakeKazzo({ firmware: "kazzo16 0.1.3" }); const info = await makeDriver(device).initialize(); - expect(info.firmwareVersion).toBe("kazzo 1.5"); + 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 } = 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). + const data = await driver.readROM( + romConfig({ mapper: 268, prgSizeBytes: 16384, chrSizeBytes: 0 }), + ); + expect(data).toHaveLength(16384); + }); + + 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 }); @@ -236,9 +384,9 @@ describe("KazzoDriver.readROM — MMC3 (banked)", () => { }); describe("KazzoDriver.readROM — unsupported mappers", () => { - // 268 (CoolBoy) and 470 are TEMPORARILY ENABLED for hardware testing (see - // ./unsupported-mappers), so they're no longer pre-flight-rejected. - // resolveMapper still rejects ids that aren't in the shared catalog at all: + // 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( diff --git a/src/lib/drivers/kazzo/kazzo-driver.ts b/src/lib/drivers/kazzo/kazzo-driver.ts index a5bd305..55a75e8 100644 --- a/src/lib/drivers/kazzo/kazzo-driver.ts +++ b/src/lib/drivers/kazzo/kazzo-driver.ts @@ -7,9 +7,10 @@ * read/write requests. Runs on kazzo hardware or on an AVR-based INL Retro * board (v1.x, pre-2018) reflashed with the Kazzo firmware. * - * NOT yet hardware-validated: the device/protocol layer is reimplemented - * from the documented Kazzo protocol and the dump path is exercised only by - * fakes; real-cart testing is pending. + * 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 { @@ -27,7 +28,16 @@ import type { KazzoDevice } from "./kazzo-device"; import type { KazzoTransport } from "./kazzo-transport"; import { KazzoNesBus } from "./kazzo-nes-bus"; import { detectKazzoMirroring } from "./detect-mirroring"; -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; 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. */ @@ -41,12 +51,26 @@ export class KazzoDriver implements DeviceDriver { systemId: "nes", operations: ["dump_rom"], autoDetect: true, - // Greys these out in the config UI; readROM pre-flight-rejects them - // too. See ./unsupported-mappers for the (family-inference) reasoning. - unsupportedMappers: [...UNSUPPORTED_MAPPERS.keys()], + // 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 @@ -78,11 +102,37 @@ export class KazzoDriver implements DeviceDriver { async initialize(): Promise { this.log("Initializing Kazzo..."); - await this.kazzoDevice.fetchFirmwareVersion(); + + // 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: this.kazzoDevice.firmwareVersion, + firmwareVersion: firmware.label, deviceName: this.kazzoDevice.productName, capabilities: this.capabilities, }; @@ -121,12 +171,11 @@ export class KazzoDriver implements DeviceDriver { 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 Kazzo: ` + - `${unsupportedReason}. The cart itself is fine — use a dumper this ` + - "board accepts.", + `Mapper ${mapperId} (${mapper.name}) can't be dumped with this ` + + `Kazzo firmware: ${unsupportedReason}. The cart itself is fine.`, ); } return mapper; diff --git a/src/lib/drivers/kazzo/kazzo-nes-bus.ts b/src/lib/drivers/kazzo/kazzo-nes-bus.ts index 92c8884..5a88613 100644 --- a/src/lib/drivers/kazzo/kazzo-nes-bus.ts +++ b/src/lib/drivers/kazzo/kazzo-nes-bus.ts @@ -17,7 +17,8 @@ * - `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; 470 is pre-flight-rejected anyway. + * 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"; diff --git a/src/lib/drivers/kazzo/unsupported-mappers.ts b/src/lib/drivers/kazzo/unsupported-mappers.ts index 8b26ca5..8607dc6 100644 --- a/src/lib/drivers/kazzo/unsupported-mappers.ts +++ b/src/lib/drivers/kazzo/unsupported-mappers.ts @@ -1,33 +1,68 @@ /** - * Mappers the Kazzo dumper cannot drive, even though they exist in the - * shared NES catalog. + * Catalog mappers gated on a Kazzo firmware feature: M2 idling HIGH. * - * Mappers 268 (CoolBoy / Mindkids) and 470 (INX_007T_V01) reimplement their - * mapper in a CPLD whose reset detector wants sustained M2 clocking before - * it will latch a register write. Kazzo is the same AVR + V-USB lineage as - * the INL Retro — it idles M2 between transfers and emits a single pulse per - * write — and the INL was *hardware-classified* against both boards as - * unable to land a single register write (see - * drivers/inl/unsupported-mappers.ts). Kazzo has NOT been separately tested - * on these carts, so this rejection is only an inference: a dump would likely - * return a boot-bank mirror, but an instrumented attempt could overturn it. + * 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. * - * >>> INFERENCE OVERTURNED ON HARDWARE (2026-06-08) <<< - * The Kazzo dumped a 2 MB mapper-268 (Mindkids submapper 1) cart BYTE-PERFECT, - * matching the No-Intro reference (CRC32 E7822236) — so the INL classification - * does NOT transfer: the Kazzo's write cycle drives that CPLD where the INL's - * didn't. 268 is supported here. 470 (INX_007T_V01) is now ALSO - * hardware-validated on the Kazzo (2026-06-09) — a 1 MB cart dumped - * byte-perfect against the No-Intro reference (CRC32 55AB5439). A first - * attempt mismatched only because data line D7 floated high on a dirty edge - * connector (the dump was exactly reference|0x80, low 7 bits pristine); a - * clean re-seat verified it — a contact fault, not a mapper limit. Re-add an - * id below only if a cart is actually shown un-dumpable on Kazzo. + * 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 map feeds `capability.unsupportedMappers` (greys the config UI) and - * `KazzoDriver.resolveMapper` (pre-flight reject). Mappers stay in the shared - * catalog regardless, for devices whose bus drives the CPLD. + * 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. */ -export const UNSUPPORTED_MAPPERS: ReadonlyMap = new Map([ - // (empty — 268 hardware-validated on Kazzo; 470 enabled pending its own test) + +/** 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; +} From ecffc5f58dbb5dd051c1116d5fef80b1dae5e6eb Mon Sep 17 00:00:00 2001 From: Pat Hawks Date: Sun, 14 Jun 2026 08:21:09 -0500 Subject: [PATCH 06/11] Address Copilot review: symmetric disconnect teardown + test doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A physical unplug skips transport.disconnect() (the hook sees connected === false once handleDisconnect nulls the device), so the device-initiated path must tear down the same state the explicit disconnect() does. Both device wrappers now drop the global USB "disconnect" listener in handleDisconnect (and Kazzo also clears its firmware-version caches so a stale classification can't outlive the device). Also correct the firmware-m2 test header: the all-0xFF clipped build is gated (capable in practice, but a blank version section is not an identity), not a fingerprint that opens the gate — matching the implementation and the first test case. --- src/lib/drivers/inl/inl-device.ts | 12 ++++++++---- src/lib/drivers/kazzo/firmware-m2.test.ts | 9 +++++---- src/lib/drivers/kazzo/kazzo-device.ts | 16 ++++++++++++---- 3 files changed, 25 insertions(+), 12 deletions(-) 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/kazzo/firmware-m2.test.ts b/src/lib/drivers/kazzo/firmware-m2.test.ts index 929376f..af0163a 100644 --- a/src/lib/drivers/kazzo/firmware-m2.test.ts +++ b/src/lib/drivers/kazzo/firmware-m2.test.ts @@ -4,10 +4,11 @@ import { VERSION_STRING_SIZE } from "./kazzo-opcodes"; /** * The firmware-era classifier behind the M2-idle gate. Fail-safe is the - * invariant under test: only the two known-capable fingerprints — the - * clipped build's erased version section (32 × 0xFF) and a NUL-terminated - * pre-flip version string — may open the gate; every other shape, however - * plausible, classifies as idle-low. + * 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. */ diff --git a/src/lib/drivers/kazzo/kazzo-device.ts b/src/lib/drivers/kazzo/kazzo-device.ts index f691dff..8c68915 100644 --- a/src/lib/drivers/kazzo/kazzo-device.ts +++ b/src/lib/drivers/kazzo/kazzo-device.ts @@ -97,10 +97,18 @@ export class KazzoDevice { } 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 — 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 ────────────────────────────────────────────── From 10f2b124cac883bc777920aa1d7f1c6f8f9ed36b Mon Sep 17 00:00:00 2001 From: Pat Hawks Date: Sun, 14 Jun 2026 08:21:28 -0500 Subject: [PATCH 07/11] Kazzo: harden the read-only guard; fail loud on bad reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename FLASH_WRITE_REQUESTS to REFUSED_REQUESTS and add the two flash-probe opcodes (FLASH_DEVICE drives JEDEC command cycles onto the cart bus; FLASH_STATUS is its companion poll) — a pure read-only dumper issues none of these. The guard test now iterates the live set and pins its exact membership, so coverage can't silently drift behind it. readChunked rejects a region past the bus address ceiling (64 KiB CPU / 8 KiB PPU) instead of letting the 16-bit wValue alias it to low addresses and reassemble a wrong-but-plausible dump. controlIn throws on a short read rather than fabricating empty data — a failed VRAM probe was being reported as 'horizontal' instead of surfacing the error. The echo() doc no longer claims it is a connectivity handshake (it is an unused opcode primitive). --- src/lib/drivers/kazzo/kazzo-device.test.ts | 42 ++++++++++--- src/lib/drivers/kazzo/kazzo-device.ts | 73 +++++++++++++++++----- src/lib/drivers/kazzo/kazzo-opcodes.ts | 13 ++-- 3 files changed, 97 insertions(+), 31 deletions(-) diff --git a/src/lib/drivers/kazzo/kazzo-device.test.ts b/src/lib/drivers/kazzo/kazzo-device.test.ts index 05f74ef..9e974f5 100644 --- a/src/lib/drivers/kazzo/kazzo-device.test.ts +++ b/src/lib/drivers/kazzo/kazzo-device.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { KazzoDevice } from "./kazzo-device"; import { REQUEST, + REFUSED_REQUESTS, WRITE_XOR_MASK, VRAM_VERTICAL, VERSION_STRING_SIZE, @@ -126,16 +127,20 @@ describe("KazzoDevice reads", () => { await expect(device.cpuRead(0x8000, 0x100)).rejects.toThrow(/short read/i); }); - it("aborts between pages when the signal fires", async () => { + it("aborts between pages when the signal fires, stopping early", async () => { const controller = new AbortController(); let pages = 0; - const { device } = makeDevice((c) => { + 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(); + ).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); }); }); @@ -182,14 +187,11 @@ describe("KazzoDevice domain helpers", () => { }); describe("KazzoDevice flash-write guard (read-only dumper)", () => { - it("refuses flash/firmware-write requests before any transfer", async () => { + // 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 [ - REQUEST.CPU_WRITE_FLASH, - REQUEST.FLASH_PROGRAM, - REQUEST.FLASH_ERASE, - REQUEST.FIRMWARE_PROGRAM, - ]) { + for (const req of REFUSED_REQUESTS) { await expect( device.controlOut(req, 0, 0, new Uint8Array([0])), ).rejects.toThrow(/never programs/i); @@ -201,4 +203,24 @@ describe("KazzoDevice flash-write guard (read-only dumper)", () => { 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 index 8c68915..61c5b75 100644 --- a/src/lib/drivers/kazzo/kazzo-device.ts +++ b/src/lib/drivers/kazzo/kazzo-device.ts @@ -12,7 +12,7 @@ import { KAZZO_DEVICE_FILTER, - FLASH_WRITE_REQUESTS, + REFUSED_REQUESTS, READ_PACKET_SIZE, REQUEST, INDEX, @@ -20,6 +20,11 @@ import { 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; @@ -115,13 +120,14 @@ export class KazzoDevice { /** * Hardware safety: nabu is a read-only dumper and must never program a - * cartridge's flash, firmware, or disk. Refuse every write/erase request - * the firmware exposes (see FLASH_WRITE_REQUESTS) before it goes out. + * 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 (FLASH_WRITE_REQUESTS.has(request)) { + if (REFUSED_REQUESTS.has(request)) { throw new Error( - `Refusing Kazzo flash/firmware-write request ${request}: nabu only ` + + `Refusing Kazzo flash/firmware request ${request}: nabu only ` + `reads cartridges and never programs them.`, ); } @@ -154,11 +160,20 @@ export class KazzoDevice { ); } - if (!result.data) return new Uint8Array(0); + // 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, + result.data!.buffer, + result.data!.byteOffset, + result.data!.byteLength, ); } @@ -210,9 +225,19 @@ export class KazzoDevice { 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; @@ -220,12 +245,8 @@ export class KazzoDevice { 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); - if (chunk.length !== n) { - throw new Error( - `Kazzo short read (got ${chunk.length}, expected ${n} at $${addr.toString(16)})`, - ); - } result.set(chunk, offset); offset += n; addr += n; @@ -237,7 +258,11 @@ export class KazzoDevice { // ─── Domain methods ───────────────────────────────────────────────────── - /** Connectivity handshake. Returns `[value_lo, value_hi, index_lo, index_hi]`. */ + /** + * 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); } @@ -262,7 +287,14 @@ export class KazzoDevice { onProgress?: KazzoProgressCb, signal?: AbortSignal, ): Promise { - return this.readChunked(REQUEST.CPU_READ, address, length, onProgress, signal); + return this.readChunked( + REQUEST.CPU_READ, + address, + length, + CPU_ADDR_CEILING, + onProgress, + signal, + ); } /** Read CHR (PPU bus) starting at `address` for `length` bytes. */ @@ -272,7 +304,14 @@ export class KazzoDevice { onProgress?: KazzoProgressCb, signal?: AbortSignal, ): Promise { - return this.readChunked(REQUEST.PPU_READ, address, length, onProgress, signal); + return this.readChunked( + REQUEST.PPU_READ, + address, + length, + PPU_ADDR_CEILING, + onProgress, + signal, + ); } /** diff --git a/src/lib/drivers/kazzo/kazzo-opcodes.ts b/src/lib/drivers/kazzo/kazzo-opcodes.ts index 0191dd7..64f1f92 100644 --- a/src/lib/drivers/kazzo/kazzo-opcodes.ts +++ b/src/lib/drivers/kazzo/kazzo-opcodes.ts @@ -54,15 +54,20 @@ export const REQUEST = { } as const; /** - * Request numbers that PROGRAM/ERASE a cartridge's flash or firmware. - * nabu is a read-only dumper and never issues these; KazzoDevice refuses - * them, mirroring the INL driver's flash-write guard. + * 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 FLASH_WRITE_REQUESTS: ReadonlySet = new Set([ +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, From 5015e15f769fd60ca1bc18b0ab6fb67397a0417d Mon Sep 17 00:00:00 2001 From: Pat Hawks Date: Sun, 14 Jun 2026 08:21:36 -0500 Subject: [PATCH 08/11] INL: gate readSave like readROM; honor abort on bus writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readSave resolved the mapper with a bare getNesMapper and never consulted the unsupported-mapper set, so the M2-idle / always-unsupported gate that readROM enforces was skipped for save dumps. Extract resolveMapper (a pure pre-flight check, no device I/O) and call it from both paths so a gated mapper is rejected up front on either dump, matching the Kazzo driver. InlNesBus.writeCpu and writeSerialRegister now check the abort signal, so an abort lands promptly mid-write-run instead of only at the next read — matching KazzoNesBus's per-operation abort contract. --- src/lib/drivers/inl/inl-driver.test.ts | 20 ++++++++++++++++++++ src/lib/drivers/inl/inl-driver.ts | 26 ++++++++++++++++++-------- src/lib/drivers/inl/inl-nes-bus.ts | 2 ++ 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/lib/drivers/inl/inl-driver.test.ts b/src/lib/drivers/inl/inl-driver.test.ts index 0871a54..57e5b47 100644 --- a/src/lib/drivers/inl/inl-driver.test.ts +++ b/src/lib/drivers/inl/inl-driver.test.ts @@ -138,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); diff --git a/src/lib/drivers/inl/inl-driver.ts b/src/lib/drivers/inl/inl-driver.ts index ca85077..49a933f 100644 --- a/src/lib/drivers/inl/inl-driver.ts +++ b/src/lib/drivers/inl/inl-driver.ts @@ -154,12 +154,13 @@ 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 = this.unsupportedMappers.get(mapperId); @@ -169,6 +170,16 @@ export class INLDriver implements DeviceDriver { `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 @@ -275,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); } From be24234e9ea1686f4f933958d7ea5ed7753e4d2e Mon Sep 17 00:00:00 2001 From: Pat Hawks Date: Sun, 14 Jun 2026 08:21:43 -0500 Subject: [PATCH 09/11] Connection: verify the chosen USB device; guard mid-init unplug The interactive chooser can only filter by VID/PID, and the Kazzo and INL Retro share 16c0:05dc, so a user can pick the sibling unit and run one driver against the other's firmware (an opaque 0xff on the first opcode). After the chooser resolves, verify the opened device's product string matches the selected def (via the extracted matchesUsbProduct) and fail fast with an actionable message instead. A device-initiated disconnect can also fire mid-initialize, before any driver is published: handleDisconnect now bails when there's no published driver (no phantom 'Disconnected' / premature per-session clear), and connectDevice won't publish a driver over a transport that closed during initialize(). --- src/hooks/use-connection.ts | 51 ++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/src/hooks/use-connection.ts b/src/hooks/use-connection.ts index 9c0b772..cd2d15a 100644 --- a/src/hooks/use-connection.ts +++ b/src/hooks/use-connection.ts @@ -21,15 +21,29 @@ export function webusbMatches( defs: DeviceDef[], ): boolean { if (d.vendorId !== dev.vendorId || d.productId !== dev.productId) return false; - const product = d.productName ?? ""; - if (dev.usbProduct) return product.includes(dev.usbProduct); + 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 && - product.includes(o.usbProduct), + productName.includes(o.usbProduct), ); } @@ -241,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) { @@ -332,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", () => { @@ -345,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; } From c47ad6a14e6ef9944c1f8544e299afd6b6c6986e Mon Sep 17 00:00:00 2001 From: Pat Hawks Date: Sun, 14 Jun 2026 08:21:53 -0500 Subject: [PATCH 10/11] Kazzo driver tests: assert banking, abort, and save branches The mapper-268 dump test asserted only output length; it now also checks the walk drove the outer registers ($5000 bank-select writes) and read each bank under consensus (>=2 reads of the same window), not a single trusting read. Add a mid-dump abort test proving the signal threads driver -> KazzoNesBus -> device (the CHR read never issues after the PRG read aborts), and exercise the two readSave branches the default-path test skipped: dumpSave (MMC3) and enableSram (MMC1). --- src/lib/drivers/kazzo/kazzo-driver.test.ts | 84 +++++++++++++++++++++- 1 file changed, 82 insertions(+), 2 deletions(-) diff --git a/src/lib/drivers/kazzo/kazzo-driver.test.ts b/src/lib/drivers/kazzo/kazzo-driver.test.ts index 12e4058..97590fd 100644 --- a/src/lib/drivers/kazzo/kazzo-driver.test.ts +++ b/src/lib/drivers/kazzo/kazzo-driver.test.ts @@ -15,7 +15,14 @@ import { M2_IDLE_GATED_MAPPERS } from "./unsupported-mappers"; */ interface Call { - m: "phi2Init" | "cpuWrite" | "cpuRead" | "ppuRead" | "vram" | "firmware"; + m: + | "phi2Init" + | "cpuWrite" + | "cpuWriteBytes" + | "cpuRead" + | "ppuRead" + | "vram" + | "firmware"; addr?: number; value?: number; length?: number; @@ -66,6 +73,9 @@ function fakeKazzo(opts: FakeOptions = {}) { 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, @@ -144,7 +154,9 @@ describe("KazzoDriver M2-idle firmware gate", () => { 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 } = fakeKazzo({ firmware: "kazzo16 0.1.3+m2 / Jun 10 2026" }); + const { device, calls } = fakeKazzo({ + firmware: "kazzo16 0.1.3+m2 / Jun 10 2026", + }); const driver = makeDriver(device); await driver.initialize(); @@ -153,10 +165,20 @@ describe("KazzoDriver M2-idle firmware gate", () => { 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 () => { @@ -408,6 +430,27 @@ describe("KazzoDriver.readROM — abort", () => { ).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", () => { @@ -427,6 +470,43 @@ describe("KazzoDriver.readSave", () => { 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( From a8adc07c0611f82885202ae7ecafbbd53891b577 Mon Sep 17 00:00:00 2001 From: Pat Hawks Date: Sun, 14 Jun 2026 08:22:00 -0500 Subject: [PATCH 11/11] Catalog: note both drivers' M2-idle gates in the gated-mapper comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 268/470 comments described only the INL runtime M2-idle probe. Both drivers gate these mappers on M2-low firmware — INL by feature-detecting the pin level at init, Kazzo by fingerprinting the firmware version — so the comments now point at M2_IDLE_GATED_MAPPERS in each driver's unsupported-mappers. --- src/lib/systems/nes/mappers/coolboy.ts | 7 ++++--- src/lib/systems/nes/mappers/index.ts | 9 +++++---- src/lib/systems/nes/mappers/inx007t.ts | 10 +++++----- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/lib/systems/nes/mappers/coolboy.ts b/src/lib/systems/nes/mappers/coolboy.ts index a00b538..f7dc6da 100644 --- a/src/lib/systems/nes/mappers/coolboy.ts +++ b/src/lib/systems/nes/mappers/coolboy.ts @@ -64,9 +64,10 @@ * 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; the INL driver feature-detects the connected - * firmware's M2 idle level and pre-flight-rejects this mapper on stock - * builds — see M2_IDLE_GATED_MAPPERS in drivers/inl/unsupported-mappers + * 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: diff --git a/src/lib/systems/nes/mappers/index.ts b/src/lib/systems/nes/mappers/index.ts index 50090e4..af748b4 100644 --- a/src/lib/systems/nes/mappers/index.ts +++ b/src/lib/systems/nes/mappers/index.ts @@ -52,10 +52,11 @@ 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 board's CPLD needs M2 idling high; the INL driver feature-detects - // the firmware's M2 idle level and pre-flight-rejects this id on stock - // (M2-low) builds — see M2_IDLE_GATED_MAPPERS in - // drivers/inl/unsupported-mappers for the full 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 diff --git a/src/lib/systems/nes/mappers/inx007t.ts b/src/lib/systems/nes/mappers/inx007t.ts index 87b52ac..ce37ba8 100644 --- a/src/lib/systems/nes/mappers/inx007t.ts +++ b/src/lib/systems/nes/mappers/inx007t.ts @@ -38,11 +38,11 @@ * * 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). On the INL Retro - * this board family needs M2 idling high; the driver feature-detects the - * firmware's M2 idle level and pre-flight-rejects this id on stock - * (M2-low) builds — see M2_IDLE_GATED_MAPPERS in - * drivers/inl/unsupported-mappers. + * 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