Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 44 additions & 5 deletions src/hooks/use-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ const TRANSPORT_LABEL: Record<string, string> = {
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 {
Expand Down Expand Up @@ -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 ──────────────────────────────────────
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
81 changes: 81 additions & 0 deletions src/lib/core/dump-job.test.ts
Original file line number Diff line number Diff line change
@@ -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<VerificationHashes> => ({
crc32: 0,
sha1: "0",
size: 4,
}),
verify: () => ({ matched: false, confidence: "none" as const }),
};

async function runJob(saveData: Uint8Array): Promise<string[]> {
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([]);
});
});
24 changes: 24 additions & 0 deletions src/lib/core/dump-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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;
}
133 changes: 133 additions & 0 deletions src/lib/systems/nes/mappers/mappers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand Down
Loading