From fd312e24bc1b3d3427b0cc23dde5fecebd0e1b82 Mon Sep 17 00:00:00 2001 From: Pat Hawks Date: Sat, 13 Jun 2026 15:41:35 -0500 Subject: [PATCH 1/2] =?UTF-8?q?INL:=20refuse=20every=20flash-program=20opc?= =?UTF-8?q?ode=20=E2=80=94=20nabu=20only=20ever=20reads=20carts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nabu is a read-only dumper: it drives mapper registers to select banks but never programs a cartridge's flash. INLDevice.nes now rejects the firmware's whole NES flash-PROGRAM family (NES_FLASH_WRITE_OPCODES: the 0x07-0x14 per-mapper PRG/CHR byte-program block + MMC3S at 0x26) before any transfer goes out, at any address — so a stray flash command can never reach a cart that should only be read. Prompted by an earlier one-off experiment that aimed MMC3_PRG_FLASH_WR at $5xxx mapper registers as a burst-write primitive. That couldn't actually program flash (the data write lands with /ROMSEL deasserted, and the board rejected the writes anyway), but the opcode has no place in a dumper at all — so the guard is the whole family, not just that address misuse. Ordinary register/serial writes (NES_CPU_WR, NES_MMC1_WR) are untouched. --- src/lib/drivers/inl/inl-device.test.ts | 39 ++++++++++++++++++++++++++ src/lib/drivers/inl/inl-device.ts | 13 +++++++++ src/lib/drivers/inl/inl-opcodes.ts | 34 +++++++++++++++++----- 3 files changed, 79 insertions(+), 7 deletions(-) diff --git a/src/lib/drivers/inl/inl-device.test.ts b/src/lib/drivers/inl/inl-device.test.ts index 063e85d..22a8b24 100644 --- a/src/lib/drivers/inl/inl-device.test.ts +++ b/src/lib/drivers/inl/inl-device.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { INLDevice } from "./inl-device"; +import { NES } from "./inl-opcodes"; /** * Build an INLDevice whose underlying USBDevice's controlTransferIn returns a @@ -48,3 +49,41 @@ describe("INLDevice.payloadIn desync guard", () => { await expect(inl.payloadIn(512)).rejects.toThrow(/254/); }); }); + +describe("INLDevice flash-write guard (read-only dumper)", () => { + it("refuses MMC3_PRG_FLASH_WR at any address — the misused opcode", async () => { + // No device connected: the guard must fire before any transfer, so a + // flash command can never reach the cart — at a $5xxx register address + // (the one-off experiment's misuse) AND at a real $8000 flash address. + const inl = new INLDevice(); + await expect(inl.nes(NES.MMC3_PRG_FLASH_WR, 0x5000, 0x40)).rejects.toThrow( + /flash-write opcode/i, + ); + await expect(inl.nes(NES.MMC3_PRG_FLASH_WR, 0x8000, 0x40)).rejects.toThrow( + /flash-write opcode/i, + ); + }); + + it("refuses the whole flash-program family, not just MMC3", async () => { + // Spot-check the contiguous block (CHR + other-mapper PRG writes) and + // the MMC3S stray at 0x26. + const inl = new INLDevice(); + for (const op of [0x08, 0x0a, 0x0e, 0x14, 0x26]) { + await expect(inl.nes(op, 0x8000, 0x55)).rejects.toThrow( + /never programs/i, + ); + } + }); + + it("does not interfere with the writes dumping needs", async () => { + // Register/serial writes used to select banks must still pass through to + // the transport (here they fail only because no device is connected). + const inl = new INLDevice(); + await expect(inl.nes(NES.NES_CPU_WR, 0x5000, 0x40)).rejects.toThrow( + /not connected/i, + ); + await expect(inl.nes(NES.NES_MMC1_WR, 0x8000, 0x01)).rejects.toThrow( + /not connected/i, + ); + }); +}); diff --git a/src/lib/drivers/inl/inl-device.ts b/src/lib/drivers/inl/inl-device.ts index 4292b12..ee96f08 100644 --- a/src/lib/drivers/inl/inl-device.ts +++ b/src/lib/drivers/inl/inl-device.ts @@ -12,6 +12,7 @@ import { DICT, + NES_FLASH_WRITE_OPCODES, BUFFER, INL_DEVICE_FILTER, RETURN, @@ -116,6 +117,18 @@ export class INLDevice { * GET_BANK_TABLE (0x86) which has RL=4. */ async nes(opcode: number, operand = 0, misc = 0): Promise { + // Hardware safety: nabu is a read-only dumper and must never program a + // cartridge's flash. Refuse every flash-PROGRAM opcode the firmware + // exposes (see NES_FLASH_WRITE_OPCODES) before any transfer goes out, so + // a stray flash command can never reach a cart that should only be read. + if (NES_FLASH_WRITE_OPCODES.has(opcode)) { + throw new Error( + `Refusing NES flash-write opcode 0x${opcode + .toString(16) + .padStart(2, "0")}: nabu only reads cartridges and never programs ` + + `their flash.`, + ); + } const returnLength = opcode >= 0x80 ? (opcode === 0x86 ? 4 : 3) : 1; return this.controlIn(DICT.NES, opcode, operand, misc, returnLength); } diff --git a/src/lib/drivers/inl/inl-opcodes.ts b/src/lib/drivers/inl/inl-opcodes.ts index b21b64e..454f22b 100644 --- a/src/lib/drivers/inl/inl-opcodes.ts +++ b/src/lib/drivers/inl/inl-opcodes.ts @@ -53,13 +53,11 @@ export const NES = { NES_DUALPORT_WR: 0x05, // Flash-program a byte on an MMC3 board: three JEDEC unlock writes // ($D555/$AAAA/$D555), then the (operand, misc) write, then $8000<-2 and - // a stability poll-read — ALL inside one USB transaction, each a full - // nes_cpu_wr M2 cycle microseconds apart. Caution: the tail poll re-reads - // the WRITTEN address until two consecutive reads agree, so a target - // whose reads flicker (e.g. $5xxx on the mapper-268 board) spins the - // firmware until a physical replug — which is exactly why it is NOT used - // as an M2-burst write primitive (see UNSUPPORTED_MAPPERS in - // ./unsupported-mappers). + // a stability poll-read, all inside one USB transaction. This is one of + // the firmware's flash-PROGRAM opcodes — nabu is a read-only dumper and + // never programs a cart, so `INLDevice.nes` hard-rejects the whole family + // (see NES_FLASH_WRITE_OPCODES below). Kept declared because it documents + // the device surface and is the one a one-off experiment once misused. MMC3_PRG_FLASH_WR: 0x07, SET_CUR_BANK: 0x20, SET_BANK_TABLE: 0x21, @@ -76,6 +74,28 @@ export const NES = { GET_NUM_PRG_BANKS: 0x87, } as const; +/** + * The INL firmware's NES-dictionary flash-PROGRAM opcodes + * (`shared_dict_nes.h`): per-mapper PRG/CHR flash byte-program commands, + * 0x07-0x14 contiguous (MMC3 / NROM / CNROM / CDREAM / UNROM / MMC1 / MMC4 / + * MAP30 / GTROM), plus MMC3S at 0x26. Each issues a JEDEC unlock+program + * sequence that writes the cart's flash chip. + * + * nabu is a read-only DUMPER — it drives mapper registers to select banks + * but never programs a cartridge's non-volatile storage — so `INLDevice.nes` + * refuses every opcode in this set, at any address. Listed by value rather + * than minting NES.* constants nabu otherwise never uses; only + * MMC3_PRG_FLASH_WR is named above (it documents the surface and was the one + * a one-off experiment misused). (The lower-level write *primitives* the + * firmware also uses for flashing — DISCRETE_EXP0_PRGROM_WR 0x00, M2_LOW_WR + * 0x22, FLASH_3V_WR/M2_HIGH_WR 0x25 — are dual-purpose bus-write variants, so + * they are deliberately NOT blanket-blocked here.) + */ +export const NES_FLASH_WRITE_OPCODES: ReadonlySet = new Set([ + 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, + 0x14, 0x26, +]); + // ─── Buffer Dictionary ────────────────────────────────────────────────────── export const BUFFER = { From ee27e5bba0e4b446e879f7e1b369f7615a1bc605 Mon Sep 17 00:00:00 2001 From: Pat Hawks Date: Sat, 13 Jun 2026 18:44:35 -0500 Subject: [PATCH 2/2] INL: normalize the NES opcode to 8 bits before the flash-write guard controlIn() transmits only the low 8 bits (opcode & 0xff), so an opcode outside 0-255 could slip a flash-program byte past the guard: nes(0x107) checks 0x107 (not in NES_FLASH_WRITE_OPCODES) yet would transmit 0x07 (MMC3_PRG_FLASH_WR), and returnLength was sized from the unmasked value. Mask opcode up front so the guard and returnLength both reflect the byte actually sent. No current caller passes an out-of-range opcode; this keeps the safety backstop airtight regardless, and adds a test for the disguised 0x107 case. Per PR review. --- src/lib/drivers/inl/inl-device.test.ts | 10 ++++++++++ src/lib/drivers/inl/inl-device.ts | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/src/lib/drivers/inl/inl-device.test.ts b/src/lib/drivers/inl/inl-device.test.ts index 22a8b24..7d3679e 100644 --- a/src/lib/drivers/inl/inl-device.test.ts +++ b/src/lib/drivers/inl/inl-device.test.ts @@ -75,6 +75,16 @@ describe("INLDevice flash-write guard (read-only dumper)", () => { } }); + it("refuses a flash opcode disguised in the high bits of an out-of-range value", async () => { + // controlIn() transmits only opcode & 0xff, so a caller passing 0x107 + // would send 0x07 (MMC3_PRG_FLASH_WR). The guard normalizes first, so the + // disguised flash write is still refused — never sent as 0x07. + const inl = new INLDevice(); + await expect(inl.nes(0x100 | NES.MMC3_PRG_FLASH_WR, 0x8000, 0x40)).rejects.toThrow( + /flash-write opcode/i, + ); + }); + it("does not interfere with the writes dumping needs", async () => { // Register/serial writes used to select banks must still pass through to // the transport (here they fail only because no device is connected). diff --git a/src/lib/drivers/inl/inl-device.ts b/src/lib/drivers/inl/inl-device.ts index ee96f08..bab5a63 100644 --- a/src/lib/drivers/inl/inl-device.ts +++ b/src/lib/drivers/inl/inl-device.ts @@ -117,6 +117,11 @@ export class INLDevice { * GET_BANK_TABLE (0x86) which has RL=4. */ async nes(opcode: number, operand = 0, misc = 0): Promise { + // controlIn() transmits only the low 8 bits (opcode & 0xff), so normalize + // up front: the flash-write guard and returnLength must both reflect the + // byte actually sent. Without this, nes(0x107) would slip 0x07 (a flash + // write) past the guard and mis-size its reply. + opcode &= 0xff; // Hardware safety: nabu is a read-only dumper and must never program a // cartridge's flash. Refuse every flash-PROGRAM opcode the firmware // exposes (see NES_FLASH_WRITE_OPCODES) before any transfer goes out, so