diff --git a/src/hooks/use-connection.ts b/src/hooks/use-connection.ts index a0997e2..1fe10a3 100644 --- a/src/hooks/use-connection.ts +++ b/src/hooks/use-connection.ts @@ -107,6 +107,9 @@ const TRANSPORT_LABEL: Record = { webusb: "USB device", }; +/** localStorage key for the last successfully-connected device id. */ +const LAST_DEVICE_KEY = "nabu:last-device"; + type LogFn = (msg: string, level?: "info" | "warn" | "error") => void; interface UseConnectionOptions { @@ -152,17 +155,30 @@ export function useConnection({ onDisconnectedRef.current = onDisconnected; }, [onDisconnected]); - // Close transport on page unload/refresh + // Close transport on page unload/refresh. Prefer the synchronous + // closeNow teardown: the awaits inside disconnect() may never resume in + // a dying document, leaving the port held and (on serial) hanging the + // reload. pagehide is registered too — it fires in cases beforeunload + // doesn't (e.g. bfcache navigations), and the teardown is idempotent. useEffect(() => { const cleanup = () => { try { - driverRef.current?.transport?.disconnect(); + const transport = driverRef.current?.transport; + if (transport?.closeNow) transport.closeNow(); + // Fallback for transports without closeNow: disconnect() is async, + // and the surrounding try/catch can't catch a rejected promise, so + // swallow it here to avoid an unhandled rejection during unload. + else transport?.disconnect()?.catch(() => {}); } catch { // Best-effort — page is unloading } }; window.addEventListener("beforeunload", cleanup); - return () => window.removeEventListener("beforeunload", cleanup); + window.addEventListener("pagehide", cleanup); + return () => { + window.removeEventListener("beforeunload", cleanup); + window.removeEventListener("pagehide", cleanup); + }; }, []); // ─── Probe for available devices ────────────────────────────────────── @@ -232,7 +248,16 @@ export function useConnection({ /** Shared post-connect: set state and notify caller. */ const finishConnect = useCallback( (drv: DeviceDriver, info: DeviceInfo, deviceId?: string) => { - if (deviceId) lastDeviceIdRef.current = deviceId; + if (deviceId) { + lastDeviceIdRef.current = deviceId; + // Persist across reloads so the page-load auto-reconnect tries the + // device the user actually had connected before probing the rest. + try { + localStorage.setItem(LAST_DEVICE_KEY, deviceId); + } catch { + /* storage unavailable (private mode etc.) */ + } + } // Set the ref synchronously so reprobe() can't race us into a duplicate // connection before React commits the driver state update. driverRef.current = drv; @@ -328,7 +353,21 @@ export function useConnection({ autoConnectAttempted.current = true; (async () => { - for (const id of Object.keys(CONNECTION_ENTRIES)) { + // Last-used device first (persisted across reloads), then the rest + // in registry order — with several authorized devices present, a + // reload should resume the one that was actually in use. + let lastUsed: string | null = null; + try { + lastUsed = localStorage.getItem(LAST_DEVICE_KEY); + } catch { + /* storage unavailable */ + } + const ids = Object.keys(CONNECTION_ENTRIES); + if (lastUsed && ids.includes(lastUsed)) { + ids.splice(ids.indexOf(lastUsed), 1); + ids.unshift(lastUsed); + } + for (const id of ids) { try { if (await connectDevice(id, { auto: true })) return; } catch (e) { diff --git a/src/lib/core/dump-job.test.ts b/src/lib/core/dump-job.test.ts new file mode 100644 index 0000000..2d2fbb8 --- /dev/null +++ b/src/lib/core/dump-job.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { DumpJobImpl } from "./dump-job"; +import type { + DeviceDriver, + SystemHandler, + VerificationHashes, +} from "@/lib/types"; + +/** + * The uniform-fill save warning: a save that comes back as a solid + * 0x00/0xFF block is an electrical failure signature (chip never enabled + * / unwired region), not data — hardware-found on an MMC3 cart whose + * save dumped as pure zeros. The job must flag it in the event log. + */ + +function makeDriver(saveData: Uint8Array): DeviceDriver { + return { + id: "fake", + name: "Fake", + capabilities: [], + initialize: async () => ({ + firmwareVersion: "0", + deviceName: "Fake", + capabilities: [], + }), + detectSystem: async () => null, + detectCartridge: async () => null, + readROM: async () => new Uint8Array([1, 2, 3, 4]), + readSave: async () => saveData, + writeSave: async () => {}, + on: () => {}, + }; +} + +const system: SystemHandler = { + systemId: "nes", + displayName: "NES", + fileExtension: ".nes", + getConfigFields: () => [], + validate: () => ({ valid: true }), + buildReadConfig: () => ({ systemId: "nes", params: {} }), + buildOutputFile: (data) => ({ + data, + filename: "dump.nes", + mimeType: "application/octet-stream", + }), + computeHashes: async (): Promise => ({ + crc32: 0, + sha1: "0", + size: 4, + }), + verify: () => ({ matched: false, confidence: "none" as const }), +}; + +async function runJob(saveData: Uint8Array): Promise { + const job = new DumpJobImpl(makeDriver(saveData), system, null); + const warnings: string[] = []; + job.on("onLog", (msg, level) => { + if (level === "warn") warnings.push(msg); + }); + await job.run({ backupSave: true }); + return warnings; +} + +describe("DumpJob uniform-fill save warning", () => { + it("warns when the save is a solid 0x00 block", async () => { + const warnings = await runJob(new Uint8Array(8192)); + expect(warnings.some((w) => w.includes("uniform 0x00 fill"))).toBe(true); + }); + + it("warns when the save is a solid 0xFF block", async () => { + const warnings = await runJob(new Uint8Array(8192).fill(0xff)); + expect(warnings.some((w) => w.includes("uniform 0xFF fill"))).toBe(true); + }); + + it("stays quiet for real-looking save data", async () => { + const save = Uint8Array.from({ length: 8192 }, (_, i) => (i * 7) & 0xff); + const warnings = await runJob(save); + expect(warnings).toEqual([]); + }); +}); diff --git a/src/lib/core/dump-job.ts b/src/lib/core/dump-job.ts index 4e163df..847b74e 100644 --- a/src/lib/core/dump-job.ts +++ b/src/lib/core/dump-job.ts @@ -66,6 +66,20 @@ export class DumpJobImpl { this.setState("dumping_save"); try { const saveData = await this.driver.readSave(readConfig, signal); + // A real save is never a solid 0x00/0xFF block — those are the + // electrical signatures of a save chip that was never enabled + // onto the bus (0x00) or an unwired/erased region (0xFF). + // Hardware-found 2026-06-13: an MMC3 cart's save dumped as pure + // zeros and looked plausible until inspected. Warn, don't fail — + // the bytes are still saved for inspection. + const fill = uniformFillByte(saveData); + if (fill === 0x00 || fill === 0xff) { + this.log( + `Save data is a uniform 0x${fill.toString(16).padStart(2, "0").toUpperCase()} fill — ` + + "this is almost never real save data. Re-dump before trusting it.", + "warn", + ); + } saveFile = { data: saveData, filename: `dump.sav`, @@ -148,3 +162,13 @@ export class DumpJobImpl { this.events.onLog?.(message, level); } } + +/** + * The fill byte when `data` is one repeated value, else null. Saves that + * come back as a solid block are hardware-failure signatures, not data. + */ +function uniformFillByte(data: Uint8Array): number | null { + if (data.length === 0) return null; + const first = data[0]; + return data.every((b) => b === first) ? first : null; +} diff --git a/src/lib/systems/nes/mappers/mappers.test.ts b/src/lib/systems/nes/mappers/mappers.test.ts index 42cfd7a..32eae4e 100644 --- a/src/lib/systems/nes/mappers/mappers.test.ts +++ b/src/lib/systems/nes/mappers/mappers.test.ts @@ -150,6 +150,139 @@ describe("MMC3 (mapper 4)", () => { const out = await mmc3.dumpChrRom(new Mmc3Bus(new Uint8Array(0), chr), 32); expectSameBytes(out, chr); }); + + // Hardware-found gap (an MMC3 battery-save cart, 2026-06-13): $A001 bit 7 must + // be set before $6000-$7FFF returns SRAM; with the chip off the bus the + // save dumps as uniform fill. The dump brackets the read FME-7-style: + // enabled+write-protected ($C0) only for the read, then chip back off + // the bus ($40) so it isn't exposed for the rest of the session. + it("dumpSave brackets the read: enable, read SRAM, disable", async () => { + class SramBus implements NesBus { + ramCtrl = 0; // power-on: chip off the bus + ramCtrlAtRead = -1; + readonly sram = makeImage(8 * 1024); + async setup() {} + async writeCpu(addr: number, value: number) { + if (addr === 0xa001) this.ramCtrl = value; + } + async readCpu(_addr: number, length: number) { + this.ramCtrlAtRead = this.ramCtrl; + if ((this.ramCtrl & 0x80) === 0) return new Uint8Array(length); + return this.sram.slice(0, length); + } + } + const bus = new SramBus(); + const out = await mmc3.dumpSave!(bus, 8); + expectSameBytes(out, bus.sram); + expect(bus.ramCtrlAtRead).toBe(0xc0); // enabled + write-protected + expect(bus.ramCtrl).toBe(0x40); // re-parked off the bus afterwards + }); + + // MMC6 / HKROM: same mapper id, but the save is 1 KiB + // inside the MMC6 at $7000-$73FF — $6000 is open bus, $8000 bit 5 + // master-gates the RAM (while clear, $A001 is forced to $00), and + // $A001 holds per-512-byte-half read/write enables (HhLl in bits 7-4). + // Hardware-validated 2026-06-13: open bus at $6000 can read as a few + // capacitance-echo values rather than uniform fill, so detection + // gates on byte diversity and then fingerprints the MMC6 by its + // enable-dependent driven zeros (a single-half enable actively drives + // the OTHER half to zero — open bus can't respond to $A001 values). + class Mmc6Bus implements NesBus { + reg8000 = 0; + ramCtrl = 0; + ramCtrlAtRead = -1; + lastRamCtrlWrite = -1; + readonly ram = makeImage(1024); + async setup() {} + async writeCpu(addr: number, value: number) { + if (addr === 0x8000) { + this.reg8000 = value; + // Master gate: while bit 5 is clear the protect register is + // continuously forced to $00 and writes to it are ignored. + if ((value & 0x20) === 0) this.ramCtrl = 0; + } else if (addr === 0xa001) { + this.lastRamCtrlWrite = value; // raw write, recorded even when gated + if (this.reg8000 & 0x20) this.ramCtrl = value; + } + } + async readCpu(addr: number, length: number) { + const out = new Uint8Array(length); + for (let i = 0; i < length; i++) { + const a = addr + i; + if (a < 0x7000 || this.ramCtrl === 0) { + // Open bus: low-diversity capacitance echo, NOT uniform — + // the hardware-observed pattern that defeated a uniform gate. + out[i] = [0xec, 0xcc, 0xfc, 0xee][i & 3]; + continue; + } + this.ramCtrlAtRead = this.ramCtrl; + const off = (a - 0x7000) & 0x3ff; // mirrored through $7FFF + const readable = off < 0x200 ? 0x20 : 0x80; // L : H read enables + // A read-disabled half reads back as zero. + out[i] = this.ramCtrl & readable ? this.ram[off] : 0; + } + return out; + } + } + + it("dumpSave auto-detects MMC6 past open-bus noise at $6000", async () => { + const bus = new Mmc6Bus(); + const out = await mmc3.dumpSave!(bus, 8); + expectSameBytes(out, bus.ram); + expect(bus.ramCtrlAtRead).toBe(0xa0); // both halves readable, writes denied + expect(bus.reg8000 & 0x20).toBe(0); // master gate re-closed first + expect(bus.ramCtrl).toBe(0); // gated off — the $40 park is a no-op while gated + // ...but the final $A001 write is still the write-protected $40, so a + // false-positive on a real (ungated) MMC3 wouldn't be left writable. + expect(bus.lastRamCtrlWrite).toBe(0x40); + }); + + it("dumpSave returns the $6000 read when neither window answers", async () => { + class NoRamBus implements NesBus { + async setup() {} + async writeCpu() {} + async readCpu(_addr: number, length: number) { + return new Uint8Array(length).fill(0xff); // open bus everywhere + } + } + const out = await mmc3.dumpSave!(new NoRamBus(), 8); + expect(out.length).toBe(8 * 1024); // the $6000 read, not the 1 KiB probe + expect(out.every((b) => b === 0xff)).toBe(true); // upstream warning fires + }); + + // A real MMC3 whose battery SRAM is nearly empty also fails the + // diversity gate and zero-fills the fingerprint windows — the + // cross-check against the $6000 pass (where MMC3 SRAM was enabled, + // but an MMC6 would have shown open bus) must catch it and keep the + // full 8 KiB. + it("dumpSave does not mistake a zero-heavy MMC3 SRAM for an MMC6", async () => { + class SparseSramBus implements NesBus { + ramCtrl = 0; + readonly sram = new Uint8Array(8 * 1024); + constructor() { + this.sram[0x1ff0] = 0x42; // a few live bytes outside the probe windows + this.sram[0x1ff1] = 0x99; + } + async setup() {} + async writeCpu(addr: number, value: number) { + if (addr === 0xa001) this.ramCtrl = value; + } + async readCpu(addr: number, length: number) { + // MMC3: $A001 bit 7 enables the whole 8 KiB at $6000-$7FFF. + if ((this.ramCtrl & 0x80) === 0) return new Uint8Array(length); + const off = addr - 0x6000; + return this.sram.slice(off, off + length); + } + } + const bus = new SparseSramBus(); + const out = await mmc3.dumpSave!(bus, 8); + expect(out.length).toBe(8 * 1024); + expect(out[0x1ff0]).toBe(0x42); // the real (sparse) save, intact + // Exit state matches init: off the bus AND write-protected (bit 6), + // never left at 0x00 — so a variant that ignores bit 7 can't be + // written for the rest of the session. + expect(bus.ramCtrl).toBe(0x40); + }); }); describe("MMC2 (mapper 9)", () => { diff --git a/src/lib/systems/nes/mappers/mmc3.ts b/src/lib/systems/nes/mappers/mmc3.ts index cda82b0..8e4859b 100644 --- a/src/lib/systems/nes/mappers/mmc3.ts +++ b/src/lib/systems/nes/mappers/mmc3.ts @@ -23,6 +23,7 @@ import type { NesBus } from "../bus"; import type { NesMapper, ProgressCb } from "./types"; import { walkBanks } from "./bank-walk"; +import { bytesEqual, readBankWithConsensus } from "./bank-reliability"; const BANK_SELECT = 0x8000; const BANK_DATA = 0x8001; @@ -74,7 +75,9 @@ export async function setupBanks(bus: NesBus): Promise { /** Set up MMC3 registers to a known state before dumping. */ async function initMmc3(bus: NesBus): Promise { - // PRG-RAM: disable writes, allow reads (so writes to $6000-$7FFF are ignored) + // PRG-RAM: bit 7 clear takes the chip off the bus entirely (and bit 6 + // write-protects it on variants that ignore bit 7), so no ROM pass can + // touch a battery save. await bus.writeCpu(PRG_RAM_CTRL, 0x40); await setupBanks(bus); } @@ -175,4 +178,99 @@ export const mmc3: NesMapper = { dumpChrRom: (bus, sizeKB, onProgress) => dumpMmc3StyleChrRom(bus, sizeKB, MMC3, onProgress), + + // Same protective bracket as FME-7's hardware-validated dumpSave: + // expose the SRAM only for the read itself. $A001 bit 7 must be SET + // before $6000-$7FFF returns SRAM (the power-on/init state leaves the + // chip off the bus, which dumps as uniform fill); bit 6 stays set so + // the window is write-protected even while exposed. MMC3-specific — + // RAMBO-1 shares the dump core but its $A001 is not a PRG-RAM control + // register. + async dumpSave(bus, sramKB, onProgress) { + await bus.setup(); + await bus.writeCpu(PRG_RAM_CTRL, 0xc0); + const data = await bus.readCpu(0x6000, sramKB * 1024, onProgress); + // Take the chip back off the bus as soon as the read completes — + // deasserting its enable shields the battery save from stray bus + // cycles for the rest of the session, including the unplug + // power-down, the riskiest window. + await bus.writeCpu(PRG_RAM_CTRL, 0x40); + // A real SRAM read is byte-diverse. Open bus is not — it reads as + // uniform fill OR as a handful of capacitance-echo values (both seen + // on hardware), so gate on diversity rather than uniformity. + if (countDistinct(data) >= OPEN_BUS_MAX_DISTINCT) return data; + + // Nothing convincing at $6000. Mapper 4 covers a second board + // family: MMC6 / HKROM, whose save is 1 KiB inside + // the MMC6 itself at $7000-$73FF behind a different enable chain — + // $8000 bit 5 is a master gate (while clear, $A001 is forced to + // $00), and $A001 holds per-512-byte-half read/write enables (HhLl + // in bits 7-4). All of these writes are no-ops on a real MMC3 + // ($8000 bits 3-5 unused; $A001 transitions end chip-disabled). + // + // Detection fingerprint (hardware-validated on an HKROM cart, + // 2026-06-13): with the gate up and only ONE half read-enabled, the + // MMC6 actively DRIVES the other half to zero — a response to the + // register value that open bus cannot mimic. + await bus.writeCpu(BANK_SELECT, 0x20); + await bus.writeCpu(PRG_RAM_CTRL, 0x80); // upper half readable only + const upperOnly = await bus.readCpu(0x7000, 1024); + await bus.writeCpu(PRG_RAM_CTRL, 0x20); // lower half readable only + const lowerOnly = await bus.readCpu(0x7000, 1024); + const drivesDisabledHalves = + upperOnly.subarray(0, 512).every((b) => b === 0) && + lowerOnly.subarray(512).every((b) => b === 0); + // False-positive guard: an MMC3 whose battery SRAM zero-fills these + // exact windows would also pass the zero checks — but then the same + // window inside the $6000 pass (which had the MMC3 SRAM enabled) + // would match what the "enabled half" returns now. On an MMC6 the + // $6000 pass saw open bus there instead. + const mmc3Lookalike = bytesEqual( + upperOnly.subarray(512), + data.subarray(0x1200, 0x1400), + ); + + if (drivesDisabledHalves && !mmc3Lookalike) { + // Both halves readable, writes denied; consensus-read the 1 KiB — + // a battery-weak MMC6 array flickers individual cells, which a + // single read would silently mis-capture. + await bus.writeCpu(PRG_RAM_CTRL, 0xa0); + const { data: mmc6 } = await readBankWithConsensus({ + read: () => bus.readCpu(0x7000, 1024, onProgress), + label: "MMC6 save RAM", + }); + // Close the MMC6 master gate, then park PRG-RAM control to the same + // off-bus + write-protected state as init ($40). On a gated MMC6 the + // $A001 write is a no-op; on an MMC3 false-positive it keeps bit 6 + // set, so a variant that ignores bit 7 still can't be written. + await bus.writeCpu(BANK_SELECT, 0x00); + await bus.writeCpu(PRG_RAM_CTRL, 0x40); + return mmc6; + } + + // Not an MMC6 — re-park the probe registers to init's safe state + // (gate closed, then PRG-RAM control off-bus + write-protected) and + // return the $6000 read; if it was uniform, the dump-job's warning + // tells the user. + await bus.writeCpu(BANK_SELECT, 0x00); + await bus.writeCpu(PRG_RAM_CTRL, 0x40); + return data; + }, }; + +/** + * Open bus reads as one value or a small set of bus-echo values (6 and + * 4 distinct observed on hardware across 4 KiB windows); real SRAM — + * even a freshly-initialized save — is far more diverse. The threshold + * splits those regimes with wide margins on both sides. + */ +const OPEN_BUS_MAX_DISTINCT = 16; + +function countDistinct(data: Uint8Array): number { + const seen = new Set(); + for (const b of data) { + seen.add(b); + if (seen.size >= OPEN_BUS_MAX_DISTINCT) break; + } + return seen.size; +} diff --git a/src/lib/systems/nes/nes-constants.ts b/src/lib/systems/nes/nes-constants.ts index e8ea903..9dbff68 100644 --- a/src/lib/systems/nes/nes-constants.ts +++ b/src/lib/systems/nes/nes-constants.ts @@ -102,7 +102,9 @@ export const NES_MAPPER_DB: NESMapperDef[] = [ }, { id: 4, - name: "MMC3 (TxROM)", + // MMC6 (HKROM) is mapper 4 too — same banking, different save + // hardware. The save path auto-detects it (see mmc3.ts dumpSave). + name: "MMC3 / MMC6 (TxROM, HKROM)", prgSizesKB: [32, 64, 128, 256, 512], chrSizesKB: [0, 8, 16, 32, 64, 128, 256], mirroring: "mapper_controlled", diff --git a/src/lib/transport/serial-transport.ts b/src/lib/transport/serial-transport.ts index 59dd015..c6f6059 100644 --- a/src/lib/transport/serial-transport.ts +++ b/src/lib/transport/serial-transport.ts @@ -95,6 +95,42 @@ export class SerialTransport implements Transport { this.pendingTotal = 0; } + /** + * Best-effort teardown for page unload (`Transport.closeNow`). Reloading + * with an open port can hang Chromium's navigation while it waits on the + * held serial handle, and the awaits inside `disconnect()` may never + * resume once the document starts dying. So issue reader-cancel and + * writer-abort synchronously this tick — the calls Chromium needs to + * start releasing the handle — then queue releaseLock + port.close() + * behind a microtask, since the locks aren't free until cancel/abort + * settle. Every rejection is swallowed. The port object dies with the + * document either way; this just gets the release calls in as early as + * possible. + */ + closeNow(): void { + const { port, reader, writer } = this; + this.port = null; + this.reader = null; + this.writer = null; + this.pendingBytes = []; + this.pendingTotal = 0; + if (!port) return; + port.removeEventListener("disconnect", this.onSerialDisconnect); + reader?.cancel().catch(() => {}); + writer?.abort().catch(() => {}); + // Locks may still be held for a microtask or two after cancel/abort; + // queue the close behind them rather than awaiting. + Promise.resolve().then(() => { + try { + reader?.releaseLock(); + writer?.releaseLock(); + } catch { + /* still locked — close() below is then a no-op rejection */ + } + port.close().catch(() => {}); + }); + } + debug = false; async send(data: Uint8Array, _options?: TransferOptions): Promise { diff --git a/src/lib/types.ts b/src/lib/types.ts index a197391..bffe00f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -12,6 +12,14 @@ export interface Transport { readonly connected: boolean; connect(options?: TransportConnectOptions): Promise; disconnect(): Promise; + /** + * Synchronous best-effort teardown for page unload. A dying document + * may never resume the awaits inside `disconnect()`, so this kicks off + * every release step in one synchronous burst without awaiting between + * them. Optional — transports whose handles don't outlive the document + * (WebUSB/WebHID release cleanly on unload) omit it. + */ + closeNow?(): void; send(data: Uint8Array, options?: TransferOptions): Promise; receive(length: number, options?: TransferOptions): Promise; on(