diff --git a/src/App.tsx b/src/App.tsx index 587ad53..b3fb20c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,17 +13,27 @@ import { useDumpJob } from "@/hooks/use-dump-job"; import { useNoIntro } from "@/hooks/use-nointro"; import { useAmiiboDb } from "@/hooks/use-amiibo-db"; import { useConnection } from "@/hooks/use-connection"; +import { useRetrode2 } from "@/hooks/use-retrode2"; import { DatabasePanel } from "@/components/shared/database-panel"; import { GBSystemHandler } from "@/lib/systems/gb/gb-system-handler"; import { GBASystemHandler } from "@/lib/systems/gba/gba-system-handler"; +import { SNESSystemHandler } from "@/lib/systems/snes/snes-system-handler"; +import { GenesisSystemHandler } from "@/lib/systems/genesis/genesis-system-handler"; +import { N64SystemHandler } from "@/lib/systems/n64/n64-system-handler"; +import { SMSSystemHandler } from "@/lib/systems/sms/sms-system-handler"; import { NESSystemHandler } from "@/lib/systems/nes/nes-system-handler"; +import { N64ControllerPakHandler } from "@/lib/systems/n64/n64-controller-pak-handler"; +import { VirtualBoySystemHandler } from "@/lib/systems/vboy/vboy-system-handler"; +import { Atari2600SystemHandler } from "@/lib/systems/atari2600/atari2600-system-handler"; import { Ps1SystemHandler } from "@/lib/systems/ps1/ps1-system-handler"; import { NOINTRO_SYSTEM_NAMES } from "@/lib/core/nointro"; import { AmiiboScanner } from "@/components/wizard/amiibo-scanner"; import { InfinityScanner } from "@/components/wizard/infinity-scanner"; import { NDSScanner } from "@/components/wizard/nds-scanner"; +import { Retrode2Panel } from "@/components/wizard/retrode2-panel"; import type { InfinityDriver } from "@/lib/drivers/infinity/infinity-driver"; import type { NDSDeviceDriver } from "@/lib/systems/nds/nds-header"; +import type { Retrode2Driver } from "@/lib/drivers/retrode2/retrode2-driver"; import type { DeviceDriver, DeviceInfo, @@ -39,7 +49,15 @@ const ALL_SYSTEMS: SystemHandler[] = [ new GBSystemHandler("gb"), new GBSystemHandler("gbc"), new GBASystemHandler(), + new SNESSystemHandler(), + new GenesisSystemHandler(), + new N64SystemHandler(), + new N64ControllerPakHandler(), + new SMSSystemHandler("sms"), + new SMSSystemHandler("gg"), new NESSystemHandler(), + new VirtualBoySystemHandler(), + new Atari2600SystemHandler(), new Ps1SystemHandler(), ]; @@ -60,6 +78,33 @@ const ACTIVE_STATES: ReadonlySet = new Set([ ]); const isDumping = (s: DumpJobState): boolean => ACTIVE_STATES.has(s); +/** + * How a connected device is presented: its own bespoke panel, or the standard + * detect → configure → dump wizard. One source of truth for both render + * routing and autoDetect suppression (previously four ad-hoc booleans plus a + * separately-maintained scanner list). + */ +type DeviceUiKind = "amiibo" | "infinity" | "nds" | "retrode" | "standard"; + +function deviceUiKind(driver: DeviceDriver | null): DeviceUiKind { + if (!driver) return "standard"; + if (driver.id === "RETRODE2") return "retrode"; + const has = (id: string) => driver.capabilities.some((c) => c.systemId === id); + if (has("amiibo")) return "amiibo"; + if (has("disney-infinity")) return "infinity"; + if (has("nds_save")) return "nds"; + return "standard"; +} + +/** Kinds that run their own detection loop, so App must NOT auto-detect them. + * The Retrode auto-loads every ROM itself (useRetrode2), so it's here too. */ +const SCANNER_KINDS: ReadonlySet = new Set([ + "amiibo", + "infinity", + "nds", + "retrode", +]); + /** Merge config field defaults with pre-filled values from auto-detection. */ function seedConfigDefaults( system: SystemHandler, @@ -90,6 +135,10 @@ function App() { // dumps only. Keyed by header-field key; empty means "use the defaults the // computed header already carries". Reset on every new dump / teardown. const [headerOverrides, setHeaderOverrides] = useState({}); + // Completion-screen opt-in: swap the dumped ROM for the heuristically trimmed + // variant the dump job pre-computed (DumpResult.trimSuggestion). Off by + // default — a heuristic trim is never applied silently. Reset on every dump. + const [useTrimmed, setUseTrimmed] = useState(false); const [autoDetected, setAutoDetected] = useState(null); const [detecting, setDetecting] = useState(false); const [unsupportedDetection, setUnsupportedDetection] = useState<{ @@ -215,14 +264,9 @@ function App() { const onDeviceReady = useCallback( (_driver: DeviceDriver, _info: DeviceInfo) => { - // Scanner-based devices handle detection in their own polling loop - const isScanner = _driver.capabilities.some( - (c) => - c.systemId === "amiibo" || - c.systemId === "disney-infinity" || - c.systemId === "nds_save", - ); - if (!isScanner) autoDetectSystem(_driver); + // Scanner-based devices run their own detection loop; everything else + // (incl. the Retrode) goes through the standard auto-detect. + if (!SCANNER_KINDS.has(deviceUiKind(_driver))) autoDetectSystem(_driver); }, [autoDetectSystem], ); @@ -233,14 +277,21 @@ function App() { * unplug) — via useConnection's onDisconnected hook, so a replug/auto- * reconnect starts fresh instead of showing the previous dump's report. */ + // Clear the shared dump state (job result, trim choice, header edits). Used + // by the disconnect teardown and the Retrode hook's "Back" action. + const resetDumpState = useCallback(() => { + dumpJob.reset(); + setUseTrimmed(false); + setHeaderOverrides({}); + }, [dumpJob]); + const clearSessionState = useCallback(() => { setSelectedSystem(null); setConfigValues({}); - setHeaderOverrides({}); setAutoDetected(null); setUnsupportedDetection(null); - dumpJob.reset(); - }, [dumpJob]); + resetDumpState(); // retrode's own dump list resets in useRetrode2 on driver change + }, [resetDumpState]); const connection = useConnection({ log, @@ -263,17 +314,36 @@ function App() { return ALL_SYSTEMS.filter((s) => supported.has(s.systemId)); }, [connection.driver]); - // Determine the status badge state - const isAmiiboDevice = - connection.driver?.capabilities.some((c) => c.systemId === "amiibo") ?? - false; - const isInfinityDevice = - connection.driver?.capabilities.some( - (c) => c.systemId === "disney-infinity", - ) ?? false; - const isNDSSaveDevice = - connection.driver?.capabilities.some((c) => c.systemId === "nds_save") ?? - false; + // Single routing classifier — drives both the render and the Retrode hook. + const uiKind = deviceUiKind(connection.driver); + + /** Build a ROM's dump config from its detected CartridgeInfo — the same + * prefill + seed the standard wizard uses, packaged for the Retrode hook + * (which dumps every ROM on the volume itself). */ + const buildRetrodeConfig = useCallback( + (handler: SystemHandler, cartInfo: CartridgeInfo | null): ConfigValues => { + const cap = connection.driver?.capabilities.find( + (c) => c.systemId === handler.systemId, + ); + const prefilled = cartInfo + ? prefillFromCartInfo(handler, cartInfo, hasSeparateSaveRead(cap)) + : {}; + return seedConfigDefaults(handler, prefilled, cartInfo ?? undefined); + }, + [connection.driver, prefillFromCartInfo], + ); + + // The Retrode auto-loads every ROM on its volume itself (own lifecycle + + // change polling), so it doesn't ride App's single-dump state. + const retrode = useRetrode2({ + driver: uiKind === "retrode" ? (connection.driver as Retrode2Driver) : null, + systems: ALL_SYSTEMS, + getDb: nointro.getDb, + getMultiDb: nointro.getMultiDb, + buildConfig: buildRetrodeConfig, + log, + onDisconnect: handleDisconnect, + }); const badgeState = useMemo(() => { if (dumpJob.state !== "idle") return dumpJob.state; @@ -286,7 +356,19 @@ function App() { // edits, or a system without an editable header — so downstream memos (e.g. // the icon-allocating PS1 summary) don't churn on unrelated re-renders. const editedResult = useMemo(() => { - const result = dumpJob.result; + let result = dumpJob.result; + // Heuristic over-dump trim is a completion-screen opt-in: when enabled, + // swap in the trimmed variant (rom/hashes/verification) the dump job + // pre-computed. Header edits below then apply on top of the chosen base. + if (useTrimmed && result?.trimSuggestion) { + const t = result.trimSuggestion; + result = { + ...result, + rom: t.rom, + hashes: t.hashes, + verification: t.verification, + }; + } if ( !result?.rom || result.verification.matched || @@ -309,7 +391,7 @@ function App() { meta: { ...result.rom.meta, ...(selectedSystem.headerMeta?.(data) ?? {}) }, }, }; - }, [dumpJob.result, selectedSystem, headerOverrides]); + }, [dumpJob.result, selectedSystem, headerOverrides, useTrimmed]); // Decoding the PS1 dump summary allocates fresh icon arrays per call, which // would restart the IconCanvas animation on every unrelated re-render @@ -320,6 +402,19 @@ function App() { return selectedSystem?.summarizeDump?.(data) ?? null; }, [selectedSystem, editedResult?.rom?.data]); + // Device-independent title parsed from the dumped ROM's OWN internal header + // (SNES $7FC0, N64 0x20, GB 0x134, GBA 0xA0, Genesis name field, …). Derived + // from the FINAL bytes so a header edit / trim is reflected. Undefined for + // systems without a header title (SMS/GG, NES, …) or handlers that don't + // implement headerTitle, in which case CompleteStep keeps its existing + // title → "(Unverified) " fallback. Keyed on the rom data reference + // + handler, like dumpSummary. + const headerTitle = useMemo(() => { + const data = editedResult?.rom?.data; + if (!data) return undefined; + return selectedSystem?.headerTitle?.(data); + }, [selectedSystem, editedResult?.rom?.data]); + // Editable NES 2.0 header fields, offered only for an unverified dump (the // verified path uses the canonical DB header). Built from the ORIGINAL ROM // bytes overlaid with the user's edits — never the already-edited file. @@ -337,6 +432,7 @@ function App() { async (system: SystemHandler) => { dumpJob.reset(); setHeaderOverrides({}); + setUseTrimmed(false); // Do auto-detection BEFORE updating state, so the UI renders once // with the final values instead of flashing empty then populated. @@ -397,8 +493,9 @@ function App() { const handleStartDump = useCallback(async () => { if (!connection.driver || !selectedSystem) return; - // A fresh dump starts with no header edits carried over from the last one. + // A fresh dump starts with no header edits or trim choice carried over. setHeaderOverrides({}); + setUseTrimmed(false); // Resolve field defaults for locked/unset keys (e.g. backupSave, battery) // so locked checkbox values that the user can't interact with are included. const resolved = seedConfigDefaults( @@ -436,6 +533,7 @@ function App() { * natural. */ const handleScan = useCallback(() => { dumpJob.reset(); + setUseTrimmed(false); setAutoDetected(null); setUnsupportedDetection(null); if (connection.driver) autoDetectSystem(connection.driver); @@ -487,21 +585,21 @@ function App() { error={connection.connectError} availableDevices={connection.availableDevices} /> - ) : isAmiiboDevice ? ( + ) : uiKind === "amiibo" ? ( - ) : isInfinityDevice ? ( + ) : uiKind === "infinity" ? ( - ) : isNDSSaveDevice ? ( + ) : uiKind === "nds" ? ( + ) : uiKind === "retrode" ? ( + ) : (
{/* Persistent device header — Disconnect (and Scan, on @@ -610,6 +719,10 @@ function App() { // chain (verified name → title → "(Unverified) "), // and a truthy placeholder here would short-circuit it. title={configValues.title as string | undefined} + // Device-independent: parsed from the dumped bytes' own + // header, preferred over `title` (which may be a device + // filename) for the unverified output name. + headerTitle={headerTitle} fileExtension={selectedSystem?.fileExtension ?? ""} deviceInfo={connection.deviceInfo} cartInfo={autoDetected} @@ -625,6 +738,19 @@ function App() { onHeaderFieldChange={(key, value) => setHeaderOverrides((prev) => ({ ...prev, [key]: value })) } + overdump={ + dumpJob.result.trimSuggestion + ? { + fullSize: dumpJob.result.hashes.size, + trimmedSize: dumpJob.result.trimSuggestion.size, + note: dumpJob.result.trimSuggestion.note, + verifiedWhenTrimmed: + dumpJob.result.trimSuggestion.verification.matched, + trimmed: useTrimmed, + onToggle: setUseTrimmed, + } + : undefined + } log={log} /> )} diff --git a/src/components/wizard/complete-step.tsx b/src/components/wizard/complete-step.tsx index f8e3f8c..3ed6f6d 100644 --- a/src/components/wizard/complete-step.tsx +++ b/src/components/wizard/complete-step.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; import type { DumpResult, DeviceInfo, @@ -16,6 +17,13 @@ import { generateDumpReport } from "@/lib/core/dump-report"; type IconCell = Extract; +/** The extension (including the dot) of a No-Intro rom filename, or undefined + * when it has none — e.g. "Game (USA).md" → ".md". */ +function datExtension(romName: string | undefined): string | undefined { + const m = romName?.match(/\.[^.]+$/); + return m ? m[0] : undefined; +} + function IconCanvas({ cell }: { cell: IconCell }) { const ref = useRef(null); useEffect(() => { @@ -64,6 +72,16 @@ interface CompleteStepProps { * defaulting this to a placeholder string would mask it. */ title?: string; + /** + * Title parsed from the dumped ROM's INTERNAL header (device-independent), + * when the system has one. Preferred over {@link title} for naming an + * UNVERIFIED dump, because it comes from the bytes rather than from a + * driver/device-supplied filename. Undefined for systems without a header + * title (SMS/GG, NES, …) or when the parent has no header title to offer; + * the existing `title` → "(Unverified) " fallback then applies + * unchanged. Never overrides the verified No-Intro name. + */ + headerTitle?: string; fileExtension: string; deviceInfo: DeviceInfo | null; cartInfo: CartridgeInfo | null; @@ -82,13 +100,35 @@ interface CompleteStepProps { headerFields?: ResolvedConfigField[] | null; /** Called when the user edits a header field; keyed by field key. */ onHeaderFieldChange?: (key: string, value: unknown) => void; + /** + * A heuristic over-dump trim the dump job pre-computed but did NOT apply. + * When present, the completion screen offers it as an opt-in: checking the + * box swaps the saved ROM (and the hashes/verification shown) for the trimmed + * variant. Absent when the dump matched a DAT or no shorter size was found. + */ + overdump?: { + fullSize: number; + trimmedSize: number; + note: string; + /** The trimmed size matched a No-Intro entry (a strong trim signal). */ + verifiedWhenTrimmed: boolean; + trimmed: boolean; + onToggle: (trimmed: boolean) => void; + }; /** Event-log sink, used to surface a failed save. */ log: (message: string, level?: "info" | "warn" | "error") => void; + /** + * Suppress the trailing "unplug to dump another" / "swap and Scan" hint. + * Set when the card is one of several in a list that owns its own continuation + * affordance (the Retrode auto-load shows a single hint for the whole volume). + */ + hideContinuationHint?: boolean; } export function CompleteStep({ result, title, + headerTitle, fileExtension, deviceInfo, cartInfo, @@ -98,26 +138,54 @@ export function CompleteStep({ summary, headerFields, onHeaderFieldChange, + overdump, log, + hideContinuationHint, }: CompleteStepProps) { const verified = result.verification.matched; const verifiedName = result.verification.entry?.name; const integrityFailed = summary?.integrity?.ok === false; - const ok = saveOnly ? !integrityFailed : verified; - const headingText = saveOnly + const integrityMsg = summary?.integrity?.message; + + // Three outcomes: + // - "ok" (green ✓): a No-Intro match, or a clean save-only dump. + // - "neutral" (ℹ): no verification database is loaded for this ROM's system, + // so we genuinely can't say verified/unverified — not a warning. We still + // surface the handler's internal-checksum result when it reports one. + // - "warn" (amber ?): a database IS loaded and the dump didn't match it, or + // a save-only integrity check failed. + const noDatabase = + !saveOnly && !verified && result.verification.databaseLoaded === false; + const tone: "ok" | "neutral" | "warn" = saveOnly ? integrityFailed - ? "Unverified" - : "Complete" + ? "warn" + : "ok" : verified - ? "Verified" - : "Unverified"; + ? "ok" + : noDatabase + ? "neutral" + : "warn"; + + const headingText = + tone === "ok" + ? saveOnly + ? "Complete" + : "Verified" + : tone === "neutral" + ? "Not checked" + : "Unverified"; + const noDatabaseDetail = integrityMsg + ? `No verification database loaded · ${integrityMsg}` + : "No verification database loaded for this system"; const subText = saveOnly ? integrityFailed - ? (summary?.integrity?.message ?? "Integrity check failed") + ? (integrityMsg ?? "Integrity check failed") : "Dump complete" : verified ? verifiedName - : "No match in verification database"; + : noDatabase + ? noDatabaseDetail + : "No match in verification database"; // Unverified dumps from systems without cart titles still deserve a // distinctive filename: tag them with the system name and the content @@ -128,16 +196,30 @@ export function CompleteStep({ // the display name's "/" and tidies the whitespace. const unverifiedName = `${systemDisplayName} (Unverified) ${hexStr(result.hashes.crc32)}`; - const baseName = verifiedName ?? (title || unverifiedName); + // Unverified stem, device-agnostic: prefer the title parsed from the ROM's + // own internal header (when the parent could derive one) over the cartridge/ + // config `title` — the latter can be a driver/device-supplied filename (e.g. + // the Retrode 2's synthesized volume name). Falls through to the CRC32 name + // when neither is available, so no system loses its current filename. + const unverifiedStem = headerTitle || title || unverifiedName; + + const baseName = verifiedName ?? unverifiedStem; + // On a verified match, prefer the No-Intro DAT's own extension — it's the + // canonical form for these exact (hash-matched) bytes, so it's authoritative + // even when the detected system was wrong (e.g. a cart from another system + // read as Genesis gets that system's extension, not ".gen"). Falls back to + // the handler's extension when the DAT entry carries no filename. + const verifiedExt = + datExtension(result.verification.entry?.romName) ?? fileExtension; // Save-only systems (e.g. PS1 memory card) have no verification DB and // their handlers build a sensible date-stamped filename in `buildOutputFile`. - // ROM systems use the cartridge title (or verified name when available). + // ROM systems use the header/cartridge title (or verified name when available). const romFilename = verifiedName !== undefined - ? verifiedName + fileExtension + ? verifiedName + verifiedExt : saveOnly && result.rom ? result.rom.filename - : (title || unverifiedName) + fileExtension; + : unverifiedStem + fileExtension; const saveFilename = baseName + ".sav"; const save = useCallback( @@ -162,27 +244,37 @@ export function CompleteStep({ save(data, baseName + ".txt", [".txt"]); }, [result, deviceInfo, cartInfo, systemDisplayName, romFilename, baseName, save]); + const toneCard = + tone === "ok" + ? "border-primary/40 bg-primary/5" + : tone === "neutral" + ? "border-border bg-muted/20" + : "border-chart-3/30 bg-chart-3/5"; + const toneBadge = + tone === "ok" + ? "bg-primary/20 text-primary" + : tone === "neutral" + ? "bg-muted text-muted-foreground" + : "bg-chart-3/20 text-chart-3"; + const toneHeading = + tone === "ok" + ? "text-primary" + : tone === "neutral" + ? "text-foreground" + : "text-chart-3"; + const glyph = tone === "ok" ? "\u2713" : tone === "neutral" ? "\u2139" : "?"; + return ( - +
- {ok ? "\u2713" : "?"} + {glyph}
-
+
{headingText}
{subText}
@@ -193,13 +285,21 @@ export function CompleteStep({
CRC32: - + {hexStr(result.hashes.crc32)}
SHA-1: - + {result.hashes.sha1}
@@ -211,6 +311,29 @@ export function CompleteStep({
+ {/* Heuristic over-dump trim — opt-in, never applied silently. Checking + the box swaps the saved ROM (and the hashes above) for the trimmed + variant. A No-Intro match on the trimmed size is a strong signal; a + geometry-only guess is offered with a caveat. */} + {overdump && ( + + )} + {!verified && !saveOnly && result.verification.suggestions && (
    {result.verification.suggestions.map((s, i) => ( @@ -339,14 +462,14 @@ export function CompleteStep({ )}
- {!hotSwap && ( + {!hideContinuationHint && !hotSwap && (

{saveOnly ? `Disconnect the ${deviceInfo?.deviceName ?? "device"} from USB to back up another save.` : `To dump another cartridge, unplug the ${deviceInfo?.deviceName ?? "device"} from USB, swap the cartridge, then plug it back in.`}

)} - {hotSwap && ( + {!hideContinuationHint && hotSwap && (

Swap the cartridge and click Scan to back up another.

diff --git a/src/components/wizard/connect-step.tsx b/src/components/wizard/connect-step.tsx index 93294c4..72567e7 100644 --- a/src/components/wizard/connect-step.tsx +++ b/src/components/wizard/connect-step.tsx @@ -35,6 +35,7 @@ export function ConnectStep({ serial: !!navigator.serial, webhid: !!navigator.hid, webusb: !!navigator.usb, + directory: "showDirectoryPicker" in window, }; // Sort detected devices to the top diff --git a/src/components/wizard/device-info-dialog.tsx b/src/components/wizard/device-info-dialog.tsx index b7b237f..f93a69f 100644 --- a/src/components/wizard/device-info-dialog.tsx +++ b/src/components/wizard/device-info-dialog.tsx @@ -18,6 +18,7 @@ const TRANSPORT_LABEL: Record = { webusb: "WebUSB", nfc: "Web NFC", http: "HTTP", + directory: "Folder (mass storage)", }; const RULES_FILE_URL = diff --git a/src/components/wizard/retrode-rom-card.tsx b/src/components/wizard/retrode-rom-card.tsx new file mode 100644 index 0000000..8aa698e --- /dev/null +++ b/src/components/wizard/retrode-rom-card.tsx @@ -0,0 +1,168 @@ +import { useMemo, useState } from "react"; +import { TriangleAlert } from "lucide-react"; +import { CompleteStep } from "@/components/wizard/complete-step"; +import type { ConfigValues, DeviceInfo, DumpResult } from "@/lib/types"; +import type { RetrodeDump } from "@/hooks/use-retrode2"; + +/** + * One cartridge's slot in the Retrode auto-load list. Owns the per-ROM + * completion state (heuristic-trim toggle + header edits) and derives the same + * edited-result / summary / header-title view App's single-dump flow does, then + * renders a {@link CompleteStep}. Pre-completion states render a compact row. + * + * Self-contained per ROM so several can sit side by side without sharing the + * trim/header state that, in the single-dump path, lives in App. + */ +export function RetrodeRomCard({ + dump, + deviceInfo, + log, +}: { + dump: RetrodeDump; + deviceInfo: DeviceInfo | null; + log: (message: string, level?: "info" | "warn" | "error") => void; +}) { + const [useTrimmed, setUseTrimmed] = useState(false); + const [headerOverrides, setHeaderOverrides] = useState({}); + + const handler = dump.handler; + const baseResult = dump.result; + + // Memory-card-style systems (N64 Controller Pak) have no No-Intro database; + // like the PS1 memory card they're "save-only" so the completion screen's + // heading is driven by the internal-checksum integrity (summarizeDump), + // not by a ROM verification match. + const saveOnly = handler?.systemId === "n64_controller_pak"; + + // Heuristic over-dump trim (opt-in) + NES-2.0 header edits, applied on top of + // the dumped bytes — mirrors App.editedResult for the single-dump flow. + const editedResult = useMemo(() => { + let result = baseResult; + if (!result) return result; + if (useTrimmed && result.trimSuggestion) { + const t = result.trimSuggestion; + result = { + ...result, + rom: t.rom, + hashes: t.hashes, + verification: t.verification, + }; + } + if ( + !result.rom || + result.verification.matched || + !handler?.applyHeaderOverrides || + Object.keys(headerOverrides).length === 0 + ) { + return result; + } + const data = handler.applyHeaderOverrides(result.rom.data, headerOverrides); + return { + ...result, + rom: { + ...result.rom, + data, + meta: { ...result.rom.meta, ...(handler.headerMeta?.(data) ?? {}) }, + }, + }; + }, [baseResult, handler, headerOverrides, useTrimmed]); + + const romData = editedResult?.rom?.data; + const dumpSummary = useMemo( + () => (romData ? (handler?.summarizeDump?.(romData) ?? null) : null), + [handler, romData], + ); + const headerTitle = useMemo( + () => (romData ? handler?.headerTitle?.(romData) : undefined), + [handler, romData], + ); + const headerFields = useMemo(() => { + if (!baseResult?.rom || baseResult.verification.matched) return null; + const fields = handler?.getHeaderFields?.( + baseResult.rom.data, + headerOverrides, + ); + return fields && fields.length > 0 ? fields : null; + }, [baseResult, handler, headerOverrides]); + + const heading = ( +
+ + {dump.title || dump.name} + + {dump.label} +
+ ); + + // Pre-completion states render a compact row, not a full card. + if (dump.state !== "done" || !editedResult || !handler || !baseResult) { + return ( +
+ {heading} + {dump.state === "pending" && ( + Queued… + )} + {dump.state === "dumping" && ( + + + Reading… + {dump.progress + ? ` ${Math.round(dump.progress.fraction * 100)}%` + : ""} + + )} + {dump.state === "unsupported" && ( + + + The file is on the volume, but nabu has no {dump.label} validator — + it can't check or name this dump. + + )} + {dump.state === "error" && ( + + + {dump.error ?? "Dump failed."} + + )} +
+ ); + } + + return ( +
+ {heading} + + setHeaderOverrides((prev) => ({ ...prev, [key]: value })) + } + overdump={ + baseResult.trimSuggestion + ? { + fullSize: baseResult.hashes.size, + trimmedSize: baseResult.trimSuggestion.size, + note: baseResult.trimSuggestion.note, + verifiedWhenTrimmed: + baseResult.trimSuggestion.verification.matched, + trimmed: useTrimmed, + onToggle: setUseTrimmed, + } + : undefined + } + log={log} + hideContinuationHint + /> +
+ ); +} diff --git a/src/components/wizard/retrode2-panel.tsx b/src/components/wizard/retrode2-panel.tsx new file mode 100644 index 0000000..1fe2fdb --- /dev/null +++ b/src/components/wizard/retrode2-panel.tsx @@ -0,0 +1,512 @@ +import { useMemo, useState } from "react"; +import { + ArrowLeft, + ChevronDown, + ChevronUp, + Download, + RotateCcw, + Settings, + TriangleAlert, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { ConfigField } from "@/components/shared/config-field"; +import { saveFile } from "@/lib/core/file-save"; +import { RetrodeRomCard } from "@/components/wizard/retrode-rom-card"; +import type { ConfigValues, DeviceInfo, ResolvedConfigField } from "@/lib/types"; +import type { Retrode2Driver } from "@/lib/drivers/retrode2/retrode2-driver"; +import type { RetrodeDump } from "@/hooks/use-retrode2"; +import { + buildConfigFields, + defaultRetrodeCfg, + parseRetrodeCfg, + RETRODE2_CONFIG_FILENAME, + serializeRetrodeCfg, + type RetrodeCfg, +} from "@/lib/drivers/retrode2/retrode2-config"; +import { firmwareStatus } from "@/lib/drivers/retrode2/retrode2-firmware"; + +interface Retrode2PanelProps { + driver: Retrode2Driver; + deviceInfo: DeviceInfo | null; + /** One entry per ROM on the volume, auto-loaded on connect + on change. */ + dumps: RetrodeDump[]; + /** True while (re)scanning + dumping the volume. */ + scanning: boolean; + /** ROM files beyond the auto-load cap (>0 ⇒ likely the wrong folder). */ + hiddenCount: number; + /** Force a re-scan + re-dump now. */ + onReload: () => void; + onDisconnect: () => void; + log: (message: string, level?: "info" | "warn" | "error") => void; +} + +/** + * Screen for a mounted Retrode 2. The Retrode synthesizes ROM/save files onto a + * FAT volume; on connect (and whenever the volume changes) every ROM is + * auto-loaded — no Start button. One screen: the device's RETRODE.CFG settings + * on top (read-only; the volume is read-only through the browser, so edits are + * DOWNLOADED on the Configure screen to copy onto the device), then a result + * card per cartridge underneath. + */ +export function Retrode2Panel({ + driver, + deviceInfo, + dumps, + scanning, + hiddenCount, + onReload, + onDisconnect, + log, +}: Retrode2PanelProps) { + const [view, setView] = useState<"main" | "config">("main"); + + const fwStatus = deviceInfo + ? firmwareStatus(deviceInfo.firmwareVersion) + : null; + + return ( +
+
+ + {deviceInfo?.deviceName ?? "Retrode 2"} + {deviceInfo?.firmwareVersion && + deviceInfo.firmwareVersion !== "unknown" && ( + + firmware {deviceInfo.firmwareVersion} + + )} + + +
+ + {fwStatus?.outdatedNote && ( +
+ + {fwStatus.outdatedNote} +
+ )} + + {view === "config" ? ( + setView("main")} + /> + ) : ( + <> + setView("config")} /> + + + )} +
+ ); +} + +/** + * Group a parsed config's GUI fields by section, in file order within each + * group — shared by the read-only landing summary and the editable Configure + * screen so both show the same fields in the same order. + * + * Note: per-option firmware history (which version introduced/renamed each key) + * lives in `retrode2-firmware.ts` (`firmwareForOption`) and is intentionally NOT + * surfaced here — it's developer trivia, not something a user editing their + * config needs. The functional help text comes from the field descriptors. + */ +function groupConfigFields( + cfg: RetrodeCfg | null, +): [string, ResolvedConfigField[]][] { + if (!cfg) return []; + const map = new Map(); + for (const f of buildConfigFields(cfg)) { + const g = f.group ?? "Other"; + (map.get(g) ?? map.set(g, []).get(g)!).push(f); + } + for (const list of map.values()) + list.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + return [...map.entries()]; +} + +/** A config value rendered for the read-only summary (option label, On/Off, …). */ +function displayConfigValue(f: ResolvedConfigField): string { + if (f.type === "checkbox") return f.value ? "On" : "Off"; + if (f.type === "select" && f.options) { + return ( + f.options.find((o) => String(o.value) === String(f.value))?.label ?? + String(f.value) + ); + } + const s = String(f.value ?? ""); + return s === "" ? "—" : s; +} + +/** One label → value row in the read-only settings summary. `alert` flags a + * value that could interfere with a clean dump (a non-auto override). */ +function SettingRow({ + field, + alert, +}: { + field: ResolvedConfigField; + alert?: boolean; +}) { + return ( +
+
+ {field.label} +
+
+ {alert && } + {displayConfigValue(field)} +
+
+ ); +} + +/** RETRODE.CFG keys that can interfere with a clean dump — shown even when the + * settings summary is collapsed, and pulled to the top (in this order) when + * expanded. Forced detection/size/mapping override auto-detection; SNES + * overdump correction rewrites the dump on the device; Genesis SRAM width must + * match the cart. */ +const DUMP_AFFECTING_KEYS = [ + "forcesystem", + "forcesize", + "forcemapper", + "snesoverdump", + "segasram16bit", +]; + +/** The "won't interfere" value for a dump-affecting key (its auto/off default): + * a value differing from this is highlighted. Keys absent here (e.g. + * segaSram16bit, which has no neutral — the width must match the cart) are + * shown in the dump group but never flagged. */ +const NEUTRAL_VALUE: Record = { + forcesystem: "auto", + forcesize: 0, + forcemapper: 0, + snesoverdump: false, +}; + +function isInterfering(f: ResolvedConfigField): boolean { + const neutral = NEUTRAL_VALUE[f.key.toLowerCase()]; + if (neutral === undefined) return false; + if (typeof neutral === "string") + return String(f.value).toLowerCase() !== neutral; + if (typeof neutral === "boolean") return Boolean(f.value) !== neutral; + return Number(f.value) !== neutral; +} + +function DeviceSettings({ + driver, + onEditConfig, +}: { + driver: Retrode2Driver; + onEditConfig: () => void; +}) { + const hasConfig = driver.config !== null; + const [expanded, setExpanded] = useState(false); + + // The current RETRODE.CFG settings, grouped, for a compact READ-ONLY summary. + // Editing lives behind "Configure…" (a download, since the volume is + // read-only through the browser). + const settingGroups = useMemo( + () => groupConfigFields(driver.config), + [driver.config], + ); + + // Split the dump-affecting overrides out from the rest: collapsed view shows + // only these (in DUMP_AFFECTING_KEYS order); expanded view shows them first + // (under "Dump settings") then the remaining grouped settings. + const { dumpFields, otherGroups } = useMemo(() => { + const all = settingGroups.flatMap(([, fields]) => fields); + const dumpFields = DUMP_AFFECTING_KEYS.map((k) => + all.find((f) => f.key.toLowerCase() === k), + ).filter((f): f is ResolvedConfigField => f !== undefined); + const otherGroups = settingGroups + .map( + ([g, fields]) => + [ + g, + fields.filter( + (f) => !DUMP_AFFECTING_KEYS.includes(f.key.toLowerCase()), + ), + ] as [string, ResolvedConfigField[]], + ) + .filter(([, fields]) => fields.length > 0); + return { dumpFields, otherGroups }; + }, [settingGroups]); + + return ( + <> + {/* Current device settings — read-only; edit on the Configure screen */} + + + + + Device settings + + + + + + {hasConfig ? ( +
+ {/* Dump-affecting overrides: always visible, top of the list. */} + {dumpFields.length > 0 ? ( +
+ {expanded && ( +

+ Dump settings +

+ )} +
+ {dumpFields.map((f) => ( + + ))} +
+
+ ) : ( + !expanded && ( + + No dump-affecting overrides on this firmware — expand to see + all settings. + + ) + )} + + {/* Everything else, only when expanded. */} + {expanded && + otherGroups.map(([group, fields]) => ( +
+

+ {group} +

+
+ {fields.map((f) => ( + + ))} +
+
+ ))} + + +
+ ) : ( + + No RETRODE.CFG found on the volume. + + )} +
+
+ + ); +} + +/** The dumped ROM(s) on the volume — one card each, auto-loaded. */ +function RomResults({ + dumps, + scanning, + hiddenCount, + onReload, + deviceInfo, + log, +}: { + dumps: RetrodeDump[]; + scanning: boolean; + hiddenCount: number; + onReload: () => void; + deviceInfo: DeviceInfo | null; + log: (message: string, level?: "info" | "warn" | "error") => void; +}) { + return ( +
+
+ + Cartridges + {scanning && ( + + )} + + +
+ + {hiddenCount > 0 && ( +
+ + + Found {dumps.length + hiddenCount} ROM files — more than a Retrode + holds. Showing the first {dumps.length}. If this isn't your + Retrode's drive, Disconnect and pick the right folder. + +
+ )} + + {dumps.length === 0 ? ( + + {scanning + ? "Scanning the volume…" + : "No cartridge files found on the volume."} + + ) : ( + dumps.map((d) => ( + // Key by name + load generation: a re-dump (same filename, new bytes) + // remounts the card so stale trim/header edits can't carry over. + + )) + )} + +

+ Swap a cartridge — or copy a new RETRODE.CFG onto the volume — and the + list reloads automatically. +

+
+ ); +} + +function ConfigEditor({ + driver, + log, + onBack, +}: { + driver: Retrode2Driver; + log: (message: string, level?: "info" | "warn" | "error") => void; + onBack: () => void; +}) { + const cfg = driver.config; + const [values, setValues] = useState(() => ({ + ...(cfg?.values ?? {}), + })); + const [saving, setSaving] = useState(false); + + // Field structure (stable); each field's displayed value comes from `values`. + const groups = useMemo(() => groupConfigFields(cfg), [cfg]); + + async function download() { + if (!cfg) return; + setSaving(true); + try { + const text = serializeRetrodeCfg(cfg, values); + await saveFile( + new TextEncoder().encode(text), + RETRODE2_CONFIG_FILENAME, + [".cfg"], + ); + log( + "Saved RETRODE.CFG — copy it onto the Retrode volume, then eject and " + + "re-mount the device for the change to take effect.", + ); + onBack(); + } catch (e) { + log(`Couldn't save RETRODE.CFG: ${(e as Error).message}`, "error"); + } finally { + setSaving(false); + } + } + + return ( + + + + + RETRODE.CFG + + + + + +

+ The Retrode volume is read-only through the browser, so changes are + downloaded as a RETRODE.CFG file. Copy it onto the device (replacing + the existing one), then eject and re-mount for the device to apply it. +

+ + {groups.map(([group, fields]) => ( +
+

+ {group} +

+ {fields.map((field) => ( + + setValues((prev) => ({ ...prev, [field.key]: v })) + } + /> + ))} +
+ ))} + +
+ + +
+
+
+ ); +} diff --git a/src/hooks/use-connection.ts b/src/hooks/use-connection.ts index 1fe10a3..aac713c 100644 --- a/src/hooks/use-connection.ts +++ b/src/hooks/use-connection.ts @@ -105,6 +105,7 @@ const TRANSPORT_LABEL: Record = { serial: "serial port", webhid: "HID device", webusb: "USB device", + directory: "folder", }; /** localStorage key for the last successfully-connected device id. */ @@ -390,6 +391,7 @@ export function useConnection({ serial: !!navigator.serial, webusb: !!navigator.usb, webhid: !!navigator.hid, + directory: "showDirectoryPicker" in window, }; if (!apiAvailable[dev.transport]) { setConnectError( @@ -403,6 +405,7 @@ export function useConnection({ } catch (e) { const msg = (e as Error).message; if ( + (e as Error).name === "AbortError" || msg.includes("No port selected") || msg.includes("No device selected") ) { diff --git a/src/hooks/use-nointro.ts b/src/hooks/use-nointro.ts index 449e2a6..dfd71e1 100644 --- a/src/hooks/use-nointro.ts +++ b/src/hooks/use-nointro.ts @@ -5,6 +5,7 @@ import { saveDat, loadAllDats, buildVerificationDb, + combineVerificationDbs, matchesSystemName, NOINTRO_SYSTEM_NAMES, } from "@/lib/core/nointro"; @@ -71,11 +72,27 @@ export function useNoIntro() { [dbs], ); + // A virtual DB spanning EVERY loaded DAT, for the "match against anything" + // flow (the Retrode, whose system guess is unreliable). Null when none loaded. + const getMultiDb = useCallback( + (): VerificationDB | null => combineVerificationDbs([...dbs.values()]), + [dbs], + ); + const systemNames = [...dbs.keys()]; const totalEntries = [...dbs.values()].reduce( (sum, db) => sum + db.entryCount, 0, ); - return { dbs, systemNames, totalEntries, loading, error, importDat, getDb }; + return { + dbs, + systemNames, + totalEntries, + loading, + error, + importDat, + getDb, + getMultiDb, + }; } diff --git a/src/hooks/use-retrode2.test.ts b/src/hooks/use-retrode2.test.ts new file mode 100644 index 0000000..e1e403f --- /dev/null +++ b/src/hooks/use-retrode2.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { isVolumeGone } from "./use-retrode2"; + +/** + * isVolumeGone decides whether a failed volume read means the device is gone + * (reset/unplugged → return to the connect screen) vs a transient read the poll + * should just retry. It must match both the raw DOMException names and the + * friendly messages DirectoryTransport wraps them in. + */ +describe("isVolumeGone", () => { + const domEx = (name: string) => { + const e = new Error(name); + (e as { name: string }).name = name; + return e; + }; + + it("treats a removed-volume / permission-lost DOMException as gone", () => { + expect(isVolumeGone(domEx("NotFoundError"))).toBe(true); + expect(isVolumeGone(domEx("NotAllowedError"))).toBe(true); + }); + + it("treats the transport's wrapped messages as gone", () => { + expect(isVolumeGone(new Error("No directory mounted"))).toBe(true); + expect( + isVolumeGone( + new Error('"RETRODE.CFG" is no longer on the device — removed?'), + ), + ).toBe(true); + expect( + isVolumeGone(new Error('Access to "x" was denied — re-mount.')), + ).toBe(true); + }); + + it("does NOT treat an unrelated read error as gone (poll retries it)", () => { + expect(isVolumeGone(new Error("Failed to read RETRODE.CFG"))).toBe(false); + expect(isVolumeGone(domEx("AbortError"))).toBe(false); + expect(isVolumeGone(undefined)).toBe(false); + }); +}); diff --git a/src/hooks/use-retrode2.ts b/src/hooks/use-retrode2.ts new file mode 100644 index 0000000..0f64b01 --- /dev/null +++ b/src/hooks/use-retrode2.ts @@ -0,0 +1,321 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { DumpJobImpl } from "@/lib/core/dump-job"; +import type { + CartridgeInfo, + ConfigValues, + DumpProgress, + DumpResult, + SystemHandler, + SystemId, + VerificationDB, +} from "@/lib/types"; +import type { Retrode2Driver } from "@/lib/drivers/retrode2/retrode2-driver"; + +/** Poll cadence for volume-change detection. The File System Access API has no + * directory-change event, so we re-list and compare a signature; 2s keeps the + * cost trivial on a mass-storage volume while feeling responsive to a swap. */ +const POLL_MS = 2000; + +/** A Retrode 2 exposes at most a couple of cartridges (SNES slot + Genesis + * slot). The picker can't be restricted to a drive, so a user can point us at + * an arbitrary folder full of ROMs; cap the auto-load well above the real + * device's ceiling so a wrong folder degrades to a warning instead of dumping + * (and rendering) hundreds of files. */ +const MAX_AUTOLOAD_ROMS = 4; + +/** One cartridge's auto-load: its identity, progress, and finished dump. */ +export interface RetrodeDump { + /** ROM filename on the volume — the per-ROM dump target. */ + name: string; + /** + * Monotonic load generation, bumped each full (re)load. Combined with `name` + * it forms the card's React key, so a re-dump of a same-named ROM REMOUNTS + * the card — discarding any stale per-ROM edit state (trim toggle / header + * overrides) that must not carry over to freshly re-read bytes. + */ + loadId: number; + systemId: SystemId; + /** System display label (e.g. "Super Nintendo"). */ + label: string; + /** Display title derived from the filename (display only — never a stem). */ + title: string; + supported: boolean; + state: "pending" | "dumping" | "done" | "error" | "unsupported"; + progress?: DumpProgress; + result?: DumpResult; + cartInfo?: CartridgeInfo | null; + handler?: SystemHandler; + error?: string; +} + +interface UseRetrode2Options { + /** The connected Retrode 2 driver, or null when another device (or none). */ + driver: Retrode2Driver | null; + /** All system handlers, to match each ROM's systemId to its validator. */ + systems: SystemHandler[]; + /** No-Intro DB for a system (null when no DAT is loaded for it). Used only to + * decide the no-match tone (neutral when the detected system's DAT is + * absent), not for the match itself — see `getMultiDb`. */ + getDb: (systemId: SystemId) => VerificationDB | null; + /** A virtual DB spanning EVERY loaded DAT. The Retrode verifies against this + * ("does the file match any known dump?") rather than gating on its own + * unreliable system guess. Null when no DAT is loaded. */ + getMultiDb: () => VerificationDB | null; + /** Build the dump config for a ROM from its detected CartridgeInfo. */ + buildConfig: (handler: SystemHandler, cartInfo: CartridgeInfo | null) => ConfigValues; + log: (message: string, level?: "info" | "warn" | "error") => void; + /** Tear down the connection (back to the connect screen). Called when the + * volume becomes unreachable — the device was reset or unplugged, which + * invalidates the directory handle. */ + onDisconnect: () => void; +} + +/** + * Whether an error means the mounted volume is gone — the device was reset or + * unplugged, so its directory handle is now invalid. Distinct from a transient + * read failure while the device regenerates a file (which the poll just + * retries). Matches the raw DOMException names and the friendly messages the + * DirectoryTransport wraps them in. + */ +export function isVolumeGone(e: unknown): boolean { + const name = (e as DOMException)?.name; + if (name === "NotFoundError" || name === "NotAllowedError") return true; + const msg = (e as Error)?.message ?? ""; + return ( + /No directory mounted/i.test(msg) || + /no longer on the device/i.test(msg) || + /was denied/i.test(msg) + ); +} + +export interface UseRetrode2 { + /** One entry per ROM on the volume (capped at {@link MAX_AUTOLOAD_ROMS}). */ + dumps: RetrodeDump[]; + /** True while (re)scanning + dumping the volume. */ + scanning: boolean; + /** ROM files found beyond the cap (0 normally). >0 strongly implies the user + * picked the wrong folder — a Retrode never holds this many carts. */ + hiddenCount: number; + /** Force a re-scan + re-dump now (also happens automatically on change). */ + onReload: () => void; +} + +/** + * Owns the Retrode 2's auto-load lifecycle: on connect (and whenever the volume + * changes) it scans every ROM the device synthesized and dumps each one — + * ROM + save — through the shared dump pipeline, with no Start button. The + * Retrode is READ-ONLY through the browser, so config edits happen on a + * separate Configure screen that DOWNLOADS a RETRODE.CFG to copy onto the + * device; copying it (the device regenerates its files) is one of the changes + * the poll picks up to reload. + */ +export function useRetrode2({ + driver, + systems, + getDb, + getMultiDb, + buildConfig, + log, + onDisconnect, +}: UseRetrode2Options): UseRetrode2 { + const [dumps, setDumps] = useState([]); + const [scanning, setScanning] = useState(false); + const [hiddenCount, setHiddenCount] = useState(0); + const [reloadKey, setReloadKey] = useState(0); + const onReload = useCallback(() => setReloadKey((k) => k + 1), []); + + // Latest dependencies, read through a ref so the load effect depends only on + // the driver + an explicit reload — not on the identity of these callbacks, + // which would otherwise re-run the effect (and re-dump) on every render. Read + // PER ROM at the point of use (not snapshotted once) so a DAT imported mid- + // load is consulted for the ROMs still to come. + const depsRef = useRef({ + systems, + getDb, + getMultiDb, + buildConfig, + log, + onDisconnect, + }); + depsRef.current = { + systems, + getDb, + getMultiDb, + buildConfig, + log, + onDisconnect, + }; + + // Monotonic across the hook's lifetime (survives effect re-runs), so each + // (re)load gives every ROM a fresh card key → the card remounts and its + // edit state can't outlive the bytes it was made for. + const loadIdRef = useRef(0); + + useEffect(() => { + if (!driver) { + setDumps([]); + setScanning(false); + setHiddenCount(0); + return; + } + let cancelled = false; + let loading = false; + let lastSig = ""; + // Abort the in-flight dump on teardown so a read can't keep running (and + // logging) against a torn-down/closed transport. The driver reads aren't + // mid-stream interruptible, but the pipeline stops at the next phase + // boundary and reports a clean abort instead of a spurious error. + const abort = new AbortController(); + + const patch = (name: string, p: Partial) => { + if (!cancelled) + setDumps((prev) => + prev.map((d) => (d.name === name ? { ...d, ...p } : d)), + ); + }; + + // If the volume vanished mid-load (device reset/unplugged), don't surface a + // cryptic file error on a now-dead handle — log it plainly and return to the + // connect screen. Returns true when it handled a gone-volume error. + const handledVolumeGone = (e: unknown): boolean => { + if (cancelled || !isVolumeGone(e)) return false; + depsRef.current.log( + "The Retrode is no longer reachable — it was reset or unplugged. " + + "Reconnect to dump again.", + "warn", + ); + depsRef.current.onDisconnect(); + return true; + }; + + const loadAll = async () => { + if (cancelled || loading) return; + loading = true; + setScanning(true); + const loadId = ++loadIdRef.current; + try { + const found = await driver.scanRoms(); + if (cancelled) return; + lastSig = await driver.volumeSignature(); + if (cancelled) return; + // Cap the auto-load: a Retrode exposes only a couple of carts, so a + // larger set means the user picked the wrong folder. Show + dump the + // first few and warn, rather than flooding the UI / dumping hundreds. + const roms = found.slice(0, MAX_AUTOLOAD_ROMS); + const hidden = found.length - roms.length; + setHiddenCount(hidden); + if (hidden > 0) { + depsRef.current.log( + `Found ${found.length} ROM files on this volume — far more than a ` + + `Retrode holds (a couple of cartridges). This looks like the wrong ` + + `folder; showing the first ${roms.length}. Disconnect and pick the ` + + `Retrode's drive.`, + "warn", + ); + } + setDumps( + roms.map((r) => ({ + name: r.name, + loadId, + systemId: r.systemId, + label: r.label, + title: r.title, + supported: r.supported, + state: r.supported ? "pending" : "unsupported", + })), + ); + for (const r of roms) { + if (cancelled) return; + if (!r.supported) continue; + // Read deps fresh per ROM so a DAT/config change lands on the rest. + const { systems, getDb, getMultiDb, buildConfig, log } = + depsRef.current; + const handler = systems.find((s) => s.systemId === r.systemId); + if (!handler) { + patch(r.name, { state: "unsupported" }); + continue; + } + patch(r.name, { state: "dumping", handler }); + try { + driver.selectRom(r.name); + const cartInfo = await driver.detectCartridge(r.systemId); + if (cancelled) return; + // Verify against EVERY loaded DAT, not just the device's (unreliable) + // system guess — a Retrode dump is "verified" if it matches any + // known dump. CRC+size collisions across systems don't happen, so + // there are no false positives. + const job = new DumpJobImpl(driver, handler, getMultiDb()); + job.on("onProgress", (pr) => patch(r.name, { progress: pr })); + job.on("onLog", (m, l) => { + if (!cancelled) log(m, l); + }); + let result = await job.run( + buildConfig(handler, cartInfo), + abort.signal, + ); + if (cancelled) return; + // Searching every DAT means `databaseLoaded` (any-DAT-loaded) + // over-reports for a no-match. Re-base it on the DETECTED system's + // DAT so "no DAT for this system" stays the neutral "not checked" + // state rather than an alarming "unverified" — and drop the no-match + // suggestions in that neutral case (they imply we checked the right + // DB, which we couldn't). + if (!result.verification.matched) { + const ownDbLoaded = getDb(r.systemId) != null; + result = { + ...result, + verification: { + ...result.verification, + databaseLoaded: ownDbLoaded, + suggestions: ownDbLoaded + ? result.verification.suggestions + : undefined, + }, + }; + } + patch(r.name, { state: "done", result, cartInfo }); + } catch (e) { + if (cancelled) return; + // A gone volume mid-dump dooms the rest too — bail to the connect + // screen instead of marking every remaining ROM "error". + if (handledVolumeGone(e)) return; + patch(r.name, { state: "error", error: (e as Error).message }); + } + } + } catch (e) { + if (cancelled || handledVolumeGone(e)) return; + depsRef.current.log( + `Volume scan failed: ${(e as Error).message}`, + "error", + ); + } finally { + if (!cancelled) setScanning(false); + loading = false; + } + }; + + void loadAll(); + + // Poll for volume changes (cart swap, or the device regenerating files + // after a config copy) — re-list and re-dump only when the signature moves. + const id = window.setInterval(() => { + if (cancelled || loading) return; + driver + .volumeSignature() + .then((sig) => { + if (!cancelled && !loading && sig !== lastSig) void loadAll(); + }) + .catch(() => { + /* transient FS read while a file regenerates — retry next tick */ + }); + }, POLL_MS); + + return () => { + cancelled = true; + abort.abort(); + window.clearInterval(id); + }; + }, [driver, reloadKey]); + + return { dumps, scanning, hiddenCount, onReload }; +} diff --git a/src/lib/core/connection-registry.ts b/src/lib/core/connection-registry.ts index 56cc659..99724e9 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 { DirectoryTransport } from "@/lib/transport/directory-transport"; +import { Retrode2Driver } from "@/lib/drivers/retrode2/retrode2-driver"; import type { DeviceDriver, DeviceIdentity, @@ -127,4 +129,12 @@ export const CONNECTION_ENTRIES: Record = { : (t as UsbTransport).connect(), createDriver: (t) => new SMS4Driver(t as UsbTransport), }, + + RETRODE2: { + createTransport: () => new DirectoryTransport(), + connect: (t) => (t as DirectoryTransport).connect(), + createDriver: (t) => new Retrode2Driver(t as DirectoryTransport), + postInitLog: (info) => + `Mounted: ${info.deviceName} (firmware ${info.firmwareVersion})`, + }, }; diff --git a/src/lib/core/devices.ts b/src/lib/core/devices.ts index b5b2f45..7868ead 100644 --- a/src/lib/core/devices.ts +++ b/src/lib/core/devices.ts @@ -120,4 +120,24 @@ export const DEVICES: Record = { "Discontinued Neoflash NDS slot-1 cartridge adapter. Backs up DS " + "cartridge save data via the cart's SPI passthrough.", }, + RETRODE2: { + id: "RETRODE2", + name: "Retrode 2", + vendorId: null, + productId: null, + transport: "directory", + systems: [ + { id: "snes", name: "Super Nintendo" }, + { id: "genesis", name: "Genesis / Mega Drive" }, + { id: "n64", name: "Nintendo 64" }, + { id: "gb", name: "Game Boy / Color" }, + { id: "gba", name: "Game Boy Advance" }, + { id: "sms", name: "Master System / Game Gear" }, + ], + homepage: "https://www.retrode.com/", + description: + "Matthias Hullin's Retrode 2 cartridge reader. It appears as a USB " + + "drive; nabu reads the ROM and save it writes there, validates them, " + + "and trims over-dumps. Pick the mounted Retrode folder to begin.", + }, }; diff --git a/src/lib/core/dump-job.test.ts b/src/lib/core/dump-job.test.ts index 2d2fbb8..9b7f8fd 100644 --- a/src/lib/core/dump-job.test.ts +++ b/src/lib/core/dump-job.test.ts @@ -3,6 +3,7 @@ import { DumpJobImpl } from "./dump-job"; import type { DeviceDriver, SystemHandler, + VerificationDB, VerificationHashes, } from "@/lib/types"; @@ -79,3 +80,26 @@ describe("DumpJob uniform-fill save warning", () => { expect(warnings).toEqual([]); }); }); + +describe("DumpJob verification.databaseLoaded", () => { + const db: VerificationDB = { + systemId: "nes", + source: "test", + entryCount: 0, + lookup: () => null, // present, but never matches + }; + + it("is false when no verification database was passed", async () => { + const job = new DumpJobImpl(makeDriver(new Uint8Array(0)), system, null); + const result = await job.run({}); + expect(result.verification.matched).toBe(false); + expect(result.verification.databaseLoaded).toBe(false); + }); + + it("is true when a database was consulted (even on a no-match)", async () => { + const job = new DumpJobImpl(makeDriver(new Uint8Array(0)), system, db); + const result = await job.run({}); + expect(result.verification.matched).toBe(false); + expect(result.verification.databaseLoaded).toBe(true); + }); +}); diff --git a/src/lib/core/dump-job.ts b/src/lib/core/dump-job.ts index 847b74e..b196b05 100644 --- a/src/lib/core/dump-job.ts +++ b/src/lib/core/dump-job.ts @@ -8,6 +8,7 @@ import type { ConfigValues, VerificationDB, } from "@/lib/types"; +import { correctOverdump } from "@/lib/core/overdump"; export class DumpJobImpl { state: DumpJobState = "idle"; @@ -96,29 +97,44 @@ export class DumpJobImpl { signal?.throwIfAborted(); - // Hash, verify, then build output (verify may trim the ROM) + // Hash + verify, with device-agnostic No-Intro over-dump correction: if + // the full dump doesn't match a loaded DAT but a smaller geometric prefix + // does, adopt the trimmed size. A no-op when no DAT is loaded or the full + // dump already matches, so it's safe for every device. this.setState("hashing"); - const hashes = await this.system.computeHashes(rawData); + const corrected = await correctOverdump( + rawData, + this.system, + this.verificationDb, + ); + const content = corrected.content; + const hashes = corrected.hashes; this.log(`CRC32: ${hashes.crc32.toString(16).toUpperCase().padStart(8, "0")} SHA-1: ${hashes.sha1}`); + if (corrected.trimmedFrom != null) { + this.log( + `Over-dump corrected to a No-Intro match: ${corrected.trimmedFrom} → ${content.length} bytes`, + ); + } this.setState("verifying"); - const verification = await this.system.verify( - hashes, - this.verificationDb, - rawData, - ); + // Stamp whether a DB was actually consulted, so the UI can tell "no DAT + // loaded for this system" (neutral) apart from a genuine no-match + // (unverified). Device-agnostic: every system's verify() ran against the + // same this.verificationDb, so it's the single source of truth. + const databaseLoaded = this.verificationDb != null; + const verification = { ...corrected.verification, databaseLoaded }; if (verification.matched && verification.entry) { this.log(`Verified: ${verification.entry.name}`); } - // Device- and mapper-agnostic heuristics over the raw bytes (e.g. + // Device- and mapper-agnostic heuristics over the (corrected) bytes (e.g. // PRG banks identical to bank 0). Informational only — surface them // in the event log; never fail the dump. - const notes = this.system.analyzeDump?.(rawData, readConfig) ?? []; + const notes = this.system.analyzeDump?.(content, readConfig) ?? []; for (const note of notes) this.log(note, "warn"); const outputFile = this.system.buildOutputFile( - rawData, + content, readConfig, verification, ); @@ -128,11 +144,40 @@ export class DumpJobImpl { for (const warning of outputFile.warnings ?? []) this.log(warning, "warn"); + // Unverified over-dump? Offer a heuristic trim on the completion screen — + // never applied silently. Pre-compute the trimmed variant so the UI + // toggle is instant. (Authoritative No-Intro trimming already ran above.) + let trimSuggestion: DumpResult["trimSuggestion"]; + if (!verification.matched) { + const suggestion = this.system.suggestTrim?.(content); + if (suggestion && suggestion.size < content.length) { + const trimmed = content.subarray(0, suggestion.size); + const tHashes = await this.system.computeHashes(trimmed); + const tVerification = { + ...(await this.system.verify(tHashes, this.verificationDb, trimmed)), + databaseLoaded, + }; + trimSuggestion = { + size: suggestion.size, + note: suggestion.note, + rom: this.system.buildOutputFile(trimmed, readConfig, tVerification), + hashes: tHashes, + verification: tVerification, + }; + this.log( + `Possible over-dump: ${content.length} → ${suggestion.size} bytes ` + + `(${suggestion.note}) — choose whether to trim on the completion screen.`, + "warn", + ); + } + } + const result: DumpResult = { rom: outputFile, save: saveFile, hashes, verification, + trimSuggestion, durationMs: Date.now() - startTime, }; diff --git a/src/lib/core/nointro.test.ts b/src/lib/core/nointro.test.ts index f4cf2af..2846d2a 100644 --- a/src/lib/core/nointro.test.ts +++ b/src/lib/core/nointro.test.ts @@ -1,10 +1,17 @@ import { describe, it, expect } from "vitest"; import { NoIntroVerificationDB, + buildVerificationDb, + combineVerificationDbs, matchesSystemName, type NoIntroDat, type NoIntroEntry, } from "./nointro"; +import type { + VerificationDB, + VerificationEntry, + VerificationHashes, +} from "@/lib/types"; function entry(name: string, serial: string): NoIntroEntry { return { @@ -116,3 +123,64 @@ describe("matchesSystemName", () => { expect(matchesSystemName("Game Boy Color", "Game Boy")).toBe(false); }); }); + +function fakeDb(systemId: string, byCrc: Record): VerificationDB { + return { + systemId, + source: systemId, + entryCount: Object.keys(byCrc).length, + lookup(h: VerificationHashes): VerificationEntry | null { + const name = byCrc[h.crc32]; + return name ? { name, status: "verified" } : null; + }, + }; +} + +const hashes = (crc32: number): VerificationHashes => ({ + crc32, + sha1: "", + size: 0, +}); + +describe("combineVerificationDbs", () => { + it("returns null when there are no DBs to search", () => { + expect(combineVerificationDbs([])).toBeNull(); + }); + + it("matches an entry in ANY member DB (first hit wins)", () => { + const snes = fakeDb("snes", { 0x1111: "A SNES Game" }); + const gen = fakeDb("genesis", { 0x2222: "A Genesis Game" }); + const multi = combineVerificationDbs([snes, gen])!; + + // A dump labeled for one system still matches another system's DAT. + expect(multi.lookup(hashes(0x1111))?.name).toBe("A SNES Game"); + expect(multi.lookup(hashes(0x2222))?.name).toBe("A Genesis Game"); + // No hit anywhere → null (stays unverified). + expect(multi.lookup(hashes(0x9999))).toBeNull(); + // Aggregate metadata. + expect(multi.entryCount).toBe(2); + }); +}); + +describe("VerificationEntry carries the DAT rom filename", () => { + it("exposes romName (incl. canonical extension) on a lookup hit", () => { + const dat: NoIntroDat = { + systemName: "Sega - Mega Drive - Genesis", + version: "1", + entries: [ + { + gameName: "Some Game (USA)", + romName: "Some Game (USA).md", + size: 4, + crc32: "abcd1234", + sha1: "", + }, + ], + }; + const verifyDb = buildVerificationDb(dat, "genesis"); + const entry = verifyDb.lookup({ crc32: 0xabcd1234, sha1: "", size: 4 }); + expect(entry?.name).toBe("Some Game (USA)"); + // The DAT's own extension (.md) is preserved for canonical naming. + expect(entry?.romName).toBe("Some Game (USA).md"); + }); +}); diff --git a/src/lib/core/nointro.ts b/src/lib/core/nointro.ts index 2b530c9..7f5af8f 100644 --- a/src/lib/core/nointro.ts +++ b/src/lib/core/nointro.ts @@ -161,6 +161,7 @@ export class NoIntroVerificationDB implements VerificationDB { private toVerificationEntry(entry: NoIntroEntry): VerificationEntry { return { name: entry.gameName, + romName: entry.romName || undefined, status: entry.status === "verified" ? "verified" : "unknown", header: entry.header, sha1: entry.sha1, @@ -185,6 +186,12 @@ export const NOINTRO_SYSTEM_NAMES: Readonly> = gba: ["Nintendo - Game Boy Advance", "Game Boy Advance"], nes: ["Nintendo - Nintendo Entertainment System", "NES"], snes: ["Nintendo - Super Nintendo Entertainment System", "SNES"], + genesis: ["Sega - Mega Drive - Genesis", "Genesis"], + n64: ["Nintendo - Nintendo 64", "Nintendo 64"], + sms: ["Sega - Master System - Mark III", "Master System"], + gg: ["Sega - Game Gear", "Game Gear"], + vboy: ["Nintendo - Virtual Boy", "Virtual Boy"], + atari2600: ["Atari - 2600", "Atari 2600"], nds_save: ["Nintendo - Nintendo DS", "DS"], }; @@ -207,6 +214,34 @@ export function matchesSystemName( return datName === candidate || datName.startsWith(candidate + " ("); } +/** + * A virtual DB that searches several loaded DATs at once: a dump matches if + * it's a known entry in ANY of them. Used by the Retrode flow, where the + * device's system guess is unreliable — so we ask "is this any known dump?" + * rather than gating on one system's DAT. Lookups are by hash (+ size), so + * cross-system false positives don't occur (different games never share a + * CRC32/SHA-1). Returns null when there are no DBs to search. The first DB to + * hit wins; `lookupBySerial` is intentionally omitted (the Retrode matches by + * content hash, not serial). + */ +export function combineVerificationDbs( + dbs: VerificationDB[], +): VerificationDB | null { + if (dbs.length === 0) return null; + return { + systemId: "multi", + source: `${dbs.length} loaded DAT${dbs.length === 1 ? "" : "s"}`, + entryCount: dbs.reduce((sum, db) => sum + db.entryCount, 0), + lookup(hashes: VerificationHashes): VerificationEntry | null { + for (const db of dbs) { + const entry = db.lookup(hashes); + if (entry) return entry; + } + return null; + }, + }; +} + // ─── IndexedDB persistence ────────────────────────────────────────────── const DB_NAME = "nabu-nointro"; diff --git a/src/lib/core/overdump.test.ts b/src/lib/core/overdump.test.ts new file mode 100644 index 0000000..8157209 --- /dev/null +++ b/src/lib/core/overdump.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from "vitest"; +import { crc32, sha1Hex, sha256Hex } from "@/lib/core/hashing"; +import { correctOverdump, overdumpCandidates } from "./overdump"; +import type { + SystemHandler, + VerificationDB, + VerificationHashes, +} from "@/lib/types"; + +const KB = 1024; + +/** Minimal handler: real content hashes, verify = No-Intro DB lookup. */ +const handler: Pick = { + async computeHashes(data) { + const [sha1, sha256] = await Promise.all([sha1Hex(data), sha256Hex(data)]); + return { crc32: crc32(data), sha1, sha256, size: data.length }; + }, + verify(hashes, db) { + if (!db) return { matched: false, confidence: "none" }; + const entry = db.lookup(hashes); + return entry + ? { matched: true, entry, confidence: "exact" } + : { matched: false, confidence: "none" }; + }, +}; + +/** A DB that matches exactly one known SHA-1. */ +function dbMatching(sha1: string): VerificationDB { + return { + systemId: "test", + source: "test", + entryCount: 1, + lookup(h: VerificationHashes) { + return h.sha1 === sha1 + ? { name: "Real Game", status: "verified" as const } + : null; + }, + }; +} + +const data = (n: number, seed = 7): Uint8Array => { + const a = new Uint8Array(n); + for (let i = 0; i < n; i++) a[i] = (i ^ (i >>> 7) ^ (i >>> 13) ^ seed) & 0xff; + return a; +}; +function mirrorOverdump(real: Uint8Array, total: number): Uint8Array { + const out = new Uint8Array(total); + out.set(real, 0); + const t = total - real.length; + out.set(real.subarray(real.length - t, real.length), real.length); + return out; +} +function fillOverdump(real: Uint8Array, total: number, v: number): Uint8Array { + const out = new Uint8Array(total); + out.set(real, 0); + out.fill(v, real.length); + return out; +} +const bytesEqual = (a: Uint8Array, b: Uint8Array): boolean => + a.length === b.length && a.every((x, i) => x === b[i]); + +describe("overdumpCandidates", () => { + it("finds the 3-in-4 fractional mirror point", () => { + const over = mirrorOverdump(data(192 * KB), 256 * KB); + expect(overdumpCandidates(over)).toContain(192 * KB); + }); + it("finds a full power-of-two mirror", () => { + const over = mirrorOverdump(data(128 * KB), 256 * KB); + expect(overdumpCandidates(over)).toContain(128 * KB); + }); + it("finds a trailing-fill boundary", () => { + const over = fillOverdump(data(192 * KB), 256 * KB, 0xff); + expect(overdumpCandidates(over)).toContain(192 * KB); + }); + it("returns nothing for non-over-dumped data", () => { + expect(overdumpCandidates(data(256 * KB))).toEqual([]); + }); +}); + +describe("correctOverdump", () => { + it("trims a mirror over-dump to the No-Intro-matched size", async () => { + const real = data(192 * KB); + const over = mirrorOverdump(real, 256 * KB); + const r = await correctOverdump(over, handler, dbMatching(await sha1Hex(real))); + expect(r.content.length).toBe(192 * KB); + expect(bytesEqual(r.content, real)).toBe(true); + expect(r.trimmedFrom).toBe(256 * KB); + expect(r.verification.matched).toBe(true); + }); + + it("leaves a full-match dump untouched", async () => { + const real = data(192 * KB); + const r = await correctOverdump(real, handler, dbMatching(await sha1Hex(real))); + expect(r.content.length).toBe(192 * KB); + expect(r.trimmedFrom).toBeUndefined(); + expect(r.verification.matched).toBe(true); + }); + + it("does not trim when no DAT is loaded", async () => { + const over = mirrorOverdump(data(192 * KB), 256 * KB); + const r = await correctOverdump(over, handler, null); + expect(r.content.length).toBe(256 * KB); + expect(r.trimmedFrom).toBeUndefined(); + }); + + it("does not trim when nothing matches the DAT", async () => { + const over = mirrorOverdump(data(192 * KB), 256 * KB); + const r = await correctOverdump(over, handler, dbMatching("0".repeat(40))); + expect(r.content.length).toBe(256 * KB); + expect(r.trimmedFrom).toBeUndefined(); + }); + + it("adopts the handler's suggestTrim size when geometry misses it", async () => { + // 160 KB real size: the 96 KB tail is not a 1−1/2ⁿ fraction, so the + // geometric candidate walk can't reach it. Only suggestTrim knows the size. + const real = data(160 * KB); + const total = 256 * KB; + const over = new Uint8Array(total); + over.set(real, 0); + for (let i = real.length; i < total; i++) over[i] = (i * 31) & 0xff; // non-mirror, non-fill + expect(overdumpCandidates(over)).not.toContain(160 * KB); // geometry misses + + const withSuggest: Pick< + SystemHandler, + "computeHashes" | "verify" | "suggestTrim" + > = { + ...handler, + suggestTrim: (content) => + content.length > 160 * KB + ? { size: 160 * KB, note: "test ladder size" } + : null, + }; + const r = await correctOverdump(over, withSuggest, dbMatching(await sha1Hex(real))); + expect(r.content.length).toBe(160 * KB); + expect(r.trimmedFrom).toBe(total); + expect(r.verification.matched).toBe(true); + }); + + it("does NOT adopt the suggestTrim size when the DAT doesn't confirm it", async () => { + // suggestTrim proposes a size, but it must never bypass No-Intro: with a DB + // that matches nothing, the full dump is kept (the heuristic stays opt-in). + const real = data(160 * KB); + const total = 256 * KB; + const over = new Uint8Array(total); + over.set(real, 0); + for (let i = real.length; i < total; i++) over[i] = (i * 31) & 0xff; + const withSuggest: Pick< + SystemHandler, + "computeHashes" | "verify" | "suggestTrim" + > = { ...handler, suggestTrim: () => ({ size: 160 * KB, note: "t" }) }; + + const r = await correctOverdump(over, withSuggest, dbMatching("0".repeat(40))); + expect(r.content.length).toBe(total); + expect(r.trimmedFrom).toBeUndefined(); + }); +}); diff --git a/src/lib/core/overdump.ts b/src/lib/core/overdump.ts new file mode 100644 index 0000000..c1fa55f --- /dev/null +++ b/src/lib/core/overdump.ts @@ -0,0 +1,126 @@ +import type { + SystemHandler, + VerificationDB, + VerificationHashes, + VerificationResult, +} from "@/lib/types"; + +/** + * Device- and system-agnostic over-dump correction. + * + * Some readers (e.g. the Retrode) size a dump from the cartridge's header and + * round up to a power of two, producing a file larger than the real ROM whose + * tail is a mirror of earlier data or constant 0x00/0xFF fill. When a No-Intro + * DAT is loaded we don't need any per-system cleverness to fix this: hash the + * full dump, and if it doesn't match, hash progressively smaller geometric + * prefixes and adopt the first that DOES match. No-Intro is the oracle. + * + * This composes with (does not replace) per-device heuristic trimmers: those + * remain the best-effort path for unverified / homebrew / no-DAT dumps, where + * there's no authoritative size to confirm against. + */ + +/** Don't propose a trimmed size below this (smallest plausible cart). */ +const MIN_SIZE = 0x8000; // 32 KB +/** A trailing constant-fill run must be at least this long to count. */ +const MIN_FILL = 0x10000; // 64 KB + +/** The tail [L:end] is a byte-exact mirror of the equal-length block before L. */ +function isTailMirror(data: Uint8Array, L: number): boolean { + const t = data.length - L; + if (t <= 0 || t > L) return false; + for (let i = 0; i < t; i++) { + if (data[L + i] !== data[L - t + i]) return false; + } + return true; +} + +/** + * Plausible real-ROM sizes (< data.length) for an over-dump, derived purely + * from geometry: a trailing 0x00/0xFF fill boundary, and mirror points where + * the tail repeats the block immediately before it (covers both full + * power-of-two mirrors and the fractional 3-in-4 / 6-in-8 shapes). Descending + * so larger (closer-to-real) sizes are confirmed first. + */ +export function overdumpCandidates(data: Uint8Array): number[] { + const D = data.length; + if (D <= MIN_SIZE) return []; + const out = new Set(); + + // Trailing constant-fill run. + const last = data[D - 1]; + if (last === 0x00 || last === 0xff) { + let i = D - 1; + while (i >= 0 && data[i] === last) i--; + const real = i + 1; + if (D - real >= MIN_FILL && real >= MIN_SIZE) out.add(real); + } + + // Mirror points: peel a power-of-two tail that mirrors the block before it. + for (let tail = D >> 1; tail >= MIN_SIZE; tail >>= 1) { + const L = D - tail; + if (L >= MIN_SIZE && isTailMirror(data, L)) out.add(L); + } + + return [...out].sort((a, b) => b - a); +} + +export interface CorrectedDump { + /** The content to hash/verify/emit — a prefix of the input when corrected. */ + content: Uint8Array; + hashes: VerificationHashes; + verification: VerificationResult; + /** Original length when a smaller size was adopted (else undefined). */ + trimmedFrom?: number; +} + +/** + * Hash + verify `content`, applying No-Intro-confirmed over-dump correction. + * Returns the full dump's result unless a strictly smaller geometric prefix + * produces a No-Intro match, in which case that trimmed prefix is adopted. + * A no-op when no DAT is loaded or the full dump already matches. + */ +export async function correctOverdump( + content: Uint8Array, + handler: Pick, + db: VerificationDB | null, +): Promise { + const hashes = await handler.computeHashes(content); + const verification = await handler.verify(hashes, db, content); + if (verification.matched || !db) return { content, hashes, verification }; + + // Candidate sizes: the geometric set, PLUS the system's own heuristic trim + // size when it offers one. The geometric set only reaches `1 − 1/2ⁿ` tail + // fractions, so a non-power-of-two real size (e.g. a 10/20 Mbit cart) would + // otherwise be invisible to this authoritative layer and left to the opt-in + // heuristic — even with a DAT loaded. Feeding suggestTrim's size in lets the + // DAT confirm it directly. (suggestTrim sizes are also ladder-aligned, which + // sidesteps the byte-order-misalignment edge for normalized systems.) + const suggested = handler.suggestTrim?.(content)?.size; + const sizes = overdumpCandidates(content); + if ( + suggested !== undefined && + suggested >= MIN_SIZE && + suggested < content.length && + !sizes.includes(suggested) + ) { + sizes.push(suggested); + sizes.sort((a, b) => b - a); // keep descending: confirm larger sizes first + } + + for (const len of sizes) { + const cand = content.subarray(0, len); + const candHashes = await handler.computeHashes(cand); + const candVerification = await handler.verify(candHashes, db, cand); + if (candVerification.matched) { + return { + content: cand, + hashes: candHashes, + verification: candVerification, + trimmedFrom: content.length, + }; + } + } + + return { content, hashes, verification }; +} diff --git a/src/lib/drivers/retrode2/retrode2-config.test.ts b/src/lib/drivers/retrode2/retrode2-config.test.ts new file mode 100644 index 0000000..35ba0c9 --- /dev/null +++ b/src/lib/drivers/retrode2/retrode2-config.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from "vitest"; +import { + buildConfigFields, + parseRetrodeCfg, + serializeRetrodeCfg, +} from "./retrode2-config"; + +// The real firmware .25a RETRODE.CFG (config text — not a ROM). +const SAMPLE = `; Retrode .25a - Config +; Remove any line to revert setting to factory default + + +[HIDMode] 1 ; 0: Off; 1: 4Joy+Mouse; 2: 2Joy; 3: KB; 4: iCade +[blinkControllers] 1 + + +[nesMode] 0 ; 1: NES gamepads; 0: SNES + +[filenameChksum] 1 ; checksum in filename? 0=no, 1=yes +[detectionDelay] 5 ; how long to wait after cart insertion/removal +[saveReadonly] 0 ; write protect save? 0=no, 1=yes +[segaSram16bit] 1 ; 0=no, 1=yes, 2=y+large +[sramExt] srm +[snesRomExt] sfc +[snesOverdump] 0 ; overdump correction? 0=no, 1=yes +[snesRomVer] 0 ; version instead of checksum? 0=no, 1=yes +[segaRomExt] bin +; Override autodetect: +[forceSystem] auto +[forceSize] 0 +[forceMapper] 0 +; Optional plug-ins: +[n64RomExt] z64 +[gbRomExt] gb +[gbaRomExt] gba +[smsRomExt] sms +[ggRomExt] gg +`; + +describe("retrode2 config (curated, no inference)", () => { + it("renders a field for every known key in the file", () => { + const fields = buildConfigFields(parseRetrodeCfg(SAMPLE)); + // All 20 setting lines are curated keys. + expect(fields.length).toBe(20); + const byKey = Object.fromEntries(fields.map((f) => [f.key, f])); + + expect(byKey.HIDMode.type).toBe("select"); + expect(byKey.HIDMode.options).toHaveLength(5); + expect(byKey.HIDMode.value).toBe(1); + + expect(byKey.saveReadonly.type).toBe("checkbox"); + expect(byKey.saveReadonly.value).toBe(false); + + expect(byKey.nesMode.type).toBe("select"); + expect(byKey.snesRomExt.type).toBe("text"); + expect(byKey.snesRomExt.value).toBe("sfc"); + }); + + it("offers exactly the forceSystem tokens the v0.25a firmware accepts", () => { + const fields = buildConfigFields(parseRetrodeCfg(SAMPLE)); + const force = Object.fromEntries(fields.map((f) => [f.key, f])).forceSystem; + const values = (force.options ?? []).map((o) => o.value).sort(); + // The accepted set read out of the firmware token table (strings after + // "forceSystem invalid" in Retrode2-v0.25a.hex), plus the "auto" default. + // SMS and GG are DISTINCT; VBOY (Virtual Boy, added in fw 0.25) is present. + expect(values).toEqual( + [ + "A26", "GBA", "GBOY", "GG", "MDRV", "N64", "NGP", "NGP2", "SMS", "SNES", + "TG16", "VBOY", "auto", + ].sort(), + ); + }); + + it("round-trips parse → serialize → parse with identical values", () => { + const cfg = parseRetrodeCfg(SAMPLE); + const out = serializeRetrodeCfg(cfg, cfg.values); + expect(out).toContain("[HIDMode] 1"); + expect(out).toContain("[saveReadonly] 0"); + expect(parseRetrodeCfg(out).values).toEqual(cfg.values); + }); + + it("re-emits an unedited file byte-for-byte (LF and CRLF)", () => { + const cfg = parseRetrodeCfg(SAMPLE); + expect(serializeRetrodeCfg(cfg, cfg.values)).toBe(SAMPLE); + + const crlf = SAMPLE.replace(/\n/g, "\r\n"); + const cfgCrlf = parseRetrodeCfg(crlf); + const outCrlf = serializeRetrodeCfg(cfgCrlf, cfgCrlf.values); + expect(outCrlf).toBe(crlf); // CRLF preserved, not converted to LF + }); + + it("is idempotent — repeated saves never grow trailing blank lines", () => { + const once = serializeRetrodeCfg( + parseRetrodeCfg(SAMPLE), + parseRetrodeCfg(SAMPLE).values, + ); + const twice = serializeRetrodeCfg( + parseRetrodeCfg(once), + parseRetrodeCfg(once).values, + ); + expect(twice).toBe(once); + expect(once).toBe(SAMPLE); + }); + + it("keeps comment-column alignment on lines the user didn't edit", () => { + const cfg = parseRetrodeCfg(SAMPLE); + const out = serializeRetrodeCfg(cfg, { ...cfg.values, nesMode: 1 }); + // The edited line is rewritten in canonical form… + expect(out).toContain("[nesMode] 1 ; 1: NES gamepads; 0: SNES"); + // …but an untouched, column-aligned line keeps its exact spacing. + expect(out).toContain( + "[filenameChksum] 1 ; checksum in filename? 0=no, 1=yes", + ); + }); + + it("keeps CRLF endings when a value is edited in a CRLF file", () => { + const crlf = SAMPLE.replace(/\n/g, "\r\n"); + const cfg = parseRetrodeCfg(crlf); + const out = serializeRetrodeCfg(cfg, { ...cfg.values, nesMode: 1 }); + expect(out).toContain("[nesMode] 1 ; 1: NES gamepads; 0: SNES"); + // Every newline is part of a CRLF — no lone LF on the rewritten line or + // anywhere else (split counts match only when each \n is preceded by \r). + expect(out.split("\n").length).toBe(out.split("\r\n").length); + }); + + it("writes edited values back", () => { + const cfg = parseRetrodeCfg(SAMPLE); + const out = serializeRetrodeCfg(cfg, { + ...cfg.values, + nesMode: 1, + saveReadonly: true, + snesRomExt: "smc", + }); + expect(out).toContain("[nesMode] 1"); + expect(out).toContain("[saveReadonly] 1"); + expect(out).toContain("[snesRomExt] smc"); + }); + + it("preserves an unknown key verbatim but never shows it", () => { + const cfg = parseRetrodeCfg("[mysteryKey] 42 ; future option\n[nesMode] 0\n"); + const fields = buildConfigFields(cfg); + expect(fields.map((f) => f.key)).toEqual(["nesMode"]); + expect(serializeRetrodeCfg(cfg, cfg.values)).toContain( + "[mysteryKey] 42 ; future option", + ); + }); +}); diff --git a/src/lib/drivers/retrode2/retrode2-config.ts b/src/lib/drivers/retrode2/retrode2-config.ts new file mode 100644 index 0000000..f7b6cc3 --- /dev/null +++ b/src/lib/drivers/retrode2/retrode2-config.ts @@ -0,0 +1,415 @@ +import type { + ConfigOption, + ConfigValues, + ResolvedConfigField, +} from "@/lib/types"; + +/** + * RETRODE.CFG model for the nabu Retrode 2 config editor. + * + * The Retrode's config options are a finite, known set that has evolved across + * firmware versions (v0.17 used `enumerateHID`/`sramReadonly`/…; the current + * `.25a` uses `HIDMode`/`saveReadonly`/`segaSram16bit`/`snesOverdump`/…). Every + * key is curated directly in {@link KNOWN} with its widget, options, and + * defaults — there is no inference. + * + * - Parsing keeps a line model that preserves comments, blanks, key casing, + * and order verbatim. + * - Only keys present in the device's file AND in {@link KNOWN} render in the + * editor, so it always matches the connected device's firmware. + * - Serialization rewrites edited values in place; comments, blanks, and any + * key not in {@link KNOWN} are preserved verbatim (never lost, never shown). + * An unrecognized key means {@link KNOWN} is missing an entry — add it. + */ + +export const RETRODE2_CONFIG_FILENAME = "RETRODE.CFG"; + +type FieldType = "select" | "checkbox" | "number" | "text"; +type ValueType = "bool" | "int" | "str"; +type Group = "controllers" | "cartridge" | "files" | "advanced"; + +interface Descriptor { + label: string; + type: FieldType; + valueType: ValueType; + options?: ConfigOption[]; + range?: { min: number; max: number; step?: number }; + helpText?: string; + group: Group; +} + +const GROUP_LABELS: Record = { + controllers: "Controllers", + cartridge: "Cartridge & Detection", + files: "File Naming", + advanced: "Advanced", +}; +const GROUP_ORDER: Group[] = ["controllers", "cartridge", "files", "advanced"]; + +const onOff = (zero: string, one: string): ConfigOption[] => [ + { value: 0, label: zero }, + { value: 1, label: one }, +]; + +// The exact 4-char tokens [forceSystem] accepts, verified against the v0.25a +// firmware's token table (the strings immediately after "forceSystem invalid" +// in Retrode2-v0.25a.hex: VBOY GBOY GBA N64 GG SMS NGP MDRV TG16 NGP2 A26 SNES). +// "auto" = no override (the factory default). SMS and GG are DISTINCT tokens, +// and VBOY (added in fw 0.25) is a real value — both were missing before. +const FORCE_SYSTEM_OPTIONS: ConfigOption[] = [ + { value: "auto", label: "Auto-detect" }, + { value: "SNES", label: "Super Nintendo" }, + { value: "MDRV", label: "Genesis / Mega Drive" }, + { value: "GBOY", label: "Game Boy / Color" }, + { value: "GBA", label: "Game Boy Advance" }, + { value: "N64", label: "Nintendo 64" }, + { value: "SMS", label: "Master System" }, + { value: "GG", label: "Game Gear" }, + { value: "VBOY", label: "Virtual Boy" }, + { value: "TG16", label: "TurboGrafx-16 / PC Engine" }, + { value: "A26", label: "Atari 2600" }, + { value: "NGP", label: "Neo Geo Pocket" }, + { value: "NGP2", label: "Neo Geo Pocket Color" }, +]; + +const FORCE_SIZE_OPTIONS: ConfigOption[] = [ + { value: 0, label: "Auto-detect (from header)" }, + { value: 1, label: "512 KB" }, + { value: 2, label: "1 MB" }, + { value: 4, label: "2 MB" }, + { value: 8, label: "4 MB" }, + { value: 16, label: "8 MB" }, + { value: 32, label: "16 MB" }, +]; + +/** Curated descriptors across known firmware versions, keyed by lowercase. */ +const KNOWN: Record = { + // ── .25a controllers ── + hidmode: { + label: "USB HID mode", + type: "select", + valueType: "int", + options: [ + { value: 0, label: "Off" }, + { value: 1, label: "4 joypads + mouse" }, + { value: 2, label: "2 joypads" }, + { value: 3, label: "Keyboard" }, + { value: 4, label: "iCade" }, + ], + group: "controllers", + }, + blinkcontrollers: { + label: "Blink controller LEDs", + type: "checkbox", + valueType: "bool", + group: "controllers", + }, + nesmode: { + label: "Controller type", + type: "select", + valueType: "int", + options: onOff("SNES gamepads", "NES gamepads"), + group: "controllers", + }, + // ── v0.17 controllers ── + enumeratehid: { + label: "Expose USB game controllers", + type: "checkbox", + valueType: "bool", + group: "controllers", + }, + keyboardmode: { + label: "Use gamepads as keyboard", + type: "checkbox", + valueType: "bool", + group: "controllers", + }, + numgamepads: { + label: "Number of gamepads", + type: "number", + valueType: "int", + range: { min: 0, max: 4 }, + group: "controllers", + }, + gamepad1: { + label: "Gamepad 1 key map", + type: "text", + valueType: "str", + helpText: "12 HID keycodes (hex): B Y SEL STA UP DN LFT RGT A X L R", + group: "advanced", + }, + gamepad2: { + label: "Gamepad 2 key map", + type: "text", + valueType: "str", + helpText: "12 HID keycodes (hex), same order as Gamepad 1.", + group: "advanced", + }, + + // ── cartridge & detection ── + forcesystem: { + label: "Force system", + type: "select", + valueType: "str", + options: FORCE_SYSTEM_OPTIONS, + helpText: "Override auto-detection for an undetectable cartridge.", + group: "cartridge", + }, + forcesize: { + label: "Force ROM size", + type: "select", + valueType: "int", + options: FORCE_SIZE_OPTIONS, + helpText: "Override the header-derived size for a mis-sized dump.", + group: "cartridge", + }, + forcemapper: { + label: "Force SNES mapping", + type: "select", + valueType: "int", + options: [ + { value: 0, label: "Auto-detect" }, + { value: 1, label: "LoROM" }, + { value: 2, label: "HiROM" }, + ], + group: "cartridge", + }, + snesoverdump: { + label: "SNES overdump correction", + type: "checkbox", + valueType: "bool", + helpText: "Trim mirrored over-dumps on the device itself.", + group: "cartridge", + }, + snesromver: { + label: "Filename: version instead of checksum", + type: "checkbox", + valueType: "bool", + group: "cartridge", + }, + savereadonly: { + label: "Write-protect cartridge save", + type: "checkbox", + valueType: "bool", + helpText: "When on, writing a save file back to the cartridge is blocked.", + group: "cartridge", + }, + sramreadonly: { + label: "Write-protect cartridge SRAM", + type: "checkbox", + valueType: "bool", + group: "cartridge", + }, + segasram16bit: { + label: "Genesis SRAM width", + type: "select", + valueType: "int", + options: [ + { value: 0, label: "8-bit" }, + { value: 1, label: "16-bit" }, + { value: 2, label: "16-bit (large)" }, + ], + group: "cartridge", + }, + detectiondelay: { + label: "Detection delay (s)", + type: "number", + valueType: "int", + range: { min: 0, max: 60 }, + helpText: "How long to wait after cartridge insertion/removal.", + group: "cartridge", + }, + detectatari: { + label: "Auto-detect Atari 2600", + type: "checkbox", + valueType: "bool", + group: "cartridge", + }, + + // ── file naming ── + filenamechksum: { + label: "Append checksum to filename", + type: "checkbox", + valueType: "bool", + group: "files", + }, + sramext: { label: "Save (SRAM) extension", type: "text", valueType: "str", group: "files" }, + snesromext: { label: "SNES ROM extension", type: "text", valueType: "str", group: "files" }, + segaromext: { label: "Genesis / Mega Drive ROM extension", type: "text", valueType: "str", group: "files" }, + n64romext: { label: "Nintendo 64 ROM extension", type: "text", valueType: "str", group: "files" }, + gbromext: { label: "Game Boy ROM extension", type: "text", valueType: "str", group: "files" }, + gbaromext: { label: "Game Boy Advance ROM extension", type: "text", valueType: "str", group: "files" }, + smsromext: { label: "Master System ROM extension", type: "text", valueType: "str", group: "files" }, + ggromext: { label: "Game Gear ROM extension", type: "text", valueType: "str", group: "files" }, + vbromext: { label: "Virtual Boy ROM extension", type: "text", valueType: "str", group: "files" }, + atariromext: { label: "Atari 2600 ROM extension", type: "text", valueType: "str", group: "files" }, + tg16romext: { label: "TurboGrafx-16 ROM extension", type: "text", valueType: "str", group: "files" }, +}; + +// ── Line model ────────────────────────────────────────────────────────────── + +type CfgLine = + // `raw` is the verbatim original line, replayed unchanged unless the value is + // edited — this preserves the device's comment-column alignment and spacing. + | { kind: "setting"; rawKey: string; value: string; comment: string; raw: string } + | { kind: "raw"; text: string }; + +export interface RetrodeCfg { + lines: CfgLine[]; + values: ConfigValues; + /** The file's original line ending ("\r\n" for the DOS device, else "\n"). */ + eol: string; +} + +/** + * Find a config entry case-insensitively. The file keeps its original-case keys + * (e.g. `[forceSystem]`), but the config layer is otherwise case-insensitive, so + * callers shouldn't repeat the lowercase-key dance. Returns the original key + * (needed to rewrite the right line) and its parsed value, or undefined. + */ +export function cfgEntry( + cfg: RetrodeCfg, + key: string, +): { key: string; value: ConfigValues[string] } | undefined { + const lk = key.toLowerCase(); + const k = Object.keys(cfg.values).find((x) => x.toLowerCase() === lk); + return k === undefined ? undefined : { key: k, value: cfg.values[k] }; +} + +/** Case-insensitive config value as a non-empty string, or undefined. */ +export function cfgString(cfg: RetrodeCfg, key: string): string | undefined { + const v = cfgEntry(cfg, key)?.value; + return typeof v === "string" && v !== "" ? v : undefined; +} + +const SETTING_RE = /^\s*\[([A-Za-z0-9_]+)\]\s*([^;]*?)\s*(?:;\s*(.*))?$/; + +export function parseRetrodeCfg(text: string): RetrodeCfg { + const lines: CfgLine[] = []; + const values: ConfigValues = {}; + // The Retrode is a DOS/FAT device and writes CRLF. Preserve whichever ending + // the file actually uses so a round-trip doesn't rewrite every line's EOL. + const eol = text.includes("\r\n") ? "\r\n" : "\n"; + for (const line of text.split(/\r?\n/)) { + const m = line.match(SETTING_RE); + if (m && line.trim().startsWith("[")) { + const rawKey = m[1]; + const value = (m[2] ?? "").trim(); + const comment = (m[3] ?? "").trim(); + lines.push({ kind: "setting", rawKey, value, comment, raw: line }); + const d = KNOWN[rawKey.toLowerCase()]; + if (d) values[rawKey] = coerce(d.valueType, value); + } else { + lines.push({ kind: "raw", text: line }); + } + } + return { lines, values, eol }; +} + +function coerce(valueType: ValueType, raw: string): boolean | number | string { + const t = raw.trim(); + switch (valueType) { + case "bool": + return t === "1"; + case "int": { + const n = parseInt(t, 10); + return Number.isNaN(n) ? 0 : n; + } + case "str": + return t; + } +} + +function format(valueType: ValueType, value: unknown): string { + switch (valueType) { + case "bool": + return value ? "1" : "0"; + case "int": + return String(typeof value === "number" ? value : 0); + case "str": + return String(value ?? ""); + } +} + +/** Build grouped GUI fields from a parsed config, in file order within groups. */ +export function buildConfigFields(cfg: RetrodeCfg): ResolvedConfigField[] { + const fields: ResolvedConfigField[] = []; + let fileIndex = 0; + for (const line of cfg.lines) { + if (line.kind !== "setting") continue; + const d = KNOWN[line.rawKey.toLowerCase()]; + if (!d) continue; // unknown key: preserved on write-back, not shown + fields.push({ + key: line.rawKey, + label: d.label, + type: d.type, + value: cfg.values[line.rawKey] ?? coerce(d.valueType, line.value), + options: d.options, + range: d.range, + helpText: d.helpText, + group: GROUP_LABELS[d.group], + order: GROUP_ORDER.indexOf(d.group) * 1000 + fileIndex++, + }); + } + return fields; +} + +/** + * Serialize edited values back, preserving comments, order, line endings, and + * unknown keys. A line is re-emitted in canonical form ONLY when its value + * actually changed; every untouched line (known-but-unedited, unknown, comment, + * blank) is replayed byte-for-byte, so the device's comment-column alignment + * survives and parse→serialize with no edits is idempotent. + */ +export function serializeRetrodeCfg( + cfg: RetrodeCfg, + values: ConfigValues, +): string { + const out = cfg.lines.map((line) => { + if (line.kind === "raw") return line.text; + const d = KNOWN[line.rawKey.toLowerCase()]; + const orig = cfg.values[line.rawKey]; + const current = d ? (values[line.rawKey] ?? orig) : undefined; + // Unknown key, or a known key the user didn't change → verbatim original. + if (!d || current === orig) return line.raw; + const comment = line.comment ? ` ; ${line.comment}` : ""; + return `[${line.rawKey}] ${format(d.valueType, current)}${comment}`; + }); + // split() on a trailing newline left a final "" element; joining with the + // original EOL (and NOT appending another) reconstructs the terminator + // exactly — no blank-line growth across repeated saves. + return out.join(cfg.eol); +} + +/** + * Canonical factory RETRODE.CFG (current `.25a` format) — fallback if absent. + * Emitted with CRLF endings to match what the DOS/FAT device itself writes. + */ +export function defaultRetrodeCfg(): string { + return `; Retrode .25a - Config +; Remove any line to revert setting to factory default + +[HIDMode] 1 ; 0: Off; 1: 4Joy+Mouse; 2: 2Joy; 3: KB; 4: iCade +[blinkControllers] 1 +[nesMode] 0 ; 1: NES gamepads; 0: SNES +[filenameChksum] 1 ; checksum in filename? 0=no, 1=yes +[detectionDelay] 5 ; how long to wait after cart insertion/removal +[saveReadonly] 0 ; write protect save? 0=no, 1=yes +[segaSram16bit] 1 ; 0=no, 1=yes, 2=y+large +[sramExt] srm +[snesRomExt] sfc +[snesOverdump] 0 ; overdump correction? 0=no, 1=yes +[snesRomVer] 0 ; version instead of checksum? 0=no, 1=yes +[segaRomExt] bin +; Override autodetect: +[forceSystem] auto +[forceSize] 0 +[forceMapper] 0 +; Optional plug-ins: +[n64RomExt] z64 +[gbRomExt] gb +[gbaRomExt] gba +[smsRomExt] sms +[ggRomExt] gg +`.replace(/\n/g, "\r\n"); +} diff --git a/src/lib/drivers/retrode2/retrode2-detect.test.ts b/src/lib/drivers/retrode2/retrode2-detect.test.ts new file mode 100644 index 0000000..af08861 --- /dev/null +++ b/src/lib/drivers/retrode2/retrode2-detect.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect } from "vitest"; +import { + ROM_EXTENSIONS, + deriveTitle, + extOf, + findRomFile, + findRomFiles, + findSaveFile, + stripExt, +} from "./retrode2-detect"; + +describe("extOf", () => { + it("returns the lowercase extension without the dot", () => { + expect(extOf("GAME.SFC")).toBe("sfc"); + expect(extOf("game.bin")).toBe("bin"); + }); + + it("uses only the final extension when several dots are present", () => { + expect(extOf("GAME.ABCD.sfc")).toBe("sfc"); + }); + + it("returns empty string when there is no extension", () => { + expect(extOf("README")).toBe(""); + }); +}); + +describe("stripExt", () => { + it("drops only the final extension", () => { + expect(stripExt("GAME.SFC")).toBe("GAME"); + expect(stripExt("GAME.ABCD.sfc")).toBe("GAME.ABCD"); + }); + + it("returns the name unchanged when there is no extension", () => { + expect(stripExt("README")).toBe("README"); + }); +}); + +describe("findRomFiles", () => { + it("returns [] when there are no ROM files", () => { + expect(findRomFiles([])).toEqual([]); + expect(findRomFiles(["RETRODE.CFG", "GAME.SRM", "notes.txt"])).toEqual([]); + }); + + it("returns one entry for a single ROM with the right systemId", () => { + expect(findRomFiles(["GAME.sfc"])).toEqual([ + { name: "GAME.sfc", systemId: "snes" }, + ]); + }); + + it("returns ALL entries when multiple ROM-extension files are present", () => { + // A .sfc (snes) + a .bin (genesis) => two RomFile entries. + const roms = findRomFiles(["SNESGAME.sfc", "MDGAME.bin"]); + expect(roms).toEqual([ + { name: "SNESGAME.sfc", systemId: "snes" }, + { name: "MDGAME.bin", systemId: "genesis" }, + ]); + }); + + it("excludes the file whose extension equals sramExt", () => { + // Without overriding sramExt, .srm is the default save extension and is + // not a ROM_EXTENSIONS key, so it's filtered as a non-ROM anyway. The + // load-bearing case is when the save extension collides with a ROM ext. + const roms = findRomFiles(["GAME.bin", "GAME.smc"], "smc"); + expect(roms).toEqual([{ name: "GAME.bin", systemId: "genesis" }]); + }); + + it("maps extensions to systemIds via ROM_EXTENSIONS", () => { + // Spot-check a representative entry from each system family. + const samples: Array<[string, string]> = [ + ["a.sfc", "snes"], + ["a.smc", "snes"], + ["a.bin", "genesis"], + ["a.gen", "genesis"], + ["a.md", "genesis"], + ["a.smd", "genesis"], + ["a.z64", "n64"], + ["a.n64", "n64"], + ["a.v64", "n64"], + ["a.gb", "gb"], + ["a.gbc", "gbc"], + ["a.gba", "gba"], + ["a.sms", "sms"], + ["a.gg", "gg"], + ["a.a26", "atari2600"], + ["a.vb", "vboy"], + ["a.pce", "tg16"], + ["a.huc", "tg16"], + ]; + for (const [name, systemId] of samples) { + const roms = findRomFiles([name]); + expect(roms).toEqual([{ name, systemId }]); + // …and the mapping comes straight from the exported table. + expect(ROM_EXTENSIONS[extOf(name)]).toBe(systemId); + } + }); + + it("does NOT recognize .nes — the Retrode 2 never dumped NES carts", () => { + // NES was a removed firmware prototype; the device can't produce a .nes, + // so a .nes on the volume is some other folder's file, not a cartridge. + expect(findRomFiles(["Homebrew.nes"])).toEqual([]); + expect(ROM_EXTENSIONS.nes).toBeUndefined(); + }); + + it("is case-insensitive on the extension", () => { + // FILE0001.SFC must still classify as snes despite the upper-case ext. + expect(findRomFiles(["FILE0001.SFC"])).toEqual([ + { name: "FILE0001.SFC", systemId: "snes" }, + ]); + // Upper-case save extension is still excluded by the lowercased compare. + expect(findRomFiles(["GAME.BIN", "GAME.SMC"], "SMC")).toEqual([ + { name: "GAME.BIN", systemId: "genesis" }, + ]); + }); +}); + +describe("findRomFile", () => { + it("returns the first match", () => { + expect(findRomFile(["FIRST.sfc", "SECOND.bin"])).toEqual({ + name: "FIRST.sfc", + systemId: "snes", + }); + }); + + it("returns null when there is no ROM file", () => { + expect(findRomFile(["GAME.srm", "RETRODE.CFG"])).toBeNull(); + }); +}); + +describe("findSaveFile", () => { + it("matches the save by exact base name", () => { + const names = ["GAME.ABCD.sfc", "GAME.ABCD.srm"]; + expect(findSaveFile(names, "GAME.ABCD.sfc")).toBe("GAME.ABCD.srm"); + }); + + it("prefers the exact base over any other save on the volume", () => { + // Two saves present; only the base-matching one may be returned. + const names = ["GAME.ABCD.sfc", "OTHER.srm", "GAME.ABCD.srm"]; + expect(findSaveFile(names, "GAME.ABCD.sfc")).toBe("GAME.ABCD.srm"); + }); + + it("falls back to the lone save only when there is exactly one ROM and one save", () => { + // Base names differ (Retrode checksum suffix changed), but the volume is + // unambiguous: one ROM, one save => attach it. + const names = ["GAME.ABCD.sfc", "GAME.0000.srm"]; + expect(findSaveFile(names, "GAME.ABCD.sfc")).toBe("GAME.0000.srm"); + }); + + it("does NOT cross-attach a different game's save with two carts each saved", () => { + // Two ROMs + two saves, neither save's base matches the queried ROM. + // Guessing here would flash the wrong bytes back, so it must be null. + const names = ["A.1111.sfc", "A.9999.srm", "B.2222.bin", "B.8888.srm"]; + expect(findSaveFile(names, "A.1111.sfc")).toBeNull(); + }); + + it("still returns the exact save when two ROMs are present", () => { + const names = ["A.1111.sfc", "A.1111.srm", "B.2222.bin"]; + expect(findSaveFile(names, "A.1111.sfc")).toBe("A.1111.srm"); + }); + + it("returns null when there is no save at all", () => { + expect(findSaveFile(["GAME.sfc"], "GAME.sfc")).toBeNull(); + }); + + it("does not fall back when one ROM but multiple saves exist", () => { + // One ROM, two saves => ambiguous, and no exact base match => null. + const names = ["GAME.ABCD.sfc", "X.srm", "Y.srm"]; + expect(findSaveFile(names, "GAME.ABCD.sfc")).toBeNull(); + }); + + it("honors a custom sramExt and is case-insensitive on it", () => { + const names = ["GAME.ABCD.sfc", "GAME.ABCD.SAV"]; + expect(findSaveFile(names, "GAME.ABCD.sfc", "sav")).toBe("GAME.ABCD.SAV"); + }); +}); + +describe("deriveTitle", () => { + it("strips the extension", () => { + expect(deriveTitle("Plain.sfc")).toBe("Plain"); + }); + + it("strips a trailing 4-hex checksum segment", () => { + expect(deriveTitle("GAME.ABCD.sfc")).toBe("GAME"); + expect(deriveTitle("My Game.1f3e.bin")).toBe("My Game"); + }); + + it("leaves a non-hex trailing segment intact", () => { + // "GHIJ" is not 4 hex digits, so it is part of the title. + expect(deriveTitle("GAME.GHIJ.sfc")).toBe("GAME.GHIJ"); + // Wrong length (not exactly 4) is also kept. + expect(deriveTitle("GAME.ABC.sfc")).toBe("GAME.ABC"); + expect(deriveTitle("GAME.ABCDE.sfc")).toBe("GAME.ABCDE"); + }); + + it("returns the bare name when there is no checksum or extension", () => { + expect(deriveTitle("README")).toBe("README"); + }); +}); diff --git a/src/lib/drivers/retrode2/retrode2-detect.ts b/src/lib/drivers/retrode2/retrode2-detect.ts new file mode 100644 index 0000000..9a7adbf --- /dev/null +++ b/src/lib/drivers/retrode2/retrode2-detect.ts @@ -0,0 +1,128 @@ +import type { SystemId } from "@/lib/types"; + +/** + * Maps the file extensions the Retrode 2 writes to nabu systemIds. The actual + * extension is configurable per-system in RETRODE.CFG (`*RomExt`), but these + * are the defaults plus common aliases. + */ +export const ROM_EXTENSIONS: Record = { + sfc: "snes", + smc: "snes", + bin: "genesis", + gen: "genesis", + md: "genesis", + smd: "genesis", + z64: "n64", + n64: "n64", + v64: "n64", + gb: "gb", + gbc: "gbc", + gba: "gba", + sms: "sms", + gg: "gg", + a26: "atari2600", + vb: "vboy", + pce: "tg16", + huc: "tg16", + // N64 Controller Pak ("Mempak") — a standalone 32 KB memory accessory the + // Retrode dumps as its own file (not a cartridge ROM, not a cart save). + mpk: "n64_controller_pak", +}; + +/** + * systemIds the Retrode 2 driver can fully process today (i.e. nabu has a + * SystemHandler for them). Extended as handlers for SNES/Genesis/N64/SMS land. + */ +export const SUPPORTED_SYSTEMS = new Set([ + "gb", + "gbc", + "gba", + "snes", + "genesis", + "n64", + "sms", + "gg", + "n64_controller_pak", + "vboy", + "atari2600", +]); + +// TODO(voltage-hint): the Retrode 2 has a MANUAL cartridge-voltage switch +// (defaults to 5V; must be flipped to 3.3V for GBA & N64 — the adapters are +// passive, and the wrong voltage risks cart damage). The firmware does NOT +// expose the switch position (no RETRODE.CFG key, no status file, no sense +// line), so we can't show the CURRENT setting — but we could surface per-system +// GUIDANCE: a SYSTEM_VOLTAGE map (systemId → "3.3V" | "5V") rendered as a +// caution line on the GBA/N64 result cards ("needs 3.3V — set the switch"). +// Deferred 2026-06-18. + +export interface RomFile { + name: string; + systemId: SystemId; +} + +/** Lowercase extension of a filename, without the dot. */ +export function extOf(name: string): string { + const i = name.lastIndexOf("."); + return i < 0 ? "" : name.slice(i + 1).toLowerCase(); +} + +/** Filename without its final extension. */ +export function stripExt(name: string): string { + const i = name.lastIndexOf("."); + return i < 0 ? name : name.slice(0, i); +} + +/** + * All cartridge ROM files among directory entries (excludes the save). The + * Retrode 2 has two live slots (SNES + Genesis) plus adapters, so the volume + * can carry more than one ROM at once — return every candidate and let the + * caller pick, rather than silently grabbing whichever the FS enumerates first. + */ +export function findRomFiles(names: string[], sramExt = "srm"): RomFile[] { + const save = sramExt.toLowerCase(); + return names + .filter((n) => { + const e = extOf(n); + return e in ROM_EXTENSIONS && e !== save; + }) + .map((n) => ({ name: n, systemId: ROM_EXTENSIONS[extOf(n)] })); +} + +/** The first cartridge ROM file, or null. Convenience over {@link findRomFiles}. */ +export function findRomFile(names: string[], sramExt = "srm"): RomFile | null { + return findRomFiles(names, sramExt)[0] ?? null; +} + +/** + * The save file belonging to a specific ROM, by exact base-name match. + * + * The lone-file fallback (accept "the only save on the volume" when the base + * doesn't match) fires ONLY when the volume is unambiguous — exactly one ROM + * and exactly one save. With two carts inserted there are multiple ROMs/saves, + * and guessing could attach the wrong game's save; on the write path that would + * flash the wrong bytes back to a cartridge. When in doubt, return null. + */ +export function findSaveFile( + names: string[], + romName: string, + sramExt = "srm", +): string | null { + const ext = sramExt.toLowerCase(); + const base = stripExt(romName); + const exact = names.find((n) => extOf(n) === ext && stripExt(n) === base); + if (exact) return exact; + const roms = findRomFiles(names, ext); + const saves = names.filter((n) => extOf(n) === ext); + return roms.length === 1 && saves.length === 1 ? saves[0] : null; +} + +/** Derive a display title from a Retrode filename `.<CHK>.<ext>`. */ +export function deriveTitle(name: string): string { + let base = stripExt(name); // drop .ext + const i = base.lastIndexOf("."); + if (i >= 0 && /^[0-9A-Fa-f]{4}$/.test(base.slice(i + 1))) { + base = base.slice(0, i); // drop the .CHK checksum segment + } + return base; +} diff --git a/src/lib/drivers/retrode2/retrode2-driver.test.ts b/src/lib/drivers/retrode2/retrode2-driver.test.ts new file mode 100644 index 0000000..c62ebd9 --- /dev/null +++ b/src/lib/drivers/retrode2/retrode2-driver.test.ts @@ -0,0 +1,500 @@ +import { describe, it, expect } from "vitest"; +import { Retrode2Driver } from "./retrode2-driver"; +import type { DirectoryTransport } from "@/lib/transport/directory-transport"; +import type { ReadConfig, SystemId } from "@/lib/types"; + +// ── Fake volume ────────────────────────────────────────────────────────────── +// A lightweight in-memory stand-in for DirectoryTransport, backed by a +// filename→bytes map. Only the surface the driver actually touches is +// implemented: listFiles, readFile(name, onProgress?), readText, writeFile, +// hasFile, and `directory.getFileHandle(name).getFile().size`. + +interface ProgressTick { + read: number; + total: number; +} + +class FakeVolume { + readonly files = new Map<string, Uint8Array>(); + /** Records every (read,total) the driver's readROM streamed through. */ + readonly progress: ProgressTick[] = []; + + put(name: string, bytes: Uint8Array | string): void { + this.files.set( + name, + typeof bytes === "string" ? new TextEncoder().encode(bytes) : bytes, + ); + } + + remove(name: string): void { + this.files.delete(name); + } + + /** A `DirectoryTransport`-shaped object the driver can be constructed with. */ + asTransport(): DirectoryTransport { + const files = this.files; + const progress = this.progress; + const fake = { + get directory() { + return { + getFileHandle(name: string) { + if (!files.has(name)) { + return Promise.reject(new Error(`NotFoundError: ${name}`)); + } + return Promise.resolve({ + getFile() { + return Promise.resolve({ size: files.get(name)!.length }); + }, + }); + }, + }; + }, + listFiles(): Promise<string[]> { + return Promise.resolve([...files.keys()]); + }, + hasFile(name: string): Promise<boolean> { + return Promise.resolve(files.has(name)); + }, + readFile( + name: string, + onProgress?: (read: number, total: number) => void, + ): Promise<Uint8Array> { + const data = files.get(name); + if (!data) return Promise.reject(new Error(`NotFoundError: ${name}`)); + // Mimic the real transport's chunked progress reporting: hand the + // bytes back in a couple of slices so onProgress fires more than once. + if (onProgress) { + const mid = Math.floor(data.length / 2); + onProgress(mid, data.length); + onProgress(data.length, data.length); + progress.push({ read: mid, total: data.length }); + progress.push({ read: data.length, total: data.length }); + } + return Promise.resolve(data); + }, + readText(name: string): Promise<string> { + const data = files.get(name); + if (!data) return Promise.reject(new Error(`NotFoundError: ${name}`)); + return Promise.resolve(new TextDecoder().decode(data)); + }, + writeFile(name: string, data: Uint8Array | string): Promise<void> { + files.set( + name, + typeof data === "string" ? new TextEncoder().encode(data) : data, + ); + return Promise.resolve(); + }, + }; + return fake as unknown as DirectoryTransport; + } +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +function bytes(...vals: number[]): Uint8Array { + return Uint8Array.from(vals); +} + +/** Byte-equality without the pathological cost of toEqual on big arrays. */ +function expectBytesEqual(actual: Uint8Array, expected: Uint8Array): void { + expect(actual.length).toBe(expected.length); + for (let i = 0; i < expected.length; i++) { + if (actual[i] !== expected[i]) { + throw new Error( + `byte mismatch at ${i}: got ${actual[i]}, expected ${expected[i]}`, + ); + } + } +} + +function readConfig(systemId: SystemId): ReadConfig { + return { systemId, params: {} }; +} + +/** Drives the driver and collects every onLog message it emits. */ +function withLogs(driver: Retrode2Driver): { level: string; message: string }[] { + const logs: { level: string; message: string }[] = []; + driver.on("onLog", (message, level) => logs.push({ message, level })); + return logs; +} + +/** The active selection's filename, via the `active` flag on detectedRoms. */ +function activeName(driver: Retrode2Driver): string | null { + return driver.detectedRoms.find((r) => r.active)?.name ?? null; +} + +const SNES_ROM = "Game.ABCD.sfc"; +const GEN_ROM = "Other.1234.bin"; + +describe("Retrode2Driver — single cartridge", () => { + it("detectSystem returns the ROM's system + cartInfo and selects it", async () => { + const vol = new FakeVolume(); + vol.put(SNES_ROM, bytes(1, 2, 3, 4, 5, 6, 7, 8)); + const driver = new Retrode2Driver(vol.asTransport()); + + const result = await driver.detectSystem(); + expect(result).not.toBeNull(); + expect(result!.systemId).toBe("snes"); + expect(result!.unsupported).toBeUndefined(); + expect(result!.cartInfo.romSize).toBe(8); + expect(result!.cartInfo.summary).toContain(SNES_ROM); + // No title on cartInfo: the synthesized filename must not feed the naming + // layer (output names come from the header/CRC). The filename-derived title + // survives only as DISPLAY metadata on detectedRoms below. + expect(result!.cartInfo.title).toBeUndefined(); + + expect(driver.detectedRoms).toHaveLength(1); + expect(driver.detectedRoms[0].name).toBe(SNES_ROM); + expect(driver.detectedRoms[0].systemId).toBe("snes"); + expect(driver.detectedRoms[0].title).toBe("Game"); // display only + expect(driver.detectedRoms[0].label).toBe("Super Nintendo"); + expect(driver.detectedRoms[0].supported).toBe(true); + + expect(activeName(driver)).toBe(SNES_ROM); + }); + + it("does not log the multi-cart warning with a single ROM", async () => { + const vol = new FakeVolume(); + vol.put(SNES_ROM, bytes(0, 0)); + const driver = new Retrode2Driver(vol.asTransport()); + const logs = withLogs(driver); + + await driver.detectSystem(); + expect(logs.some((l) => l.level === "warn")).toBe(false); + }); +}); + +describe("Retrode2Driver — save (SRAM) detection", () => { + // The save file shares the ROM's base name (Game.ABCD) with the .srm ext. + const SNES_SAVE = "Game.ABCD.srm"; + + it("reports saveSize + saveType when a matching save is on the volume", async () => { + const vol = new FakeVolume(); + vol.put(SNES_ROM, bytes(1, 2, 3, 4)); + vol.put(SNES_SAVE, new Uint8Array(2048)); + const driver = new Retrode2Driver(vol.asTransport()); + + const result = await driver.detectSystem(); + // saveSize is the gate that makes the shared pipeline auto-back-up the save. + expect(result!.cartInfo.saveSize).toBe(2048); + expect(result!.cartInfo.saveType).toBe("SRAM"); + }); + + it("leaves saveSize unset when no save file is present (ROM-only)", async () => { + const vol = new FakeVolume(); + vol.put(SNES_ROM, bytes(1, 2, 3, 4)); + const driver = new Retrode2Driver(vol.asTransport()); + + const result = await driver.detectSystem(); + expect(result!.cartInfo.saveSize).toBeUndefined(); + expect(result!.cartInfo.saveType).toBeUndefined(); + }); +}); + +describe("Retrode2Driver — two cartridges", () => { + function loaded(): { vol: FakeVolume; driver: Retrode2Driver } { + const vol = new FakeVolume(); + vol.put(SNES_ROM, bytes(1, 2, 3, 4)); + vol.put(GEN_ROM, bytes(9, 8, 7, 6, 5)); + return { vol, driver: new Retrode2Driver(vol.asTransport()) }; + } + + it("lists BOTH roms with name/systemId/label/supported", async () => { + const { driver } = loaded(); + await driver.detectSystem(); + + const roms = driver.detectedRoms; + expect(roms).toHaveLength(2); + const byName = Object.fromEntries(roms.map((r) => [r.name, r])); + + expect(byName[SNES_ROM].systemId).toBe("snes"); + expect(byName[SNES_ROM].label).toBe("Super Nintendo"); + expect(byName[SNES_ROM].supported).toBe(true); + + expect(byName[GEN_ROM].systemId).toBe("genesis"); + expect(byName[GEN_ROM].label).toBe("Genesis / Mega Drive"); + expect(byName[GEN_ROM].supported).toBe(true); + }); + + it("defaults the active ROM to the first candidate", async () => { + const { driver } = loaded(); + const result = await driver.detectSystem(); + expect(result!.systemId).toBe("snes"); + expect(activeName(driver)).toBe(SNES_ROM); + }); + + it("logs a warning naming both files when two carts are present", async () => { + const { driver } = loaded(); + const logs = withLogs(driver); + + await driver.detectSystem(); + const warn = logs.find((l) => l.level === "warn"); + expect(warn).toBeDefined(); + expect(warn!.message).toContain(SNES_ROM); + expect(warn!.message).toContain(GEN_ROM); + }); + + it("selectRom switches the active cart; detect* reflect the choice", async () => { + const { driver } = loaded(); + await driver.detectSystem(); // defaults to SNES + + driver.selectRom(GEN_ROM); + const result = await driver.detectSystem(); + expect(result!.systemId).toBe("genesis"); + expect(activeName(driver)).toBe(GEN_ROM); + + const cart = await driver.detectCartridge("genesis"); + expect(cart).not.toBeNull(); + expect(cart!.summary).toContain(GEN_ROM); + expect(cart!.romSize).toBe(5); + }); + + it("scanRoms returns every ROM for the auto-load-all flow", async () => { + const { driver } = loaded(); + const roms = await driver.scanRoms(); + expect(roms.map((r) => r.name).sort()).toEqual([GEN_ROM, SNES_ROM].sort()); + }); +}); + +describe("Retrode2Driver — volumeSignature (change detection)", () => { + it("includes file sizes, so a same-name re-dump and a new file both move it", async () => { + const vol = new FakeVolume(); + vol.put(SNES_ROM, bytes(1, 2, 3, 4)); + const driver = new Retrode2Driver(vol.asTransport()); + + const before = await driver.volumeSignature(); + expect(before).toContain(SNES_ROM); + + // Same filename, new length — what a forceSize re-dump produces. + vol.put(SNES_ROM, bytes(1, 2, 3, 4, 5, 6, 7, 8)); + const afterResize = await driver.volumeSignature(); + expect(afterResize).not.toBe(before); + + // A new file (cart inserted) also moves it. + vol.put(GEN_ROM, bytes(9, 9)); + const afterAdd = await driver.volumeSignature(); + expect(afterAdd).not.toBe(afterResize); + expect(afterAdd).toContain(GEN_ROM); + }); +}); + +describe("Retrode2Driver — stale-cache revalidation on cart removal", () => { + it("drops the active selection when its file is gone, and readROM throws", async () => { + const vol = new FakeVolume(); + vol.put(SNES_ROM, bytes(1, 2, 3, 4)); + vol.put(GEN_ROM, bytes(5, 6, 7)); + const driver = new Retrode2Driver(vol.asTransport()); + + driver.selectRom(GEN_ROM); + expect((await driver.detectSystem())!.systemId).toBe("genesis"); + expect(activeName(driver)).toBe(GEN_ROM); + + // Cart removed: its file disappears from the volume. + vol.remove(GEN_ROM); + + const after = await driver.detectSystem(); + // The active selection no longer points at the gone file; detect now + // falls back to the remaining cart rather than serving stale state. + expect(after).not.toBeNull(); + expect(after!.systemId).toBe("snes"); + expect(activeName(driver)).toBe(SNES_ROM); + + // A read for the removed system has no matching ROM and must throw. + await expect(driver.readROM(readConfig("genesis"))).rejects.toThrow( + /No matching ROM/, + ); + }); + + it("detectSystem returns null once the volume holds no ROMs", async () => { + const vol = new FakeVolume(); + vol.put(SNES_ROM, bytes(1, 2, 3, 4)); + const driver = new Retrode2Driver(vol.asTransport()); + + expect((await driver.detectSystem())!.systemId).toBe("snes"); + vol.remove(SNES_ROM); + + expect(await driver.detectSystem()).toBeNull(); + expect(activeName(driver)).toBeNull(); + }); + + it("findRom re-scans per call: readROM after a swap with no detectSystem throws", async () => { + const vol = new FakeVolume(); + vol.put(SNES_ROM, bytes(1, 2, 3, 4)); + vol.put(GEN_ROM, bytes(5, 6, 7)); + const driver = new Retrode2Driver(vol.asTransport()); + driver.selectRom(GEN_ROM); + await driver.detectSystem(); // active = genesis + + // Cart pulled; NO detectSystem() between the removal and the read, so it is + // findRom's OWN per-call scan() (not detectSystem's) that must catch it. + vol.remove(GEN_ROM); + await expect(driver.readROM(readConfig("genesis"))).rejects.toThrow( + /No matching ROM/, + ); + }); +}); + +describe("Retrode2Driver — unsupported system", () => { + it("flags a cart the device can produce but nabu can't yet process", async () => { + const vol = new FakeVolume(); + // TG16 / PC Engine is in the .25a firmware's forceSystem tokens (the device + // can dump it with the adapter), but nabu has no tg16 SystemHandler yet + // (RetroSpector doesn't cover it, so it wasn't ported). + vol.put("Homebrew.0000.pce", bytes(1, 2, 3, 4)); // tg16 ∉ SUPPORTED_SYSTEMS + const driver = new Retrode2Driver(vol.asTransport()); + + const result = await driver.detectSystem(); + expect(result).not.toBeNull(); + expect(result!.systemId).toBe("tg16"); + expect(result!.unsupported).toBeDefined(); + expect(result!.unsupported!.reason).toContain("Homebrew.0000.pce"); + }); +}); + +describe("Retrode2Driver — readROM", () => { + it("streams the file bytes back and fires onProgress", async () => { + const vol = new FakeVolume(); + const payload = bytes(10, 20, 30, 40, 50, 60); + vol.put(SNES_ROM, payload); + const driver = new Retrode2Driver(vol.asTransport()); + const progress: number[] = []; + driver.on("onProgress", (p) => { + expect(p.phase).toBe("rom"); + expect(p.totalBytes).toBe(payload.length); + progress.push(p.bytesRead); + }); + + const data = await driver.readROM(readConfig("snes")); + expectBytesEqual(data, payload); + + expect(progress.length).toBeGreaterThan(1); + expect(progress[progress.length - 1]).toBe(payload.length); + // Final fraction should reach 1. + expect(vol.progress[vol.progress.length - 1].read).toBe(payload.length); + }); +}); + +describe("Retrode2Driver — writeSave targets THIS rom's base, never the fallback", () => { + it("writes the SNES save to <snesbase>.srm, leaving the Genesis save alone", async () => { + const vol = new FakeVolume(); + vol.put(SNES_ROM, bytes(1, 2, 3, 4)); + vol.put(GEN_ROM, bytes(5, 6, 7, 8)); + const snesSave = "Game.ABCD.srm"; + const genSave = "Other.1234.srm"; + vol.put(snesSave, bytes(0xaa, 0xaa)); + vol.put(genSave, bytes(0xbb, 0xbb)); + const driver = new Retrode2Driver(vol.asTransport()); + + const newSnes = bytes(0x11, 0x22, 0x33); + await driver.writeSave(newSnes, readConfig("snes")); + + // SNES save rewritten; Genesis save untouched (no lone-file misfire). + expectBytesEqual(vol.files.get(snesSave)!, newSnes); + expectBytesEqual(vol.files.get(genSave)!, bytes(0xbb, 0xbb)); + }); + + it("derives <rombase>.<sramExt> when no save file exists yet", async () => { + const vol = new FakeVolume(); + vol.put(SNES_ROM, bytes(1, 2, 3, 4)); + vol.put(GEN_ROM, bytes(5, 6, 7, 8)); + const driver = new Retrode2Driver(vol.asTransport()); + + const payload = bytes(7, 7, 7, 7); + await driver.writeSave(payload, readConfig("genesis")); + + // Target derived from the Genesis ROM base, default srm extension. + const expected = "Other.1234.srm"; + expect(vol.files.has(expected)).toBe(true); + expectBytesEqual(vol.files.get(expected)!, payload); + // The SNES base never got a save. + expect(vol.files.has("Game.ABCD.srm")).toBe(false); + }); + + it("ignores a lone NON-matching save and writes the derived name instead", async () => { + const vol = new FakeVolume(); + vol.put(SNES_ROM, bytes(1, 2, 3, 4)); + // Exactly one ROM + one save whose base does NOT match — the only case + // findSaveFile's lone-fallback would fire. writeSave must NOT reuse it + // (flashing a different game's save back to the cart would corrupt it). + const leftover = "Leftover.9999.srm"; + vol.put(leftover, bytes(0xcc, 0xcc)); + const driver = new Retrode2Driver(vol.asTransport()); + + const payload = bytes(0x42, 0x42, 0x42); + await driver.writeSave(payload, readConfig("snes")); + + expect(vol.files.has("Game.ABCD.srm")).toBe(true); + expectBytesEqual(vol.files.get("Game.ABCD.srm")!, payload); + expectBytesEqual(vol.files.get(leftover)!, bytes(0xcc, 0xcc)); // untouched + }); +}); + +describe("Retrode2Driver — readSave", () => { + it("returns the matching save's bytes", async () => { + const vol = new FakeVolume(); + vol.put(SNES_ROM, bytes(1, 2, 3, 4)); + const save = bytes(0xde, 0xad, 0xbe, 0xef); + vol.put("Game.ABCD.srm", save); + const driver = new Retrode2Driver(vol.asTransport()); + + const out = await driver.readSave(readConfig("snes")); + expectBytesEqual(out, save); + }); + + it("throws when no save file exists for the cartridge", async () => { + const vol = new FakeVolume(); + vol.put(SNES_ROM, bytes(1, 2, 3, 4)); + const driver = new Retrode2Driver(vol.asTransport()); + + await expect(driver.readSave(readConfig("snes"))).rejects.toThrow( + /No save file/, + ); + }); + + it("reads sramExt case-insensitively from the config header", async () => { + const vol = new FakeVolume(); + // Uppercase key — the config layer keys by original case, so a naive + // values["sramExt"] lookup would miss it and fall back to "srm". + vol.put("RETRODE.CFG", "; Retrode .25a - Config\r\n[SRAMEXT] abc\r\n"); + vol.put(SNES_ROM, bytes(1, 2, 3, 4)); + const save = bytes(0xde, 0xad); + vol.put("Game.ABCD.abc", save); + const driver = new Retrode2Driver(vol.asTransport()); + await driver.initialize(); + + // Found only if sramExt resolved to "abc" (not the "srm" default). + expectBytesEqual(await driver.readSave(readConfig("snes")), save); + }); +}); + +describe("Retrode2Driver — force overrides (system + size)", () => { + const CFG = (sys: string, size: number) => + `; Retrode .25a - Config\r\n[forceSystem] ${sys}\r\n[forceSize] ${size}\r\n`; + + it("forceSystem / forceSize / hasOverride reflect the config", async () => { + const vol = new FakeVolume(); + vol.put("RETRODE.CFG", CFG("SNES", 8)); + const driver = new Retrode2Driver(vol.asTransport()); + await driver.initialize(); + expect(driver.forceSystem).toBe("SNES"); + expect(driver.forceSize).toBe(8); + expect(driver.hasOverride).toBe(true); + + const vol2 = new FakeVolume(); + vol2.put("RETRODE.CFG", CFG("auto", 0)); + const d2 = new Retrode2Driver(vol2.asTransport()); + await d2.initialize(); + expect(d2.forceSystem).toBe("auto"); + expect(d2.forceSize).toBe(0); + expect(d2.hasOverride).toBe(false); + }); + + it("a size-only override still counts (the under-dump case)", async () => { + const vol = new FakeVolume(); + vol.put("RETRODE.CFG", CFG("auto", 4)); + const driver = new Retrode2Driver(vol.asTransport()); + await driver.initialize(); + expect(driver.hasOverride).toBe(true); + expect(driver.forceSize).toBe(4); + }); +}); diff --git a/src/lib/drivers/retrode2/retrode2-driver.ts b/src/lib/drivers/retrode2/retrode2-driver.ts new file mode 100644 index 0000000..759da3e --- /dev/null +++ b/src/lib/drivers/retrode2/retrode2-driver.ts @@ -0,0 +1,372 @@ +import type { + CartridgeInfo, + DeviceCapability, + DeviceDriver, + DeviceDriverEvents, + DeviceInfo, + DetectSystemResult, + ReadConfig, + SystemId, +} from "@/lib/types"; +import type { DirectoryTransport } from "@/lib/transport/directory-transport"; +import { + RETRODE2_CONFIG_FILENAME, + cfgEntry, + cfgString, + parseRetrodeCfg, + type RetrodeCfg, +} from "./retrode2-config"; +import { firmwareStatus, parseFirmwareVersion } from "./retrode2-firmware"; +import { + deriveTitle, + extOf, + findRomFiles, + findSaveFile, + stripExt, + SUPPORTED_SYSTEMS, + type RomFile, +} from "./retrode2-detect"; + +/** A cartridge ROM found on the volume, with display + support metadata. */ +export interface DetectedRomInfo { + name: string; + systemId: SystemId; + title: string; + /** System display label (e.g. "Super Nintendo"). */ + label: string; + /** True when nabu has a SystemHandler that can process this system. */ + supported: boolean; + /** True for the cartridge currently chosen to read (the active selection). */ + active: boolean; +} + +const SYSTEM_LABELS: Record<string, string> = { + snes: "Super Nintendo", + genesis: "Genesis / Mega Drive", + n64: "Nintendo 64", + gb: "Game Boy", + gbc: "Game Boy Color", + gba: "Game Boy Advance", + sms: "Master System", + gg: "Game Gear", + atari2600: "Atari 2600", + vboy: "Virtual Boy", + tg16: "TurboGrafx-16", + n64_controller_pak: "N64 Controller Pak", +}; + +/** + * Driver for the Retrode 2, a USB mass-storage cartridge reader. Rather than + * stream a cartridge bus, the Retrode writes a synthesized ROM file (+ optional + * save) to a FAT volume; this driver reads those files through a + * {@link DirectoryTransport} (File System Access API) and hands the raw bytes + * to the matching SystemHandler for validation. Over-dump correction lives in + * the dump pipeline (authoritative No-Intro match) and the SystemHandler + * (opt-in heuristic offered post-dump), not here. It also reads the device's + * RETRODE.CFG so the Configure screen can render the current settings; the + * volume is read-only through the browser (its synthesized FAT rejects the File + * System Access API's temp-write file), so edits are DOWNLOADED for the user to + * copy onto the device — this driver never writes the config back. + */ +export class Retrode2Driver implements DeviceDriver { + readonly id = "RETRODE2"; + readonly name = "Retrode 2"; + readonly capabilities: DeviceCapability[] = [...SUPPORTED_SYSTEMS].map( + (systemId) => ({ + systemId, + operations: ["dump_rom", "dump_save", "write_save"], + autoDetect: true, + }), + ); + + readonly transport: DirectoryTransport; + + private readonly events: Partial<DeviceDriverEvents> = {}; + private cfg: RetrodeCfg | null = null; + private firmware = "unknown"; + /** Every ROM file currently on the volume (≥2 when both slots are loaded). */ + private candidates: RomFile[] = []; + /** The user-chosen ROM to dump; null means "default to the first candidate". */ + private activeRomName: string | null = null; + + constructor(transport: DirectoryTransport) { + this.transport = transport; + } + + private get sramExt(): string { + return (this.cfg && cfgString(this.cfg, "sramExt")) || "srm"; + } + + async initialize(): Promise<DeviceInfo> { + await this.refreshConfig(); + const status = firmwareStatus(this.firmware); + let outdatedNote: string | undefined; + if (status.outdatedNote) { + outdatedNote = status.outdatedNote; + this.log(status.outdatedNote, "warn"); + } + const base = + "Reads the ROM and save the Retrode wrote to its mounted volume and " + + "validates them, offering an over-dump trim when one is detected. " + + "Re-mount after swapping carts."; + return { + firmwareVersion: this.firmware, + deviceName: "Retrode 2", + capabilities: this.capabilities, + hotSwap: false, + compatibilityNote: outdatedNote ? `${base} ${outdatedNote}` : base, + }; + } + + /** Re-read RETRODE.CFG from the volume into `cfg`/`firmware`. Called at + * initialize and before every auto-reload, so the displayed settings track a + * config the user copied onto the device out-of-band. */ + private async refreshConfig(): Promise<void> { + if (await this.transport.hasFile(RETRODE2_CONFIG_FILENAME)) { + const text = await this.transport.readText(RETRODE2_CONFIG_FILENAME); + this.cfg = parseRetrodeCfg(text); + this.firmware = parseFirmwareVersion(text) ?? "unknown"; + } + } + + /** Parsed RETRODE.CFG, for the config editor (null if none on the volume). */ + get config(): RetrodeCfg | null { + return this.cfg; + } + + /** Current [forceSystem] token ("auto" when unset/blank). */ + get forceSystem(): string { + return (this.cfg && cfgString(this.cfg, "forceSystem")) || "auto"; + } + + /** Current [forceSize] value in 512KB units (0 = auto / header-derived). */ + get forceSize(): number { + const v = this.cfg ? cfgEntry(this.cfg, "forceSize")?.value : undefined; + return typeof v === "number" ? v : 0; + } + + /** True when any sticky size/system override is active (not auto). */ + get hasOverride(): boolean { + return this.forceSystem.toLowerCase() !== "auto" || this.forceSize !== 0; + } + + /** + * Re-list the volume and rebuild the ROM candidate set. Drops a stale active + * selection whose file is gone (cart removed), so cached state never points + * at a file that no longer exists. + */ + private async scan(): Promise<RomFile[]> { + const names = await this.transport.listFiles(); + this.candidates = findRomFiles(names, this.sramExt); + if ( + this.activeRomName && + !this.candidates.some((c) => c.name === this.activeRomName) + ) { + this.activeRomName = null; + } + return this.candidates; + } + + /** The ROM to act on: the user's selection if still present, else the first. */ + private activeRom(): RomFile | null { + if (this.candidates.length === 0) return null; + if (this.activeRomName) { + return ( + this.candidates.find((c) => c.name === this.activeRomName) ?? + this.candidates[0] + ); + } + return this.candidates[0]; + } + + /** Every ROM the last scan found, with display + support metadata and an + * `active` flag for the current selection — for the landing chooser. */ + get detectedRoms(): DetectedRomInfo[] { + const activeName = this.activeRom()?.name; + return this.candidates.map((c) => ({ + name: c.name, + systemId: c.systemId, + title: deriveTitle(c.name), + label: SYSTEM_LABELS[c.systemId] ?? c.systemId, + supported: SUPPORTED_SYSTEMS.has(c.systemId), + active: c.name === activeName, + })); + } + + /** Choose which cartridge to dump when multiple are on the volume. */ + selectRom(name: string): void { + this.activeRomName = name; + } + + /** Re-read the config and re-scan, returning every ROM on the volume — the + * entry point for the auto-load-all flow (which dumps each in turn). */ + async scanRoms(): Promise<DetectedRomInfo[]> { + await this.refreshConfig(); + await this.scan(); + return this.detectedRoms; + } + + /** + * A signature of every file on the volume (name + size) for change detection: + * the auto-load poll re-dumps only when this changes — a cart swap, or the + * device regenerating its files after the user copies a new RETRODE.CFG. + * Sizes are included so a same-name re-dump at a new length is also caught. + */ + async volumeSignature(): Promise<string> { + const names = await this.transport.listFiles(); + const parts = await Promise.all( + names.map(async (name) => { + try { + const handle = await this.transport.directory.getFileHandle(name); + return `${name}:${(await handle.getFile()).size}`; + } catch { + return `${name}:?`; + } + }), + ); + return parts.sort().join("|"); + } + + async detectSystem(): Promise<DetectSystemResult | null> { + await this.scan(); + const rom = this.activeRom(); + if (!rom) return null; + if (this.candidates.length > 1) { + this.log( + `Multiple cartridges on the volume (${this.candidates + .map((c) => c.name) + .join(", ")}). Reading ${rom.name}; pick another in the list to switch.`, + "warn", + ); + } + const cartInfo = await this.buildCartInfo(rom); + if (!SUPPORTED_SYSTEMS.has(rom.systemId)) { + const label = SYSTEM_LABELS[rom.systemId] ?? rom.systemId; + return { + systemId: rom.systemId, + cartInfo, + unsupported: { + reason: + `The file (${rom.name}) is on the volume, but nabu has no ${label} ` + + "validator — it can't check or name this dump.", + }, + }; + } + return { systemId: rom.systemId, cartInfo }; + } + + async detectCartridge(systemId: SystemId): Promise<CartridgeInfo | null> { + const rom = await this.findRom(systemId); + return rom ? this.buildCartInfo(rom) : null; + } + + private async buildCartInfo(rom: RomFile): Promise<CartridgeInfo> { + const handle = await this.transport.directory.getFileHandle(rom.name); + const size = (await handle.getFile()).size; + const label = SYSTEM_LABELS[rom.systemId] ?? rom.systemId; + // Report the matching save file's size when one is on the volume, so the + // shared pipeline auto-enables the save backup (prefillFromCartInfo flips + // `backupSave` on a truthy saveSize for a device that dumps ROM + save + // separately, which the Retrode does). Found via the same findSaveFile the + // readSave path uses, so detection and the read agree on "the save for this + // ROM" — no detect-says-yes / read-throws mismatch. Absent save → unset, so + // nothing is requested and the cart dumps ROM-only. + const saveSize = await this.matchingSaveSize(rom.name); + // Intentionally NO `title`: the synthesized Retrode filename is not an + // authoritative cart title, and feeding it into the naming layer is the + // leak the device-independent headerTitle work removed. The output name + // comes from the SystemHandler's header parse (or the CRC fallback); + // deriveTitle survives only for DISPLAY — the chooser (detectedRoms.title) + // and this summary's filename. + return { + romSize: size, + summary: `${label} cartridge (${rom.name})`, + ...(saveSize > 0 ? { saveSize, saveType: "SRAM" } : {}), + }; + } + + /** Byte length of the save file matching `romName`, or 0 when none is on the + * volume — used at detection time to decide whether to back the save up. */ + private async matchingSaveSize(romName: string): Promise<number> { + const save = findSaveFile( + await this.transport.listFiles(), + romName, + this.sramExt, + ); + if (!save) return 0; + try { + const handle = await this.transport.directory.getFileHandle(save); + return (await handle.getFile()).size; + } catch { + return 0; + } + } + + async readROM(readConfig: ReadConfig): Promise<Uint8Array> { + const rom = await this.requireRom(readConfig.systemId); + const data = await this.transport.readFile(rom.name, (read, total) => { + this.events.onProgress?.({ + phase: "rom", + bytesRead: read, + totalBytes: total, + fraction: total > 0 ? read / total : 0, + }); + }); + this.log(`Read ${rom.name} (${data.length} bytes)`); + return data; + } + + async readSave(readConfig: ReadConfig): Promise<Uint8Array> { + const rom = await this.requireRom(readConfig.systemId); + const names = await this.transport.listFiles(); + const save = findSaveFile(names, rom.name, this.sramExt); + if (!save) { + throw new Error("No save file on the Retrode volume for this cartridge."); + } + return this.transport.readFile(save); + } + + async writeSave(data: Uint8Array, readConfig: ReadConfig): Promise<void> { + const rom = await this.requireRom(readConfig.systemId); + // Write only to THIS ROM's save: the exact base-name match if it exists, + // else a name derived from the ROM base. Never the lone-file fallback — + // writing to an unrelated save would flash the wrong bytes back to a cart. + const ext = this.sramExt.toLowerCase(); + const base = stripExt(rom.name); + const names = await this.transport.listFiles(); + const save = + names.find((n) => extOf(n) === ext && stripExt(n) === base) ?? + `${base}.${this.sramExt}`; + await this.transport.writeFile(save, data); + this.log(`Wrote save ${save} (${data.length} bytes)`); + } + + /** + * The ROM file for `systemId`. Re-scans the volume every call so a swapped or + * removed cart can't be served from a stale cache, and honors the user's + * active selection when it matches. + */ + private async findRom(systemId: SystemId): Promise<RomFile | null> { + await this.scan(); + const active = this.activeRom(); + if (active?.systemId === systemId) return active; + return this.candidates.find((c) => c.systemId === systemId) ?? null; + } + + private async requireRom(systemId: SystemId): Promise<RomFile> { + const rom = await this.findRom(systemId); + if (!rom) throw new Error("No matching ROM file on the Retrode volume."); + return rom; + } + + on<K extends keyof DeviceDriverEvents>( + event: K, + handler: DeviceDriverEvents[K], + ): void { + this.events[event] = handler; + } + + private log(message: string, level: "info" | "warn" | "error" = "info"): void { + this.events.onLog?.(message, level); + } +} diff --git a/src/lib/drivers/retrode2/retrode2-firmware.test.ts b/src/lib/drivers/retrode2/retrode2-firmware.test.ts new file mode 100644 index 0000000..0282cd7 --- /dev/null +++ b/src/lib/drivers/retrode2/retrode2-firmware.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { + KNOWN_LATEST_FIRMWARE, + firmwareStatus, + parseFirmwareVersion, +} from "./retrode2-firmware"; + +describe("parseFirmwareVersion", () => { + it("reads the .25a config header", () => { + expect(parseFirmwareVersion("; Retrode .25a - Config\n[nesMode] 0")).toBe( + ".25a", + ); + }); + + it("reads a legacy firmware header", () => { + expect(parseFirmwareVersion("Retrode Firmware v0.17\n(C) 2011")).toBe( + "v0.17", + ); + }); + + it("returns null when no version is present", () => { + expect(parseFirmwareVersion("; Retrode Config\n[nesMode] 0")).toBeNull(); + }); +}); + +describe("firmwareStatus", () => { + it("treats the known-latest as current (no note)", () => { + const s = firmwareStatus(KNOWN_LATEST_FIRMWARE); + expect(s.isLatestKnown).toBe(true); + expect(s.outdatedNote).toBeUndefined(); + }); + + it("normalizes a leading-dot / v difference", () => { + expect(firmwareStatus("0.25a").isLatestKnown).toBe(true); + }); + + it("flags a confirmed older version", () => { + const s = firmwareStatus("v0.17"); + expect(s.isLatestKnown).toBe(false); + expect(s.outdatedNote).toContain(KNOWN_LATEST_FIRMWARE); + }); + + it("never nags on an unknown version", () => { + expect(firmwareStatus(null).outdatedNote).toBeUndefined(); + expect(firmwareStatus("unknown").outdatedNote).toBeUndefined(); + }); +}); diff --git a/src/lib/drivers/retrode2/retrode2-firmware.ts b/src/lib/drivers/retrode2/retrode2-firmware.ts new file mode 100644 index 0000000..76dd98c --- /dev/null +++ b/src/lib/drivers/retrode2/retrode2-firmware.ts @@ -0,0 +1,172 @@ +/** + * Retrode firmware-version awareness for the outdated-firmware flag. + * + * nabu does NOT flash firmware — it only reports when a device looks older than + * the latest known release so the user knows some config options may be + * missing. The full version timeline + per-option changelog is built by the + * `retrode-firmware-changelog` research workflow; this holds only what the + * driver needs at runtime. + */ + +/** + * Latest Retrode 1/2 (AVR/LUFA) firmware: 0.25a (2018-08-17), the final release + * of the line. The device's RETRODE.CFG header drops the leading zero + * ("; Retrode .25a - Config"); `normalize()` treats ".25a" and "0.25a" equal. + */ +export const KNOWN_LATEST_FIRMWARE = "0.25a"; + +/** + * Extract the firmware version token from a RETRODE.CFG body. The current + * `.25a` header is `; Retrode .25a - Config`; older firmware may omit the + * version entirely (returns null then — we don't guess). + */ +export function parseFirmwareVersion(cfgText: string): string | null { + const m = + cfgText.match( + /Retrode\s+(?:Firmware\s+)?(v?\.?[0-9][0-9a-z.]*)\s*-?\s*Config/i, + ) ?? cfgText.match(/Retrode\s+Firmware\s+(v?[0-9][0-9a-z.]*)/i); + return m ? m[1] : null; +} + +export interface FirmwareStatus { + version: string; + isLatestKnown: boolean; + /** Set only when the version is known AND older than the latest known. */ + outdatedNote?: string; +} + +// Compare versions ignoring a leading "v" and any leading "0."/"." prefix, so +// ".25a", "0.25a", and "v0.25a" all normalize equal. +const normalize = (v: string): string => + v + .trim() + .toLowerCase() + .replace(/^v/, "") + .replace(/^[0.]+/, ""); + +export function firmwareStatus(version: string | null): FirmwareStatus { + // No version in the header → can't tell; never nag. + if (!version || version.toLowerCase() === "unknown") { + return { version: version ?? "unknown", isLatestKnown: false }; + } + const isLatestKnown = normalize(version) === normalize(KNOWN_LATEST_FIRMWARE); + return { + version, + isLatestKnown, + outdatedNote: isLatestKnown + ? undefined + : `This Retrode reports firmware ${version}; the latest known is ` + + `${KNOWN_LATEST_FIRMWARE}. Some configuration options may be ` + + `unavailable on older firmware. nabu does not flash firmware — update ` + + `with the official Retrode tools if you want the newest options.`, + }; +} + +/** Lifecycle of one RETRODE.CFG key across the firmware lineage. */ +export interface OptionFirmware { + /** Firmware version (or era) the key first appeared. */ + introducedIn: string; + status: "current" | "renamed" | "removed"; + /** Setting only meaningful on the Retrode 2 (e.g. Genesis SRAM width). */ + retrode2Only?: boolean; + note?: string; +} + +/** + * Per-option firmware history, from the firmware-changelog research (the full + * timeline lives in spike/retrode2/FIRMWARE.md). Covers both current `.25a` + * keys and legacy keys that older firmware wrote. Keyed by lowercase config + * key. Used to annotate the config editor and document version requirements. + */ +export const OPTION_FIRMWARE: Record<string, OptionFirmware> = { + // ── Current keys (present in .25a) ── + filenamechksum: { introducedIn: "0.15", status: "current" }, + nesmode: { introducedIn: "0.15", status: "current" }, + detectiondelay: { introducedIn: "0.15", status: "current" }, + segaromext: { introducedIn: "0.15", status: "current" }, + snesromext: { introducedIn: "0.15", status: "current" }, + sramext: { introducedIn: "0.15", status: "current" }, + gbromext: { introducedIn: "0.15l", status: "current" }, + gbaromext: { introducedIn: "0.15k", status: "current" }, + n64romext: { + introducedIn: "0.15", + status: "current", + note: "default changed to .z64 in 0.23", + }, + forcesystem: { + introducedIn: "0.17", + status: "current", + note: "'vboy' value added in 0.25", + }, + forcesize: { introducedIn: "0.17", status: "current" }, + forcemapper: { introducedIn: "0.17", status: "current" }, + hidmode: { + introducedIn: "0.17d", + status: "current", + note: "multi-value successor to enumerateHID", + }, + segasram16bit: { + introducedIn: "0.17c", + status: "current", + retrode2Only: true, + note: "became a 3-value option in 0.21", + }, + smsromext: { introducedIn: "0.18a", status: "current" }, + ggromext: { introducedIn: "0.18a", status: "current" }, + blinkcontrollers: { introducedIn: "0.18c", status: "current" }, + snesoverdump: { introducedIn: "0.22", status: "current" }, + snesromver: { introducedIn: "0.22", status: "current" }, + savereadonly: { + introducedIn: "0.23", + status: "current", + note: "renamed from sramReadonly", + }, + // ── Legacy keys (older firmware only; absent from .25a) ── + enumeratehid: { + introducedIn: "0.15", + status: "renamed", + note: "renamed to HIDMode in 0.17d", + }, + keyboardmode: { + introducedIn: "0.15", + status: "removed", + note: "folded into HIDMode=3 (KB); removed in 0.18a", + }, + numgamepads: { introducedIn: "0.15", status: "removed", note: "removed in 0.18a" }, + gamepad1: { introducedIn: "0.15", status: "removed", note: "removed in 0.18a" }, + gamepad2: { introducedIn: "0.15", status: "removed", note: "removed in 0.18a" }, + detectatari: { + introducedIn: "0.15", + status: "removed", + note: "removed in 0.17d; Atari handled via forceSystem A26", + }, + sramreadonly: { + introducedIn: "0.15", + status: "renamed", + note: "renamed to saveReadonly in 0.23", + }, + nesromext: { + introducedIn: "0.15", + status: "removed", + note: "plug-in prototype ext; removed in 0.18a", + }, + vbromext: { + introducedIn: "0.15", + status: "removed", + note: "plug-in prototype ext; removed in 0.18a", + }, + atariromext: { + introducedIn: "0.15", + status: "removed", + note: "plug-in prototype ext; removed in 0.18a", + }, + tg16romext: { + introducedIn: "0.15i", + status: "removed", + note: "plug-in prototype ext; removed in 0.18a", + }, +}; + +export function firmwareForOption(key: string): OptionFirmware | undefined { + return OPTION_FIRMWARE[key.toLowerCase()]; +} diff --git a/src/lib/systems/atari2600/atari2600-header.ts b/src/lib/systems/atari2600/atari2600-header.ts new file mode 100644 index 0000000..a04017f --- /dev/null +++ b/src/lib/systems/atari2600/atari2600-header.ts @@ -0,0 +1,176 @@ +/** + * Atari 2600 (VCS) cartridge identification — pure, no DOM. + * + * The 2600 has NO ROM header: a cart is raw 6507 code with the CPU vectors at + * the very top of its address space and nothing self-describing in front of it. + * Identification is therefore heuristic — we recognise an image by the way it + * drives the hardware rather than by a magic number: + * + * 1. Size is a valid 2600 bank size (2 KiB to 128 KiB). + * 2. The 6507 reset vector points into cartridge ROM space (A12 set), checked + * at the end of the image and at every 4 KiB bank boundary — the startup + * bank varies by bankswitch scheme. + * 3. The code stores to the TIA's video-timing registers (VSYNC / VBLANK / + * WSYNC / HMOVE). Every 2600 game must, so their presence separates real + * code from noise. + * + * References: + * Stella programmer's guide (TIA register addresses): + * https://alienbill.com/2600/101/docs/stella.html + * Atari 2600 TIA hardware manual: + * https://problemkaputt.de/2k6specs.htm#televisioninterfaceadaptortia + * 6502 instruction set reference: + * https://www.masswerk.at/6502/6502_instruction_set.html + */ + +// Cartridge size constraints. The 2600 maps a 4 KiB window ($1000-$1FFF); carts +// larger than that bank-switch in 4 KiB chunks, except the original 2 KiB carts +// that mirror into the upper half of the window. +const ROM_MIN_BYTES = 0x800; // 2 KiB +const ROM_MAX_BYTES = 0x20000; // 128 KiB +const BANK_STRIDE_BYTES = 0x1000; // 4 KiB banks + +// The 6507 addresses only 13 lines; the reset/IRQ vectors at $FFFC mirror down +// to $1FFC. A reset target inside cartridge ROM space has A12 ($1000) set. +const VECTOR_A12_MASK = 0x1000; + +// 6502 zero-page store opcodes: STY zp (0x84), STA zp (0x85), STX zp (0x86). +// The TIA registers live in zero page, so video writes use exactly these. +const TIA_STORE_OPCODE_MIN = 0x84; +const TIA_STORE_OPCODE_MAX = 0x86; + +// TIA video-timing register addresses (zero page). +const TIA_VSYNC = 0x00; +const TIA_VBLANK = 0x01; +const TIA_WSYNC = 0x02; +const TIA_HMOVE = 0x2a; + +// Require at least this many distinct TIA registers to be written before we +// accept the image as real 2600 code rather than a coincidental byte pattern. +const TIA_PATTERN_THRESHOLD = 3; + +/** A recognised 2600 bank-size layout, with a coarse scheme guess. */ +export type Atari2600Scheme = "2K" | "4K" | "bankswitched"; + +export interface Atari2600Info { + /** ROM size in bytes (a validated 2600 bank size). */ + size: number; + /** + * Coarse layout guess. There is no header to read the exact mapper from, so + * this is purely size-derived: 2 KiB and 4 KiB carts are unbanked; anything + * larger is bank-switched (the specific scheme — F8/F6/F4/E0/FE/3F/… — can't + * be told apart from the bytes alone, so we don't claim one). + */ + scheme: Atari2600Scheme; + /** + * The reset vector points into cartridge ROM space, at the image end and/or + * at a 4 KiB bank boundary. True for a structurally valid image. + */ + resetVectorValid: boolean; + /** Number of distinct TIA timing registers written (0-4). */ + tiaPatternCount: number; +} + +/** + * Whether any zero-page store (STY/STA/STX zp) targets the given TIA register. + * Scans every adjacent opcode/operand pair — a deliberately loose match, since + * we only need evidence the register is driven somewhere in the image. + */ +function hasTiaWrite(data: Uint8Array, reg: number): boolean { + for (let i = 0; i + 1 < data.length; i++) { + const op = data[i]; + if ( + op >= TIA_STORE_OPCODE_MIN && + op <= TIA_STORE_OPCODE_MAX && + data[i + 1] === reg + ) { + return true; + } + } + return false; +} + +/** Count how many of the four key TIA timing registers the image writes to. */ +function countTiaPatterns(data: Uint8Array): number { + return ( + (hasTiaWrite(data, TIA_VSYNC) ? 1 : 0) + + (hasTiaWrite(data, TIA_VBLANK) ? 1 : 0) + + (hasTiaWrite(data, TIA_WSYNC) ? 1 : 0) + + (hasTiaWrite(data, TIA_HMOVE) ? 1 : 0) + ); +} + +/** A12 of the reset vector at `[end-4 .. end-3]` (little-endian) is set. */ +function resetVectorInRom(data: Uint8Array, end: number): boolean { + const lo = data[end - 4]; + const hi = data[end - 3]; + return (((hi << 8) | lo) & VECTOR_A12_MASK) !== 0; +} + +/** A valid 2600 bank size: 2 KiB, or any 4 KiB multiple up to 128 KiB. */ +function isValidSize(len: number): boolean { + if (len < ROM_MIN_BYTES || len > ROM_MAX_BYTES) return false; + return len === ROM_MIN_BYTES || len % BANK_STRIDE_BYTES === 0; +} + +function schemeForSize(len: number): Atari2600Scheme { + if (len === ROM_MIN_BYTES) return "2K"; + if (len === BANK_STRIDE_BYTES) return "4K"; + return "bankswitched"; +} + +/** + * Identify an Atari 2600 image. Returns null when the bytes don't look like a + * 2600 cart — wrong size, reset vector outside ROM space, or too few TIA writes + * to be real game code. A non-null result means all three heuristics passed; + * because there is no header or checksum, that is the strongest "this is a 2600 + * ROM" signal the format affords (a heuristic, not a cryptographic verdict). + */ +export function parseAtari2600(data: Uint8Array): Atari2600Info | null { + const len = data.length; + if (!isValidSize(len)) return null; + + // Reset vector must land in cartridge ROM space. The startup bank varies by + // scheme (most boot from the last bank; FE boots from bank 0), and some carts + // append non-program data after the code, so accept a hit at the image end OR + // at any 4 KiB bank boundary. + let resetVectorValid = resetVectorInRom(data, len); + if (!resetVectorValid) { + for (let end = BANK_STRIDE_BYTES; end <= len; end += BANK_STRIDE_BYTES) { + if (resetVectorInRom(data, end)) { + resetVectorValid = true; + break; + } + } + } + if (!resetVectorValid) return null; + + const tiaPatternCount = countTiaPatterns(data); + if (tiaPatternCount < TIA_PATTERN_THRESHOLD) return null; + + return { + size: len, + scheme: schemeForSize(len), + resetVectorValid, + tiaPatternCount, + }; +} + +const KIB = 1024; + +/** Human-readable size label (e.g. "2 KiB", "16 KiB"). */ +export function formatRomSize(bytes: number): string { + return `${bytes / KIB} KiB`; +} + +/** Human label for the coarse layout guess. */ +export function schemeLabel(scheme: Atari2600Scheme): string { + switch (scheme) { + case "2K": + return "2 KiB (unbanked)"; + case "4K": + return "4 KiB (unbanked)"; + case "bankswitched": + return "Bank-switched"; + } +} diff --git a/src/lib/systems/atari2600/atari2600-system-handler.test.ts b/src/lib/systems/atari2600/atari2600-system-handler.test.ts new file mode 100644 index 0000000..a285da9 --- /dev/null +++ b/src/lib/systems/atari2600/atari2600-system-handler.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect } from "vitest"; +import { parseAtari2600, formatRomSize } from "./atari2600-header"; +import { Atari2600SystemHandler } from "./atari2600-system-handler"; + +// Build a synthetic 2600 image that satisfies all three heuristics: +// - valid bank size +// - reset vector ($FFFC mirror at end-4/-3) points into ROM space (A12 set) +// - at least 3 distinct TIA timing-register writes (STA/STX/STY zp) +function buildImage(size: number): Uint8Array { + const d = new Uint8Array(size); + + // STA $00 (VSYNC), STX $02 (WSYNC), STY $2A (HMOVE) near the start — three + // distinct TIA registers via three distinct store opcodes. + d[0] = 0x85; // STA zp + d[1] = 0x00; // VSYNC + d[2] = 0x86; // STX zp + d[3] = 0x02; // WSYNC + d[4] = 0x84; // STY zp + d[5] = 0x2a; // HMOVE + + // Reset vector at the image end: $F000 (A12 set → cartridge ROM space). + d[size - 4] = 0x00; // lo + d[size - 3] = 0xf0; // hi + // NMI/IRQ vector too, for realism (unused by the heuristic). + d[size - 2] = 0x00; + d[size - 1] = 0xf0; + return d; +} + +describe("parseAtari2600", () => { + it("identifies a valid 4 KiB image and reports the heuristic fields", () => { + const info = parseAtari2600(buildImage(0x1000))!; + expect(info).not.toBeNull(); + expect(info.size).toBe(0x1000); + expect(info.scheme).toBe("4K"); + expect(info.resetVectorValid).toBe(true); + expect(info.tiaPatternCount).toBe(3); + }); + + it("classifies a 2 KiB image as unbanked", () => { + expect(parseAtari2600(buildImage(0x800))!.scheme).toBe("2K"); + }); + + it("classifies a larger image as bank-switched", () => { + const info = parseAtari2600(buildImage(0x4000))!; // 16 KiB + expect(info.scheme).toBe("bankswitched"); + expect(info.size).toBe(0x4000); + }); + + it("counts all four TIA registers when present", () => { + const d = buildImage(0x1000); + // Add STA $01 (VBLANK) — the fourth timing register. + d[6] = 0x85; + d[7] = 0x01; + expect(parseAtari2600(d)!.tiaPatternCount).toBe(4); + }); + + it("accepts a reset vector at a bank boundary, not just the end", () => { + // 8 KiB image whose tail reset vector is OUTSIDE ROM space, but bank 0's + // boundary ($1000) carries a valid one. + const d = buildImage(0x2000); + d[0x1000 - 4] = 0x00; // first-bank reset vector lo + d[0x1000 - 3] = 0xf0; // hi → A12 set + d[d.length - 4] = 0xff; // tail vector → $00FF, A12 clear + d[d.length - 3] = 0x00; + expect(parseAtari2600(d)!.resetVectorValid).toBe(true); + }); + + it("rejects a reset vector outside cartridge ROM space", () => { + const d = buildImage(0x1000); + d[d.length - 4] = 0x34; // $0234 → A12 clear + d[d.length - 3] = 0x02; + expect(parseAtari2600(d)).toBeNull(); + }); + + it("rejects an image with too few TIA writes", () => { + const d = buildImage(0x1000); + // Wipe two of the three TIA stores, leaving only one register. + d[2] = 0x00; + d[3] = 0x00; + d[4] = 0x00; + d[5] = 0x00; + expect(parseAtari2600(d)).toBeNull(); + }); + + it("rejects an invalid (non-bank) size", () => { + expect(parseAtari2600(buildImage(0x1234))).toBeNull(); // not 2K, not 4K-mult + }); + + it("rejects an undersized image", () => { + expect(parseAtari2600(new Uint8Array(0x400))).toBeNull(); // 1 KiB + }); + + it("rejects pure noise as not a 2600 image", () => { + const noise = new Uint8Array(0x1000).fill(0xa5); + expect(parseAtari2600(noise)).toBeNull(); + }); + + it("rejects an all-zero image (no reset vector, no TIA writes)", () => { + expect(parseAtari2600(new Uint8Array(0x1000))).toBeNull(); + }); +}); + +describe("formatRomSize", () => { + it("formats KiB sizes", () => { + expect(formatRomSize(0x800)).toBe("2 KiB"); + expect(formatRomSize(0x4000)).toBe("16 KiB"); + }); +}); + +describe("Atari2600SystemHandler", () => { + const handler = new Atari2600SystemHandler(); + + it("exposes the expected identity", () => { + expect(handler.systemId).toBe("atari2600"); + expect(handler.displayName).toBe("Atari 2600"); + expect(handler.fileExtension).toBe(".a26"); + }); + + it("has no config fields and always validates", () => { + expect(handler.getConfigFields({})).toEqual([]); + expect(handler.validate({})).toEqual({ valid: true }); + }); + + it("builds a read config for atari2600", () => { + expect(handler.buildReadConfig({})).toEqual({ + systemId: "atari2600", + params: {}, + }); + }); + + it("builds an output file with size/layout meta", () => { + const out = handler.buildOutputFile(buildImage(0x1000), { + systemId: "atari2600", + params: {}, + }); + expect(out.filename).toBe("dump.a26"); + expect(out.mimeType).toBe("application/octet-stream"); + expect(out.meta?.Format).toBe("Atari 2600"); + expect(out.meta?.Size).toBe("4 KiB"); + expect(out.meta?.Layout).toBe("4 KiB (unbanked)"); + }); + + it("computes crc32/sha1/sha256/size", async () => { + const hashes = await handler.computeHashes(buildImage(0x800)); + expect(hashes.size).toBe(0x800); + expect(hashes.sha1).toMatch(/^[0-9a-f]{40}$/); + expect(hashes.sha256).toMatch(/^[0-9a-f]{64}$/); + expect(typeof hashes.crc32).toBe("number"); + }); + + it("verify returns 'none' when no DB is present", () => { + const result = handler.verify({ crc32: 0, sha1: "x", size: 0x800 }, null); + expect(result).toEqual({ matched: false, confidence: "none" }); + }); + + it("verify returns an exact match from the DB", () => { + const entry = { name: "Some Cart", status: "verified" as const }; + const db = { + systemId: "atari2600", + source: "test", + entryCount: 1, + lookup: () => entry, + }; + const result = handler.verify({ crc32: 0, sha1: "x", size: 0x800 }, db); + expect(result).toEqual({ matched: true, entry, confidence: "exact" }); + }); + + it("summarizeDump returns the heuristic table with a passing integrity note", () => { + const summary = handler.summarizeDump(buildImage(0x1000))!; + expect(summary).not.toBeNull(); + expect(summary.title).toBe("Atari 2600 image"); + expect(summary.columns).toEqual(["Field", "Value"]); + expect(summary.monoColumns).toEqual([1]); + expect(summary.rows).toEqual([ + ["ROM size", "4 KiB"], + ["Layout", "4 KiB (unbanked)"], + ["Reset vector", "Points into ROM"], + ["TIA writes", "3 of 4 timing registers"], + ]); + expect(summary.integrity?.ok).toBe(true); + expect(summary.integrity?.message).toMatch(/heuristic/i); + }); + + it("summarizeDump returns null for noise (no fabricated verdict)", () => { + expect(handler.summarizeDump(new Uint8Array(0x1000).fill(0xa5))).toBeNull(); + }); +}); diff --git a/src/lib/systems/atari2600/atari2600-system-handler.ts b/src/lib/systems/atari2600/atari2600-system-handler.ts new file mode 100644 index 0000000..cd16829 --- /dev/null +++ b/src/lib/systems/atari2600/atari2600-system-handler.ts @@ -0,0 +1,114 @@ +import type { + SystemHandler, + ConfigValues, + CartridgeInfo, + DumpSummary, + DumpSummaryCell, + ResolvedConfigField, + ValidationResult, + ReadConfig, + OutputFile, + VerificationHashes, + VerificationDB, + VerificationResult, +} from "@/lib/types"; +import { crc32, sha1Hex, sha256Hex } from "@/lib/core/hashing"; +import { formatRomSize, parseAtari2600, schemeLabel } from "./atari2600-header"; + +/** + * Atari 2600 (VCS). ROMs are raw 6507 binaries with NO header and NO checksum — + * a No-Intro match is over the whole file. Identification is heuristic (size, + * reset-vector-in-ROM, TIA register writes); see atari2600-header.ts. This + * handler packages the already-correct bytes and surfaces what the heuristics + * found, being explicit that the integrity signal is structural, not a checksum. + */ +export class Atari2600SystemHandler implements SystemHandler { + readonly systemId = "atari2600"; + readonly displayName = "Atari 2600"; + readonly fileExtension = ".a26"; + + getConfigFields( + _currentValues: ConfigValues, + _autoDetected?: CartridgeInfo, + ): ResolvedConfigField[] { + return []; + } + + validate(_values: ConfigValues): ValidationResult { + return { valid: true }; + } + + buildReadConfig(_values: ConfigValues): ReadConfig { + return { systemId: "atari2600", params: {} }; + } + + buildOutputFile(rawData: Uint8Array, _config: ReadConfig): OutputFile { + const info = parseAtari2600(rawData); + const meta: Record<string, string> = { Format: "Atari 2600" }; + if (info) { + meta.Size = formatRomSize(info.size); + meta.Layout = schemeLabel(info.scheme); + } + return { + data: rawData, + filename: "dump.a26", + mimeType: "application/octet-stream", + meta, + }; + } + + async computeHashes(rawData: Uint8Array): Promise<VerificationHashes> { + const [sha1, sha256] = await Promise.all([ + sha1Hex(rawData), + sha256Hex(rawData), + ]); + return { crc32: crc32(rawData), sha1, sha256, size: rawData.length }; + } + + verify( + hashes: VerificationHashes, + db: VerificationDB | null, + ): VerificationResult { + if (!db) return { matched: false, confidence: "none" }; + const entry = db.lookup(hashes); + if (entry) return { matched: true, entry, confidence: "exact" }; + return { matched: false, confidence: "none" }; + } + + /** + * Structural self-check, available with NO No-Intro database. The 2600 has no + * header and no stored checksum, so the only consistency signal is whether the + * bytes parse as a 2600 image at all: a valid bank size, a reset vector that + * points into cartridge ROM space, and the TIA video-timing writes every game + * must perform. We surface that as a NEUTRAL, explicitly-heuristic integrity + * result — never a No-Intro "verified". Null when the bytes don't look like a + * 2600 ROM (don't fabricate a verdict over noise). + */ + summarizeDump(rawData: Uint8Array): DumpSummary | null { + const info = parseAtari2600(rawData); + if (!info) return null; + + const rows: DumpSummaryCell[][] = [ + ["ROM size", formatRomSize(info.size)], + ["Layout", schemeLabel(info.scheme)], + [ + "Reset vector", + info.resetVectorValid ? "Points into ROM" : "Outside ROM space", + ], + ["TIA writes", `${info.tiaPatternCount} of 4 timing registers`], + ]; + + return { + title: "Atari 2600 image", + columns: ["Field", "Value"], + monoColumns: [1], + rows, + integrity: { + ok: true, + message: + "Heuristic check passed (no header/checksum on the 2600): valid " + + "bank size, reset vector in ROM, TIA writes present", + }, + }; + } +} diff --git a/src/lib/systems/gb/gb-header.ts b/src/lib/systems/gb/gb-header.ts index 45b298b..890fa6c 100644 --- a/src/lib/systems/gb/gb-header.ts +++ b/src/lib/systems/gb/gb-header.ts @@ -1,9 +1,26 @@ import { GB_HEADER, GB_CART_TYPES, GB_ROM_SIZES, GB_RAM_SIZES } from "./gb-constants"; import type { CartridgeInfo } from "@/lib/types"; +// References: +// Game Boy cartridge header: +// https://gbdev.io/pandocs/The_Cartridge_Header.html + +/** CGB-flag sentinels. */ +const CGB_FLAG_DUAL_MODE = 0x80; +const CGB_FLAG_EXCLUSIVE = 0xc0; +const SGB_SUPPORTED = 0x03; + +/** Destination code (0x14A) -> region label. */ +const GB_REGIONS: Record<number, string> = { + 0x00: "Japan", + 0x01: "Non-Japanese", +}; + export interface GBHeaderInfo { title: string; isCGB: boolean; + /** CGB support phrasing: "No", dual-mode, or GBC-exclusive. */ + cgbSupport: string; isSGB: boolean; cartType: number; cartTypeName: string; @@ -14,8 +31,41 @@ export interface GBHeaderInfo { romSize: number; ramSizeCode: number; ramSize: number; + /** Destination code (0x14A). */ + destinationCode: number; + region: string; + /** Mask ROM version number (0x14C). */ + version: number; + /** Stored header checksum (0x14D). */ headerChecksum: number; + /** Recomputed header checksum (fold of 0x134-0x14C). */ + computedHeaderChecksum: number; headerChecksumValid: boolean; + /** Stored 16-bit big-endian global checksum (0x14E-0x14F). */ + globalChecksum: number; + /** Recomputed global checksum: sum of all bytes except the two stored ones. */ + computedGlobalChecksum: number; + globalChecksumValid: boolean; +} + +/** GB header checksum: fold 0x134..0x14C with `acc = acc - byte - 1`. */ +function computeHeaderChecksum(data: Uint8Array): number { + return Array.from(data.subarray(GB_HEADER.TITLE, GB_HEADER.HEADER_CHECKSUM)).reduce( + (acc, byte) => (acc - byte - 1) & 0xff, + 0, + ); +} + +/** + * GB global checksum: 16-bit sum of every ROM byte EXCEPT the two stored + * checksum bytes at 0x14E/0x14F. Computed by subtracting those two stored + * bytes from a full-buffer byte sum (mod 0x10000). + */ +function computeGlobalChecksum(data: Uint8Array): number { + const total = data.reduce((acc, byte) => (acc + byte) & 0xffff, 0); + const stored = + (data[GB_HEADER.GLOBAL_CHECKSUM] + data[GB_HEADER.GLOBAL_CHECKSUM + 1]) & 0xffff; + return (total - stored) & 0xffff; } export function parseGBHeader(data: Uint8Array): GBHeaderInfo | null { @@ -33,10 +83,16 @@ export function parseGBHeader(data: Uint8Array): GBHeaderInfo | null { .replace(/[^\x20-\x7e]/g, ""); const cgbFlag = data[GB_HEADER.CGB_FLAG]; - const isCGB = cgbFlag === 0x80 || cgbFlag === 0xc0; + const isCGB = cgbFlag === CGB_FLAG_DUAL_MODE || cgbFlag === CGB_FLAG_EXCLUSIVE; + const cgbSupport = + cgbFlag === CGB_FLAG_DUAL_MODE + ? "Yes (Compatible with GB and GBC)" + : cgbFlag === CGB_FLAG_EXCLUSIVE + ? "Yes (GBC Exclusive)" + : "No"; const sgbFlag = data[GB_HEADER.SGB_FLAG]; - const isSGB = sgbFlag === 0x03; + const isSGB = sgbFlag === SGB_SUPPORTED; const cartType = data[GB_HEADER.CART_TYPE]; const cartInfo = GB_CART_TYPES[cartType] ?? { @@ -54,17 +110,24 @@ export function parseGBHeader(data: Uint8Array): GBHeaderInfo | null { const ramSize = cartInfo.mbc === "MBC2" ? 512 : (GB_RAM_SIZES[ramSizeCode] ?? 0); - // Verify header checksum (0x134-0x14C) - let checksum = 0; - for (let i = 0x134; i <= 0x14c; i++) { - checksum = (checksum - data[i] - 1) & 0xff; - } + const destinationCode = data[GB_HEADER.DEST_CODE]; + const region = GB_REGIONS[destinationCode] ?? "Unknown"; + + const version = data[GB_HEADER.VERSION]; + + const computedHeaderChecksum = computeHeaderChecksum(data); const headerChecksum = data[GB_HEADER.HEADER_CHECKSUM]; - const headerChecksumValid = checksum === headerChecksum; + const headerChecksumValid = computedHeaderChecksum === headerChecksum; + + const globalChecksum = + (data[GB_HEADER.GLOBAL_CHECKSUM] << 8) | data[GB_HEADER.GLOBAL_CHECKSUM + 1]; + const computedGlobalChecksum = computeGlobalChecksum(data); + const globalChecksumValid = computedGlobalChecksum === globalChecksum; return { title, isCGB, + cgbSupport, isSGB, cartType, cartTypeName: cartInfo.name, @@ -75,8 +138,15 @@ export function parseGBHeader(data: Uint8Array): GBHeaderInfo | null { romSize, ramSizeCode, ramSize, + destinationCode, + region, + version, headerChecksum, + computedHeaderChecksum, headerChecksumValid, + globalChecksum, + computedGlobalChecksum, + globalChecksumValid, }; } diff --git a/src/lib/systems/gb/gb-system-handler.test.ts b/src/lib/systems/gb/gb-system-handler.test.ts new file mode 100644 index 0000000..f39e45a --- /dev/null +++ b/src/lib/systems/gb/gb-system-handler.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from "vitest"; +import { GBSystemHandler } from "./gb-system-handler"; + +/** Build a 0x150-byte GB header with a valid checksum and the given title. */ +function buildGBRom(title: string): Uint8Array { + const buf = new Uint8Array(0x150); + for (let i = 0; i < title.length && i < 16; i++) { + buf[0x134 + i] = title.charCodeAt(i); + } + buf[0x147] = 0x1b; // MBC5+RAM+BATTERY + buf[0x148] = 0x00; // 32 KB + buf[0x149] = 0x03; // 32 KB + let checksum = 0; + for (let i = 0x134; i <= 0x14c; i++) checksum = (checksum - buf[i] - 1) & 0xff; + buf[0x14d] = checksum; + return buf; +} + +/** + * Build a fully self-consistent 0x150-byte GB header: valid header checksum AND + * valid 16-bit global checksum (sum of all bytes except the two stored ones), + * computed the same way the parser does so integrity.ok must be true. + */ +function buildSummarizableRom( + overrides: { + title?: string; + cartType?: number; + region?: number; + version?: number; + sgb?: boolean; + cgbFlag?: number; + } = {}, +): Uint8Array { + const buf = new Uint8Array(0x150); + const title = overrides.title ?? "TESTGAME"; + for (let i = 0; i < title.length && i < 16; i++) { + buf[0x134 + i] = title.charCodeAt(i); + } + buf[0x143] = overrides.cgbFlag ?? 0x00; // CGB flag + buf[0x144] = "0".charCodeAt(0); // new-licensee code byte 0 + buf[0x145] = "0".charCodeAt(0); // new-licensee code byte 1 + buf[0x146] = overrides.sgb ? 0x03 : 0x00; // SGB flag + buf[0x147] = overrides.cartType ?? 0x1b; // MBC5+RAM+BATTERY + buf[0x148] = 0x00; // ROM size: 32 KB + buf[0x149] = 0x03; // RAM size: 32 KB + buf[0x14a] = overrides.region ?? 0x01; // destination: Non-Japanese + buf[0x14b] = 0x01; // old-licensee code + buf[0x14c] = overrides.version ?? 0x00; // mask ROM version + + // Header checksum over 0x134..0x14C. + let hck = 0; + for (let i = 0x134; i <= 0x14c; i++) hck = (hck - buf[i] - 1) & 0xff; + buf[0x14d] = hck; + + // Global checksum: sum of every byte except the two stored bytes themselves. + // With both stored bytes left at 0 they contribute nothing, so the sum of the + // whole buffer IS the value to store (big-endian). + let gck = 0; + for (let i = 0; i < buf.length; i++) gck = (gck + buf[i]) & 0xffff; + buf[0x14e] = (gck >> 8) & 0xff; + buf[0x14f] = gck & 0xff; + return buf; +} + +describe("GBSystemHandler.headerTitle", () => { + const handler = new GBSystemHandler(); + + it("returns the title decoded from the cartridge header", () => { + expect(handler.headerTitle(buildGBRom("TESTGAME"))).toBe("TESTGAME"); + }); + + it("trims trailing whitespace from a space-padded title", () => { + // The header window is fixed-width; titles shorter than the field are + // sometimes space-padded rather than null-terminated. + expect(handler.headerTitle(buildGBRom("TESTGAME "))).toBe("TESTGAME"); + }); + + it("returns undefined for a buffer too small to hold a header", () => { + expect(handler.headerTitle(new Uint8Array(0x14f))).toBeUndefined(); + }); + + it("returns undefined when the header's title is blank", () => { + // Header parses (valid checksum) but the title window is all spaces -> the + // trimmed result is "" and must surface as undefined, not an empty stem. + expect(handler.headerTitle(buildGBRom(" "))).toBeUndefined(); + }); + + it("serves the gbc variant with the same parsing", () => { + const gbc = new GBSystemHandler("gbc"); + expect(gbc.headerTitle(buildGBRom("CGBTITLE"))).toBe("CGBTITLE"); + }); +}); + +describe("GBSystemHandler.summarizeDump (DB-free header self-check)", () => { + const handler = new GBSystemHandler(); + + it("reports valid integrity and the resolved header fields", () => { + const summary = handler.summarizeDump(buildSummarizableRom())!; + expect(summary).not.toBeNull(); + expect(summary.integrity).toEqual({ + ok: true, + message: "Header checksum valid", + }); + expect(summary.columns).toEqual(["Field", "Value"]); + expect(summary.monoColumns).toEqual([1]); + expect(summary.rows).toContainEqual(["Title", "TESTGAME"]); + expect(summary.rows).toContainEqual(["Region", "Non-Japanese"]); + expect(summary.rows).toContainEqual(["Cart type", "MBC5+RAM+BATTERY (0x1B)"]); + expect(summary.rows).toContainEqual(["ROM size", "32 KB"]); + expect(summary.rows).toContainEqual(["RAM size", "32 KB"]); + expect(summary.rows).toContainEqual(["Global checksum", expect.stringContaining("(ok)")]); + }); + + it("decodes SGB and CGB-exclusive flags", () => { + const summary = handler.summarizeDump( + buildSummarizableRom({ sgb: true, cgbFlag: 0xc0 }), + )!; + expect(summary.rows).toContainEqual(["SGB support", "Yes"]); + expect(summary.rows).toContainEqual(["CGB support", "Yes (GBC Exclusive)"]); + }); + + it("flags integrity false when a header byte changes after the checksum", () => { + const buf = buildSummarizableRom(); + buf[0x147] ^= 0xff; // mutate cart type without recomputing the header checksum + const summary = handler.summarizeDump(buf)!; + expect(summary.integrity?.ok).toBe(false); + expect(summary.integrity?.message).toBe( + "Game Boy header checksum mismatch", + ); + }); + + it("returns null when the buffer is too small to hold a header", () => { + expect(handler.summarizeDump(new Uint8Array(0x14f))).toBeNull(); + }); + + it("serves the gbc variant with the same summary", () => { + const gbc = new GBSystemHandler("gbc"); + const summary = gbc.summarizeDump(buildSummarizableRom({ title: "CGB" }))!; + expect(summary.integrity?.ok).toBe(true); + expect(summary.rows).toContainEqual(["Title", "CGB"]); + }); +}); diff --git a/src/lib/systems/gb/gb-system-handler.ts b/src/lib/systems/gb/gb-system-handler.ts index f6bccc7..6dfeef1 100644 --- a/src/lib/systems/gb/gb-system-handler.ts +++ b/src/lib/systems/gb/gb-system-handler.ts @@ -2,6 +2,8 @@ import type { SystemHandler, ConfigValues, CartridgeInfo, + DumpSummary, + DumpSummaryCell, ResolvedConfigField, ValidationResult, ReadConfig, @@ -11,6 +13,10 @@ import type { VerificationResult, } from "@/lib/types"; import { crc32, sha1Hex, sha256Hex, formatBytes } from "@/lib/core/hashing"; +import { parseGBHeader } from "./gb-header"; + +const hex2 = (n: number) => `0x${n.toString(16).toUpperCase().padStart(2, "0")}`; +const hex4 = (n: number) => `0x${n.toString(16).toUpperCase().padStart(4, "0")}`; export class GBSystemHandler implements SystemHandler { readonly systemId: string; @@ -114,6 +120,65 @@ export class GBSystemHandler implements SystemHandler { }; } + /** + * Title parsed from the cartridge header at 0x134 (parseGBHeader stops at the + * null terminator and drops non-printable bytes). GB titles are space-padded + * within their fixed window, so we trim for a clean filename stem and return + * undefined when no header is present or the title is blank. Serves both gb + * and gbc; saveFile sanitizes reserved chars downstream. + */ + headerTitle(content: Uint8Array): string | undefined { + const title = parseGBHeader(content)?.title.trim(); + return title ? title : undefined; + } + + /** + * Cartridge-header self-check, available with NO No-Intro database. The GB + * header carries an 8-bit checksum over 0x134-0x14C; a clean dump satisfies + * the fold (acc = acc - byte - 1, seed 0). Integrity is keyed on that header + * checksum (the only field guaranteed to cover a fixed range). The global + * checksum is surfaced for context but not used as the integrity verdict — + * an over-/under-dump trivially fails it while the header is still pristine. + * Null when no header is present (don't fabricate fields over noise). Serves + * both gb and gbc systemIds. + */ + summarizeDump(rawData: Uint8Array): DumpSummary | null { + const h = parseGBHeader(rawData); + if (!h) return null; + + const rows: DumpSummaryCell[][] = [ + ["Title", h.title || "(none)"], + ["Region", h.region], + ["Cart type", `${h.cartTypeName} (${hex2(h.cartType)})`], + ["ROM size", formatBytes(h.romSize)], + ["RAM size", h.ramSize > 0 ? formatBytes(h.ramSize) : "None"], + ["SGB support", h.isSGB ? "Yes" : "No"], + ["CGB support", h.cgbSupport], + ["Version", String(h.version)], + [ + "Header checksum", + `${hex2(h.headerChecksum)}${h.headerChecksumValid ? " (ok)" : " (mismatch)"}`, + ], + [ + "Global checksum", + `${hex4(h.globalChecksum)}${h.globalChecksumValid ? " (ok)" : " (mismatch)"}`, + ], + ]; + + return { + title: "Game Boy header", + columns: ["Field", "Value"], + monoColumns: [1], + rows, + integrity: { + ok: h.headerChecksumValid, + message: h.headerChecksumValid + ? "Header checksum valid" + : "Game Boy header checksum mismatch", + }, + }; + } + buildOutputFile(rawData: Uint8Array, _config: ReadConfig): OutputFile { // GB/GBC ROMs are raw — no header to prepend return { diff --git a/src/lib/systems/gba/gba-header.test.ts b/src/lib/systems/gba/gba-header.test.ts index 39762dd..3e9c1c6 100644 --- a/src/lib/systems/gba/gba-header.test.ts +++ b/src/lib/systems/gba/gba-header.test.ts @@ -1,9 +1,14 @@ import { describe, it, expect } from "vitest"; -import { parseGBAHeader } from "./gba-header"; +import { + gbaHeaderChecksum, + isGBARom, + parseGBAHeader, + regionFromGameCode, +} from "./gba-header"; /** Build a 0xC0-byte GBA header with a valid checksum, plus any overrides. */ function buildGBAHeader( - overrides: { title?: string; gameCode?: string; makerCode?: string } = {}, + overrides: { title?: string; gameCode?: string } = {}, ): Uint8Array { const buf = new Uint8Array(0xc0); const write = (offset: number, s: string, max: number) => { @@ -13,10 +18,8 @@ function buildGBAHeader( }; write(0xa0, overrides.title ?? "TESTGAME", 12); write(0xac, overrides.gameCode ?? "TEST", 4); - write(0xb0, overrides.makerCode ?? "ZZ", 2); - let checksum = 0; - for (let i = 0xa0; i <= 0xbc; i++) checksum = (checksum + buf[i]) & 0xff; - buf[0xbd] = (-(checksum + 0x19)) & 0xff; + buf[0xb2] = 0x96; // fixed value + buf[0xbd] = gbaHeaderChecksum(buf); // recompute over the populated bytes return buf; } @@ -35,12 +38,52 @@ describe("parseGBAHeader", () => { expect(parseGBAHeader(buf)!.headerChecksumValid).toBe(false); }); - it("decodes title, game code, and maker code", () => { + it("decodes title and game code", () => { const header = parseGBAHeader( - buildGBAHeader({ title: "TESTROM", gameCode: "ZZZZ", makerCode: "00" }), + buildGBAHeader({ title: "TESTROM", gameCode: "ZZZZ" }), )!; expect(header.title).toBe("TESTROM"); expect(header.gameCode).toBe("ZZZZ"); - expect(header.makerCode).toBe("00"); + }); + + it("resolves the region from the 4th game-code character", () => { + expect(parseGBAHeader(buildGBAHeader({ gameCode: "AAAE" }))!.region).toBe( + "North America", + ); + expect(parseGBAHeader(buildGBAHeader({ gameCode: "AAAJ" }))!.region).toBe( + "Japan", + ); + expect(parseGBAHeader(buildGBAHeader({ gameCode: "AAAP" }))!.region).toBe( + "Europe", + ); + }); + + it("reports Unknown region for an unmapped destination letter", () => { + expect(parseGBAHeader(buildGBAHeader({ gameCode: "AAAZ" }))!.region).toBe( + "Unknown", + ); + }); +}); + +describe("isGBARom", () => { + it("accepts a well-formed header", () => { + expect(isGBARom(buildGBAHeader())).toBe(true); + }); + + it("rejects noise without the 0x96 fixed byte", () => { + expect(isGBARom(new Uint8Array(0xc0).fill(0xa5))).toBe(false); + }); + + it("rejects a header whose stored checksum is wrong", () => { + const buf = buildGBAHeader(); + buf[0xbd] ^= 0xff; // corrupt only the stored checksum byte + expect(isGBARom(buf)).toBe(false); + }); +}); + +describe("regionFromGameCode", () => { + it("falls back to Unknown for short/blank game codes", () => { + expect(regionFromGameCode("")).toBe("Unknown"); + expect(regionFromGameCode("ABC")).toBe("Unknown"); }); }); diff --git a/src/lib/systems/gba/gba-header.ts b/src/lib/systems/gba/gba-header.ts index 1280abe..bb918ce 100644 --- a/src/lib/systems/gba/gba-header.ts +++ b/src/lib/systems/gba/gba-header.ts @@ -1,12 +1,15 @@ import type { CartridgeInfo } from "@/lib/types"; +// References: +// GBA cartridge header: +// https://problemkaputt.de/gbatek.htm#gbacartridgeheader + // GBA cartridge header offsets export const GBA_HEADER = { ENTRY_POINT: 0x00, LOGO: 0x04, - TITLE: 0xa0, // 12 bytes - GAME_CODE: 0xac, // 4 bytes - MAKER_CODE: 0xb0, // 2 bytes + TITLE: 0xa0, // 12 bytes + GAME_CODE: 0xac, // 4 bytes FIXED_96H: 0xb2, UNIT_CODE: 0xb3, DEVICE_TYPE: 0xb4, @@ -14,56 +17,99 @@ export const GBA_HEADER = { CHECKSUM: 0xbd, } as const; +const HEADER_SIZE = 0xc0; +const TITLE_LEN = 12; +const GAME_CODE_LEN = 4; +const FIXED_VALUE = 0x96; +// The header checksum folds 0xE7 - b over [0xA0..0xBD); equivalently +// -(sum(0xA0..=0xBC) + 0x19), which is the form the verifier below uses. +const CHECKSUM_SEED = 0x19; +const CHECKSUM_START = 0xa0; +const CHECKSUM_END = 0xbc; // inclusive — the byte just below CHECKSUM + +/** + * Region inferred from the 4th game-code character, the standard Nintendo + * destination letter. Unmapped letters resolve to "Unknown". + */ +export const GBA_REGION_BY_GAME_CODE: Record<string, string> = { + J: "Japan", + E: "North America", + P: "Europe", + D: "Germany", + F: "France", + I: "Italy", + S: "Spain", + H: "Netherlands", + K: "Korea", + A: "Asia", + O: "Global", +}; + export interface GBAHeaderInfo { title: string; gameCode: string; - makerCode: string; + /** Region resolved from the 4th game-code character. */ + region: string; version: number; headerChecksum: number; + /** The checksum recomputed over the covered header bytes. */ + computedChecksum: number; headerChecksumValid: boolean; } -export function parseGBAHeader(data: Uint8Array): GBAHeaderInfo | null { - if (data.length < 0xc0) return null; +function decodeAscii(data: Uint8Array, start: number, len: number): string { + return new TextDecoder("ascii") + .decode(data.slice(start, start + len)) + .replace(/[^\x20-\x7e]/g, ""); +} + +/** Region from a game code's 4th destination character (undefined if blank). */ +export function regionFromGameCode(gameCode: string): string { + const dest = gameCode[3]; + return (dest && GBA_REGION_BY_GAME_CODE[dest]) ?? "Unknown"; +} - // Title at 0xA0, 12 bytes, null-terminated ASCII - let titleEnd = GBA_HEADER.TITLE; - for (let i = 0; i < 12; i++) { - if (data[GBA_HEADER.TITLE + i] === 0) break; - titleEnd = GBA_HEADER.TITLE + i + 1; +/** + * Recompute the GBA header checksum: fold 0xE7 - b over [0xA0..0xBD), which + * equals -(sum(0xA0..=0xBC) + 0x19) & 0xFF. + */ +export function gbaHeaderChecksum(data: Uint8Array): number { + let sum = 0; + for (let i = CHECKSUM_START; i <= CHECKSUM_END; i++) { + sum = (sum + data[i]) & 0xff; } - const title = new TextDecoder("ascii") - .decode(data.slice(GBA_HEADER.TITLE, titleEnd)) - .replace(/[^\x20-\x7e]/g, ""); + return -(sum + CHECKSUM_SEED) & 0xff; +} - // Game code at 0xAC, 4 bytes - const gameCode = new TextDecoder("ascii") - .decode(data.slice(GBA_HEADER.GAME_CODE, GBA_HEADER.GAME_CODE + 4)) - .replace(/[^\x20-\x7e]/g, ""); +/** + * True when the buffer carries a structurally valid GBA header: the 0x96 fixed + * byte at 0xB2 and a self-consistent header checksum. Used to avoid summarizing + * noise as a header. + */ +export function isGBARom(data: Uint8Array): boolean { + if (data.length < HEADER_SIZE || data[GBA_HEADER.FIXED_96H] !== FIXED_VALUE) { + return false; + } + return gbaHeaderChecksum(data) === data[GBA_HEADER.CHECKSUM]; +} - // Maker code at 0xB0, 2 bytes - const makerCode = new TextDecoder("ascii") - .decode(data.slice(GBA_HEADER.MAKER_CODE, GBA_HEADER.MAKER_CODE + 2)) - .replace(/[^\x20-\x7e]/g, ""); +export function parseGBAHeader(data: Uint8Array): GBAHeaderInfo | null { + if (data.length < HEADER_SIZE) return null; + const title = decodeAscii(data, GBA_HEADER.TITLE, TITLE_LEN); + const gameCode = decodeAscii(data, GBA_HEADER.GAME_CODE, GAME_CODE_LEN); const version = data[GBA_HEADER.VERSION]; - - // Header checksum: complement of sum of bytes 0xA0-0xBC - let checksum = 0; - for (let i = 0xa0; i <= 0xbc; i++) { - checksum = (checksum + data[i]) & 0xff; - } - checksum = (-(checksum + 0x19)) & 0xff; + const computedChecksum = gbaHeaderChecksum(data); const headerChecksum = data[GBA_HEADER.CHECKSUM]; - const headerChecksumValid = checksum === headerChecksum; return { title, gameCode, - makerCode, + region: regionFromGameCode(gameCode), version, headerChecksum, - headerChecksumValid, + computedChecksum, + headerChecksumValid: computedChecksum === headerChecksum, }; } @@ -73,7 +119,7 @@ export function gbaHeaderToCartridgeInfo(header: GBAHeaderInfo): CartridgeInfo { mapper: { id: 0, name: "None" }, meta: { gameCode: header.gameCode, - makerCode: header.makerCode, + region: header.region, version: header.version, headerChecksumValid: header.headerChecksumValid, }, diff --git a/src/lib/systems/gba/gba-system-handler.test.ts b/src/lib/systems/gba/gba-system-handler.test.ts new file mode 100644 index 0000000..c2aff4f --- /dev/null +++ b/src/lib/systems/gba/gba-system-handler.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "vitest"; +import { GBASystemHandler } from "./gba-system-handler"; + +interface HeaderOverrides { + title?: string; + gameCode?: string; +} + +/** Build a 0xC0-byte GBA header with a valid checksum and the 0x96 fixed byte. */ +function buildGBAHeader(o: string | HeaderOverrides = {}): Uint8Array { + const overrides: HeaderOverrides = typeof o === "string" ? { title: o } : o; + const buf = new Uint8Array(0xc0); + const write = (offset: number, s: string, max: number) => { + for (let i = 0; i < s.length && i < max; i++) { + buf[offset + i] = s.charCodeAt(i); + } + }; + if (overrides.title) write(0xa0, overrides.title, 12); + if (overrides.gameCode) write(0xac, overrides.gameCode, 4); + buf[0xb2] = 0x96; // fixed value, required by isGBARom + let checksum = 0; + for (let i = 0xa0; i <= 0xbc; i++) checksum = (checksum + buf[i]) & 0xff; + buf[0xbd] = -(checksum + 0x19) & 0xff; + return buf; +} + +describe("GBASystemHandler.headerTitle", () => { + const handler = new GBASystemHandler(); + + it("returns the title parsed from a well-formed header", () => { + expect(handler.headerTitle(buildGBAHeader("TESTROM"))).toBe("TESTROM"); + }); + + it("returns undefined for a buffer shorter than the header", () => { + expect(handler.headerTitle(new Uint8Array(0xbf))).toBeUndefined(); + }); + + it("returns undefined when the title field is blank", () => { + // All-zero title bytes parse to an empty string. + expect(handler.headerTitle(buildGBAHeader(""))).toBeUndefined(); + }); + + it("returns undefined when the title is only padding/whitespace", () => { + expect(handler.headerTitle(buildGBAHeader(" "))).toBeUndefined(); + }); + + it("trims surrounding whitespace from the title", () => { + expect(handler.headerTitle(buildGBAHeader(" PAD GAME "))).toBe( + "PAD GAME", + ); + }); + + it("strips non-printable bytes embedded in the title", () => { + const buf = buildGBAHeader("AB"); + buf[0xa2] = 0x07; // a control byte mid-title (parser strips non-printable) + buf[0xa3] = "C".charCodeAt(0); + // checksum no longer matters for headerTitle; recompute is unnecessary + expect(handler.headerTitle(buf)).toBe("ABC"); + }); +}); + +describe("GBASystemHandler.summarizeDump", () => { + const handler = new GBASystemHandler(); + + it("summarizes a well-formed header with integrity ok", () => { + const summary = handler.summarizeDump( + buildGBAHeader({ title: "TESTROM", gameCode: "AXYE" }), + )!; + expect(summary.columns).toEqual(["Field", "Value"]); + expect(summary.monoColumns).toEqual([1]); + expect(summary.integrity).toEqual({ + ok: true, + message: "Header checksum consistent", + }); + const fields = Object.fromEntries( + summary.rows.map((r) => [r[0], r[1]]), + ) as Record<string, string>; + expect(fields.Title).toBe("TESTROM"); + expect(fields["Game Code"]).toBe("AXYE"); + expect(fields.Region).toBe("North America"); + expect(fields["Header Checksum"]).toMatch(/^0x[0-9A-F]{2}$/); + }); + + it("flags integrity when a covered byte is corrupted", () => { + const buf = buildGBAHeader({ title: "TESTROM" }); + // Corrupt a covered byte but keep the 0x96 fixed marker, so the header is + // still recognized and the bad checksum surfaces as an integrity failure. + buf[0xa0] ^= 0xff; + const summary = handler.summarizeDump(buf)!; + expect(summary.integrity).toEqual({ + ok: false, + message: "GBA internal checksum mismatch", + }); + }); + + it("returns null for noise with no valid header", () => { + expect(handler.summarizeDump(new Uint8Array(0xc0).fill(0xa5))).toBeNull(); + }); + + it("returns null for a buffer shorter than the header", () => { + expect(handler.summarizeDump(new Uint8Array(0xbf))).toBeNull(); + }); +}); diff --git a/src/lib/systems/gba/gba-system-handler.ts b/src/lib/systems/gba/gba-system-handler.ts index b7a48db..3687cc0 100644 --- a/src/lib/systems/gba/gba-system-handler.ts +++ b/src/lib/systems/gba/gba-system-handler.ts @@ -2,6 +2,7 @@ import type { SystemHandler, ConfigValues, CartridgeInfo, + DumpSummary, ResolvedConfigField, ValidationResult, ReadConfig, @@ -11,15 +12,18 @@ import type { VerificationResult, } from "@/lib/types"; import { crc32, sha1Hex, sha256Hex, formatBytes } from "@/lib/core/hashing"; +import { GBA_HEADER, parseGBAHeader } from "./gba-header"; + +const GBA_FIXED_VALUE = 0x96; // Common GBA ROM sizes const GBA_ROM_SIZES = [ - 1 * 1024 * 1024, // 1 MB - 2 * 1024 * 1024, // 2 MB - 4 * 1024 * 1024, // 4 MB - 8 * 1024 * 1024, // 8 MB - 16 * 1024 * 1024, // 16 MB - 32 * 1024 * 1024, // 32 MB + 1 * 1024 * 1024, // 1 MB + 2 * 1024 * 1024, // 2 MB + 4 * 1024 * 1024, // 4 MB + 8 * 1024 * 1024, // 8 MB + 16 * 1024 * 1024, // 16 MB + 32 * 1024 * 1024, // 32 MB ]; export class GBASystemHandler implements SystemHandler { @@ -35,7 +39,10 @@ export class GBASystemHandler implements SystemHandler { const detected = autoDetected != null; // Options first — ROM size and save type - const romSize = (currentValues.romSizeBytes as number) ?? autoDetected?.romSize ?? 16 * 1024 * 1024; + const romSize = + (currentValues.romSizeBytes as number) ?? + autoDetected?.romSize ?? + 16 * 1024 * 1024; fields.push({ key: "romSizeBytes", label: "ROM Size", @@ -79,7 +86,12 @@ export class GBASystemHandler implements SystemHandler { return { valid: false, errors: [ - { field: "romSizeBytes", message: "ROM size is required.", code: "NO_ROM_SIZE", severity: "error" }, + { + field: "romSizeBytes", + message: "ROM size is required.", + code: "NO_ROM_SIZE", + severity: "error", + }, ], }; } @@ -107,18 +119,74 @@ export class GBASystemHandler implements SystemHandler { } async computeHashes(rawData: Uint8Array): Promise<VerificationHashes> { - const [sha1, sha256] = await Promise.all([sha1Hex(rawData), sha256Hex(rawData)]); + const [sha1, sha256] = await Promise.all([ + sha1Hex(rawData), + sha256Hex(rawData), + ]); return { crc32: crc32(rawData), sha1, sha256, size: rawData.length }; } - verify(hashes: VerificationHashes, db: VerificationDB | null): VerificationResult { + headerTitle(content: Uint8Array): string | undefined { + const title = parseGBAHeader(content)?.title.trim(); + return title ? title : undefined; + } + + /** + * Internal-header self-check, available with NO No-Intro database. The GBA + * header carries an 8-bit checksum over bytes 0xA0..0xBC; a clean dump's + * recomputed checksum equals the stored value. Surfaced as a neutral + * integrity result, never a No-Intro "verified". Gated on the 0x96 fixed + * marker at 0xB2 so we don't fabricate fields over noise; a marked-but- + * corrupt header still summarizes, with integrity reporting the mismatch. + */ + summarizeDump(rawData: Uint8Array): DumpSummary | null { + if ( + rawData.length < 0xc0 || + rawData[GBA_HEADER.FIXED_96H] !== GBA_FIXED_VALUE + ) { + return null; + } + const header = parseGBAHeader(rawData); + if (!header) return null; + + const rows: [string, string][] = []; + const title = header.title.trim(); + if (title) rows.push(["Title", title]); + if (header.gameCode) rows.push(["Game Code", header.gameCode]); + rows.push(["Region", header.region]); + rows.push(["Version", `1.${header.version}`]); + rows.push([ + "Header Checksum", + `0x${header.headerChecksum.toString(16).toUpperCase().padStart(2, "0")}`, + ]); + + return { + title: "GBA header", + columns: ["Field", "Value"], + monoColumns: [1], + rows, + integrity: { + ok: header.headerChecksumValid, + message: header.headerChecksumValid + ? "Header checksum consistent" + : "GBA internal checksum mismatch", + }, + }; + } + + verify( + hashes: VerificationHashes, + db: VerificationDB | null, + ): VerificationResult { if (!db) return { matched: false, confidence: "none" }; const entry = db.lookup(hashes); if (entry) return { matched: true, entry, confidence: "exact" }; return { matched: false, confidence: "none", - suggestions: ["Verify ROM size. GBA ROMs can be auto-detected by reading until 0xFF padding."], + suggestions: [ + "Verify ROM size. GBA ROMs can be auto-detected by reading until 0xFF padding.", + ], }; } } diff --git a/src/lib/systems/genesis/genesis-header.test.ts b/src/lib/systems/genesis/genesis-header.test.ts new file mode 100644 index 0000000..5bc45a0 --- /dev/null +++ b/src/lib/systems/genesis/genesis-header.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect } from "vitest"; +import { GenesisSystemHandler } from "./genesis-system-handler"; +import { + is32xRom, + isGenesisRom, + parseSegaHeader, + segaChecksum, +} from "./genesis-header"; + +const HEADER_BYTES = 0x200; + +/** Deterministic, well-varied byte at index i (avoids accidental fill tails). */ +const fillByte = (i: number) => (i ^ (i >>> 5) ^ (i >>> 11) ^ 0x3c) & 0xff; + +/** Write an ASCII string into a fixed-width field, space-padded. */ +function writeField(d: Uint8Array, off: number, len: number, text: string) { + for (let i = 0; i < len; i++) { + d[off + i] = i < text.length ? text.charCodeAt(i) : 0x20; + } +} + +function be16set(d: Uint8Array, off: number, v: number) { + d[off] = (v >> 8) & 0xff; + d[off + 1] = v & 0xff; +} + +function be32set(d: Uint8Array, off: number, v: number) { + d[off] = (v >>> 24) & 0xff; + d[off + 1] = (v >>> 16) & 0xff; + d[off + 2] = (v >>> 8) & 0xff; + d[off + 3] = v & 0xff; +} + +/** + * Build a synthetic Genesis ROM with a complete internal header and a VALID + * stored checksum (computed exactly as the parser does over $200..ROM-end). + * `consoleName` selects the signature ("SEGA GENESIS " or "SEGA 32X "). + */ +function buildRom( + size: number, + consoleName: string, + opts: { + copyright?: string; + domestic?: string; + overseas?: string; + device?: string; + region?: string; + } = {}, +): Uint8Array { + const d = new Uint8Array(size); + for (let i = HEADER_BYTES; i < size; i++) d[i] = fillByte(i); + + writeField(d, 0x100, 16, consoleName); + writeField(d, 0x110, 16, opts.copyright ?? "(C)T-12 1993.SEP"); + writeField(d, 0x120, 48, opts.domestic ?? "DOMESTIC TITLE"); + writeField(d, 0x150, 48, opts.overseas ?? "OVERSEAS TITLE"); + writeField(d, 0x190, 16, opts.device ?? "J"); + writeField(d, 0x1f0, 4, opts.region ?? "JUE"); + be32set(d, 0x1a0, 0x000000); // ROM start + be32set(d, 0x1a4, size - 1); // ROM end (inclusive) + + // Compute and store the checksum last, over the finalized body. + be16set(d, 0x18e, segaChecksum(d)); + return d; +} + +describe("parseSegaHeader", () => { + it("parses a full Genesis header with a valid stored checksum", () => { + const rom = buildRom(0x40000, "SEGA GENESIS "); + const info = parseSegaHeader(rom)!; + expect(info.console).toBe("genesis"); + expect(info.domesticTitle).toBe("DOMESTIC TITLE"); + expect(info.overseasTitle).toBe("OVERSEAS TITLE"); + expect(info.copyright).toBe("(C)T-12 1993.SEP"); + expect(info.regions).toBe("JUE"); + expect(info.regionDescription).toBe("Japan, USA, Europe"); + expect(info.checksumValid).toBe(true); + expect(info.calculatedChecksum).toBe(info.storedChecksum); + }); + + it("recognises the 32X signature", () => { + const rom = buildRom(0x40000, "SEGA 32X "); + const info = parseSegaHeader(rom)!; + expect(info.console).toBe("32x"); + expect(info.checksumValid).toBe(true); + }); + + it("accepts the alternate MEGA DRIVE signature", () => { + const rom = buildRom(0x40000, "SEGA MEGA DRIVE "); + expect(parseSegaHeader(rom)!.console).toBe("genesis"); + }); + + it("flags a checksum mismatch when body bytes change after the sum is set", () => { + const rom = buildRom(0x40000, "SEGA GENESIS "); + rom[0x300] ^= 0xff; // alter a summed byte + const info = parseSegaHeader(rom)!; + expect(info.checksumValid).toBe(false); + }); + + it("returns null for noise (no SEGA signature)", () => { + const noise = new Uint8Array(0x40000); + for (let i = 0; i < noise.length; i++) noise[i] = (i * 91) & 0xff; + expect(parseSegaHeader(noise)).toBeNull(); + }); + + it("returns null for a buffer too small to hold a header", () => { + expect(parseSegaHeader(new Uint8Array(0x100))).toBeNull(); + }); +}); + +describe("isGenesisRom / is32xRom", () => { + it("differentiates Genesis from 32X by the signature field", () => { + const gen = buildRom(0x40000, "SEGA GENESIS "); + const x32 = buildRom(0x40000, "SEGA 32X "); + expect(isGenesisRom(gen)).toBe(true); + expect(is32xRom(gen)).toBe(false); + expect(is32xRom(x32)).toBe(true); + expect(isGenesisRom(x32)).toBe(false); + }); +}); + +describe("GenesisSystemHandler.summarizeDump (DB-free internal-checksum check)", () => { + const handler = new GenesisSystemHandler(); + + it("reports a consistent checksum and surfaces the parsed header fields", () => { + const rom = buildRom(0x40000, "SEGA GENESIS "); + const summary = handler.summarizeDump(rom)!; + expect(summary).not.toBeNull(); + expect(summary.integrity).toEqual({ + ok: true, + message: "Internal checksum consistent", + }); + expect(summary.rows).toContainEqual([ + "Console", + "Sega Genesis / Mega Drive", + ]); + expect(summary.rows).toContainEqual(["Overseas title", "OVERSEAS TITLE"]); + expect(summary.rows).toContainEqual(["Copyright", "(C)T-12 1993.SEP"]); + expect(summary.rows).toContainEqual(["Region", "Japan, USA, Europe (JUE)"]); + }); + + it("labels a 32X dump's console correctly", () => { + const rom = buildRom(0x40000, "SEGA 32X "); + const summary = handler.summarizeDump(rom)!; + expect(summary.rows).toContainEqual(["Console", "Sega 32X"]); + }); + + it("reports a mismatch when a summed byte changes after the checksum was set", () => { + const rom = buildRom(0x40000, "SEGA GENESIS "); + rom[0x400] ^= 0xff; + const summary = handler.summarizeDump(rom)!; + expect(summary.integrity?.ok).toBe(false); + expect(summary.integrity?.message).toBe( + "Genesis internal checksum mismatch", + ); + }); + + it("returns null when no header is detected (noise)", () => { + const noise = new Uint8Array(0x40000); + for (let i = 0; i < noise.length; i++) noise[i] = (i * 91) & 0xff; + expect(handler.summarizeDump(noise)).toBeNull(); + }); +}); diff --git a/src/lib/systems/genesis/genesis-header.ts b/src/lib/systems/genesis/genesis-header.ts new file mode 100644 index 0000000..934793b --- /dev/null +++ b/src/lib/systems/genesis/genesis-header.ts @@ -0,0 +1,196 @@ +/** + * Sega Mega Drive / Genesis (and 32X) internal-header parsing and the stored + * 16-bit checksum, faithful to the on-disk cartridge header at $100..$1FF. + * Pure functions, no DOM dependencies — this is the rich descriptive parser + * behind the post-dump summary, distinct from genesis-rom.ts (which owns + * over-dump trimming and filename/title selection). + * + * References: + * Sega Mega Drive / Genesis ROM header format: + * https://segaretro.org/Sega_Mega_Drive/ROM_format + * Checksum algorithm: + * https://segaretro.org/Sega_Mega_Drive/Checksum + * Region codes and device support strings: + * https://plutiedev.com/rom-header#region + * + * The cart is 16-bit big-endian on the 68000 bus; a Retrode-class dumper writes + * a PLAIN LINEAR big-endian .bin, so every multi-byte field below is big-endian + * and the header sits at a fixed offset (no LoROM/HiROM candidate selection). + */ + +// Sega cartridge header layout (0x100..0x1FF), shared by Mega Drive/Genesis +// and 32X. +const HEADER_MIN_BYTES = 0x200; +const CONSOLE_NAME_START = 0x100; +const CONSOLE_NAME_END = 0x110; +const COPYRIGHT_START = 0x110; +const COPYRIGHT_END = 0x120; +const DOMESTIC_TITLE_START = 0x120; +const DOMESTIC_TITLE_END = 0x150; +const OVERSEAS_TITLE_START = 0x150; +const OVERSEAS_TITLE_END = 0x180; +const STORED_CHECKSUM_START = 0x18e; +const DEVICE_SUPPORT_START = 0x190; +const DEVICE_SUPPORT_END = 0x1a0; +const ROM_SIZE_START = 0x1a4; +const REGIONS_START = 0x1f0; +const REGIONS_END = 0x1f4; + +// The 16-bit word checksum sums from 0x200 (the byte after the header) to EOF. +const CHECKSUM_DATA_START = HEADER_MIN_BYTES; + +// Console identity strings stored at 0x100..0x10F in the Sega header. +const CONSOLE_NAME_32X = "SEGA 32X "; +const CONSOLE_NAME_MEGA_DRIVE = "SEGA MEGA DRIVE "; +const CONSOLE_NAME_GENESIS = "SEGA GENESIS "; + +export type SegaConsole = "genesis" | "32x"; + +const CONSOLE_LABELS: Record<SegaConsole, string> = { + genesis: "Sega Genesis / Mega Drive", + "32x": "Sega 32X", +}; + +/** Human label for the detected console variant. */ +export function consoleLabel(console: SegaConsole): string { + return CONSOLE_LABELS[console]; +} + +export interface SegaRomInfo { + /** Which Sega console the header signature identifies. */ + console: SegaConsole; + /** Domestic (Japanese) title field, $120-$14F, trimmed. */ + domesticTitle: string; + /** Overseas (international) title field, $150-$17F, trimmed. */ + overseasTitle: string; + /** Copyright / release string, $110-$11F, e.g. "(C)T-12 1993.SEP". */ + copyright: string; + /** Device-support string, $190-$19F (which peripherals the game uses). */ + deviceSupport: string; + /** Raw region code field, $1F0-$1F3, uppercased and NUL-trimmed. */ + regions: string; + /** Human description of the region codes, e.g. "Japan, USA, Europe". */ + regionDescription: string; + /** Stored 16-bit checksum word at $18E (big-endian). */ + storedChecksum: number; + /** Recomputed 16-bit word-sum checksum over $200..end. */ + calculatedChecksum: number; + /** True when the recomputed checksum equals the stored one. */ + checksumValid: boolean; +} + +/** + * Decode a fixed-width header text field to printable ASCII: non-printable + * bytes become spaces, runs of whitespace collapse, and the result is trimmed. + * (The format's overseas title, copyright, device-support and region fields are + * ASCII; a domestic title may carry Shift-JIS, which collapses to spaces here.) + */ +function decodeField(data: Uint8Array, start: number, end: number): string { + let s = ""; + for (let i = start; i < end; i++) { + const b = data[i]; + s += b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : " "; + } + return s.replace(/\s+/g, " ").trim(); +} + +/** Raw 16-byte console-identity string at $100, exact (no trimming). */ +function consoleName(data: Uint8Array): string { + let s = ""; + for (let i = CONSOLE_NAME_START; i < CONSOLE_NAME_END; i++) { + s += String.fromCharCode(data[i]); + } + return s; +} + +/** True when the header signature marks a Mega Drive / Genesis cartridge. */ +export function isGenesisRom(data: Uint8Array): boolean { + if (data.length < HEADER_MIN_BYTES) return false; + const name = consoleName(data); + return name === CONSOLE_NAME_MEGA_DRIVE || name === CONSOLE_NAME_GENESIS; +} + +/** True when the header signature marks a 32X cartridge. */ +export function is32xRom(data: Uint8Array): boolean { + if (data.length < HEADER_MIN_BYTES) return false; + return consoleName(data) === CONSOLE_NAME_32X; +} + +/** + * Region-code field → human description. The field mixes letter codes (J/U/E) + * and the older hex-bitmask codes (1/4/5/8/F); both are recognised. + */ +function describeRegions(regions: string): string { + const has = (c: string) => regions.includes(c); + if (has("J") && has("U") && has("E")) return "Japan, USA, Europe"; + if (has("J") && has("U")) return "Japan, USA"; + if (has("J") && has("E")) return "Japan, Europe"; + if (has("U") && has("E")) return "USA, Europe"; + if (has("J")) return "Japan"; + if (has("U")) return "USA"; + if (has("E")) return "Europe"; + if (has("1")) return "NTSC-J"; + if (has("4")) return "NTSC-U"; + if (has("5")) return "NTSC"; + if (has("8")) return "PAL"; + if (has("F")) return "All Consoles"; + return "Unknown"; +} + +/** + * The internal $18E checksum: a uniform 16-bit big-endian word sum over + * $200..end (the bytes after the 0x200-byte header), mod 0x10000. The cart + * header and 68000 vectors ($000-$1FF) are excluded. + * + * When the header carries the ROM-end address ($1A4, inclusive) and it falls + * within the buffer, the sum stops there — matching real hardware, which sums + * only the populated mask ROM and ignores any over-dumped mirror/fill tail. + */ +export function segaChecksum(data: Uint8Array): number { + let limit = data.length; + if (data.length >= HEADER_MIN_BYTES) { + const romEnd = + ((data[ROM_SIZE_START] << 24) | + (data[ROM_SIZE_START + 1] << 16) | + (data[ROM_SIZE_START + 2] << 8) | + data[ROM_SIZE_START + 3]) >>> + 0; + const romSize = romEnd + 1; + if (romSize >= CHECKSUM_DATA_START && romSize < limit) limit = romSize; + } + let sum = 0; + for (let i = CHECKSUM_DATA_START; i + 1 < limit; i += 2) { + sum += (data[i] << 8) | data[i + 1]; + } + return sum & 0xffff; +} + +/** + * Parse the Sega cartridge header at the fixed offset $100. Returns null when + * the console-identity field carries no recognised Sega signature, so we never + * "detect" a header in noise, an interleaved file, or a header-less dump. + */ +export function parseSegaHeader(data: Uint8Array): SegaRomInfo | null { + if (data.length < HEADER_MIN_BYTES) return null; + const is32x = is32xRom(data); + if (!isGenesisRom(data) && !is32x) return null; + + const copyright = decodeField(data, COPYRIGHT_START, COPYRIGHT_END); + const regions = decodeField(data, REGIONS_START, REGIONS_END).toUpperCase(); + const storedChecksum = + (data[STORED_CHECKSUM_START] << 8) | data[STORED_CHECKSUM_START + 1]; + const calculatedChecksum = segaChecksum(data); + + return { + console: is32x ? "32x" : "genesis", + domesticTitle: decodeField(data, DOMESTIC_TITLE_START, DOMESTIC_TITLE_END), + overseasTitle: decodeField(data, OVERSEAS_TITLE_START, OVERSEAS_TITLE_END), + copyright, + deviceSupport: decodeField(data, DEVICE_SUPPORT_START, DEVICE_SUPPORT_END), + regions, + regionDescription: describeRegions(regions), + storedChecksum, + calculatedChecksum, + checksumValid: storedChecksum === calculatedChecksum, + }; +} diff --git a/src/lib/systems/genesis/genesis-rom.test.ts b/src/lib/systems/genesis/genesis-rom.test.ts new file mode 100644 index 0000000..ae4d5c8 --- /dev/null +++ b/src/lib/systems/genesis/genesis-rom.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect } from "vitest"; +import { + detectHeader, + genesisChecksum, + trimGenesis, + isSmdInterleaved, + deinterleaveSmd, +} from "./genesis-rom"; + +const KB = 1024; +const MB = 1024 * 1024; + +/** Deterministic, well-varied byte at index i (1st 256KB differs from later). */ +const fillByte = (i: number) => (i ^ (i >>> 7) ^ (i >>> 13) ^ 0x5a) & 0xff; + +function writeAscii(data: Uint8Array, off: number, s: string, len: number) { + for (let i = 0; i < len; i++) data[off + i] = i < s.length ? s.charCodeAt(i) : 0x20; +} + +function writeU32BE(data: Uint8Array, off: number, v: number) { + data[off] = (v >>> 24) & 0xff; + data[off + 1] = (v >>> 16) & 0xff; + data[off + 2] = (v >>> 8) & 0xff; + data[off + 3] = v & 0xff; +} + +/** + * Build a synthetic linear big-endian Genesis ROM with a valid internal header + * at $100. The $1A4 end address is set to size-1 and the $18E checksum is the + * uniform word sum over $200..end. `system` lets a caller mark an SSF cart. + */ +function buildRom( + size: number, + opts: { + system?: string; + save?: boolean; + domestic?: string; + overseas?: string; + } = {}, +): { data: Uint8Array; checksum: number } { + const data = new Uint8Array(size); + for (let i = 0; i < size; i++) data[i] = fillByte(i); + // 68000 vectors $000-$0FF and header $100-$1FF must not look like body fill + // for the checksum identity; they're excluded from the sum ($200..end) anyway. + writeAscii(data, 0x100, opts.system ?? "SEGA MEGA DRIVE ", 16); + writeAscii(data, 0x120, opts.domestic ?? "DOMESTIC GAME", 48); // domestic name + writeAscii(data, 0x150, opts.overseas ?? "TEST GAME", 48); // overseas title + writeAscii(data, 0x180, "GM 00000000-00", 14); // serial + writeU32BE(data, 0x1a0, 0x000000); // ROM start + writeU32BE(data, 0x1a4, size - 1); // ROM end (inclusive) + writeAscii(data, 0x1f0, "U", 3); // region: USA + if (opts.save) { + writeAscii(data, 0x1b0, "RA", 2); + data[0x1b2] = 0xb0; // SRAM, odd-byte mapped (8-bit) + data[0x1b3] = 0x00; + writeU32BE(data, 0x1b4, 0x200001); + writeU32BE(data, 0x1b8, 0x203fff); // (end-start)/2+1 = 8192 = 8KB + } + // Checksum field is in $000-$1FF, excluded from the $200.. sum. + const c = genesisChecksum(data); + data[0x18e] = (c >> 8) & 0xff; + data[0x18f] = c & 0xff; + return { data, checksum: c }; +} + +/** Over-dump where the tail mirrors the block immediately before `realSize`. */ +function mirrorOverdump(real: Uint8Array, total: number): Uint8Array { + const out = new Uint8Array(total); + out.set(real, 0); + const t = total - real.length; + out.set(real.subarray(real.length - t, real.length), real.length); + return out; +} + +function fillOverdump(real: Uint8Array, total: number, v: number): Uint8Array { + const out = new Uint8Array(total); + out.set(real, 0); + out.fill(v, real.length); + return out; +} + +/** Fast byte equality (vitest's toEqual is far too slow on multi-MB arrays). */ +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +describe("genesisChecksum", () => { + it("is the uniform word sum over $200..end, self-consistent for a built ROM", () => { + const { data, checksum } = buildRom(2 * MB); + expect(genesisChecksum(data)).toBe(checksum); + expect((data[0x18e] << 8) | data[0x18f]).toBe(checksum); + }); + + it("is checksum-neutral for 0x00 padding (the weak-oracle property)", () => { + const { data, checksum } = buildRom(3 * MB); + const over = fillOverdump(data, 4 * MB, 0x00); + // 0x00-fill adds nothing to the word sum, so the over-dump's full-file sum + // still equals the stored checksum — proving checksum can't be the + // trim tie-breaker. + expect(genesisChecksum(over)).toBe(checksum); + }); + + it("is checksum-neutral for 128KB-aligned 0xFF padding", () => { + const { data, checksum } = buildRom(3 * MB); + const over = fillOverdump(data, 4 * MB, 0xff); + // A 1MB 0xFF pad is 0x80000 words; mod 0x10000 it contributes 0. + expect(genesisChecksum(over)).toBe(checksum); + }); +}); + +describe("detectHeader", () => { + it("parses the fixed-offset header of a valid ROM", () => { + const { data, checksum } = buildRom(2 * MB); + const h = detectHeader(data); + expect(h).not.toBeNull(); + expect(h!.systemName).toBe("SEGA MEGA DRIVE"); + expect(h!.title).toBe("TEST GAME"); + expect(h!.region).toBe("U"); + expect(h!.romEnd).toBe(2 * MB - 1); + expect(h!.checksum).toBe(checksum); + expect(h!.bankSwitched).toBe(false); + }); + + it("prefers the overseas name ($150) for the name field", () => { + const { data } = buildRom(1 * MB, { + domestic: "DOMESTIC NAME", + overseas: "OVERSEAS NAME", + }); + expect(detectHeader(data)!.name).toBe("OVERSEAS NAME"); + }); + + it("falls back to the domestic name ($120) when overseas is blank", () => { + const { data } = buildRom(1 * MB, { + domestic: "DOMESTIC NAME", + overseas: "", // $150 all spaces + }); + const h = detectHeader(data)!; + expect(h.title).toBe(""); // overseas field genuinely blank + expect(h.name).toBe("DOMESTIC NAME"); + }); + + it("collapses internal space runs and trims the name", () => { + const { data } = buildRom(1 * MB, { overseas: " TEST THE GAME " }); + expect(detectHeader(data)!.name).toBe("TEST THE GAME"); + }); + + it("yields a blank name when both name fields are blank", () => { + const { data } = buildRom(1 * MB, { domestic: "", overseas: "" }); + expect(detectHeader(data)!.name).toBe(""); + }); + + it("decodes an odd-mapped SRAM backup block", () => { + const { data } = buildRom(1 * MB, { save: true }); + const h = detectHeader(data); + expect(h!.save).not.toBeNull(); + expect(h!.save!.type).toBe("SRAM (odd)"); + expect(h!.save!.size).toBe(8 * KB); + }); + + it("flags a SEGA SSF bank-switched cart", () => { + const { data } = buildRom(2 * MB, { system: "SEGA SSF " }); + expect(detectHeader(data)!.bankSwitched).toBe(true); + }); + + it("returns null for noise (no SEGA string)", () => { + const noise = new Uint8Array(0x10000); + for (let i = 0; i < noise.length; i++) noise[i] = (i * 37) & 0xff; + expect(detectHeader(noise)).toBeNull(); + }); +}); + +describe("trimGenesis", () => { + it("trims a 3MB cart over-dumped to 4MB (mirror) down to exactly 3MB", () => { + const { data } = buildRom(3 * MB); + const over = mirrorOverdump(data, 4 * MB); + const { data: trimmed } = trimGenesis(over); + expect(trimmed.length).toBe(3 * MB); + expect(bytesEqual(trimmed, data)).toBe(true); + }); + + it("trims a 3MB cart 0xFF-padded to 4MB down to 3MB via $1A4", () => { + const { data } = buildRom(3 * MB); + const over = fillOverdump(data, 4 * MB, 0xff); + const { data: trimmed } = trimGenesis(over); + expect(trimmed.length).toBe(3 * MB); + expect(bytesEqual(trimmed, data)).toBe(true); + }); + + it("trims a non-pow2 1.25MB (10 Mbit) cart 0x00-padded to 2MB", () => { + const { data } = buildRom(0x140000); + const over = fillOverdump(data, 2 * MB, 0x00); + expect(trimGenesis(over).data.length).toBe(0x140000); + }); + + it("leaves an already-correct ROM untouched", () => { + const { data } = buildRom(2 * MB); + expect(trimGenesis(data).data.length).toBe(2 * MB); + }); + + it("flags a possible incomplete bank-switched dump for an SSF cart", () => { + const { data } = buildRom(0x400000, { system: "SEGA SSF " }); + const over = fillOverdump(data, 8 * MB, 0xff); + const { note } = trimGenesis(over); + expect(note).toContain("incomplete bank-switched"); + }); + + it("does NOT fold a mirror tail for an explicit-SSF cart (upper banks may hold data)", () => { + // An SSF cart understates $1A4 (static window only); its upper banks can + // carry real, un-mirrored data. Even when the over-dump's tail happens to + // mirror lower content byte-for-byte, the explicit bankSwitched flag must + // suppress the mirror-fold so no real bank is discarded. + const { data } = buildRom(3 * MB, { system: "SEGA SSF " }); + const over = mirrorOverdump(data, 4 * MB); + const { data: out, note } = trimGenesis(over); + // Full over-dump preserved (mirror NOT folded to the static 3MB window). + expect(out.length).toBe(4 * MB); + expect(bytesEqual(out, over)).toBe(true); + // Still carries the incomplete-bank-switched warning. + expect(note).toContain("incomplete bank-switched"); + }); + + it("still trims a NON-SSF mirror over-dump normally (guard didn't break common path)", () => { + // Regression guard for the !header.bankSwitched fold guard: a plain + // "SEGA ..." cart with a mirrored upper half must still fold to real size. + const { data } = buildRom(3 * MB, { system: "SEGA MEGA DRIVE " }); + const over = mirrorOverdump(data, 4 * MB); + const { data: out } = trimGenesis(over); + expect(out.length).toBe(3 * MB); + expect(bytesEqual(out, data)).toBe(true); + }); + + it("only trims constant fill (never folds a mirror) when no header is present", () => { + // No SEGA string: a small game mirrored across a larger mask must NOT be + // folded down (No-Intro often keys the full mask size). + const real = new Uint8Array(512 * KB); + for (let i = 0; i < real.length; i++) real[i] = fillByte(i); + const over = mirrorOverdump(real, 1 * MB); + expect(trimGenesis(over).data.length).toBe(1 * MB); // unchanged, not folded + + // But a pure 0xFF-fill tail on a header-less file IS trimmed. + const filled = fillOverdump(real, 1 * MB, 0xff); + expect(trimGenesis(filled).data.length).toBe(512 * KB); + }); +}); + +describe("smd de-interleave", () => { + it("detects and round-trips an interleaved .smd file to linear big-endian", () => { + const { data } = buildRom(512 * KB); + // Re-interleave into .smd: 512B header + per-16KB-block even/odd split. + const blocks = data.length / 0x4000; + const smd = new Uint8Array(512 + data.length); + smd[8] = 0xaa; + smd[9] = 0xbb; + for (let blk = 0; blk < blocks; blk++) { + const base = blk * 0x4000; + for (let k = 0; k < 0x2000; k++) { + smd[512 + base + k] = data[base + 2 * k + 1]; // odd half first + smd[512 + base + 0x2000 + k] = data[base + 2 * k]; // even half second + } + } + expect(isSmdInterleaved(smd)).toBe(true); + expect(bytesEqual(deinterleaveSmd(smd), data)).toBe(true); + // trimGenesis transparently de-interleaves, then the header parses. + const { data: out, note } = trimGenesis(smd); + expect(bytesEqual(out, data)).toBe(true); + expect(note).toContain("de-interleaved"); + }); + + it("does not mistake a linear .bin for .smd", () => { + const { data } = buildRom(512 * KB); + expect(isSmdInterleaved(data)).toBe(false); + }); +}); diff --git a/src/lib/systems/genesis/genesis-rom.ts b/src/lib/systems/genesis/genesis-rom.ts new file mode 100644 index 0000000..79cc86a --- /dev/null +++ b/src/lib/systems/genesis/genesis-rom.ts @@ -0,0 +1,325 @@ +/** + * Genesis / Mega Drive internal-header parsing, over-dump trimming, and the + * internal checksum. Shared by the Genesis SystemHandler (validation/summary) + * and the Retrode 2 over-dump trimmer. Pure functions, no DOM dependencies. + * + * The cart is 16-bit big-endian on the 68000 bus; a Retrode-class dumper reads + * words MSByte-first and writes a PLAIN LINEAR big-endian .bin (NOT interleaved + * .smd). There is ONE internal header at a fixed offset $100 ("SEGA …"), so — + * unlike SNES — there is no LoROM/HiROM candidate-selection problem. + * + * Trim policy (per the verdict review): the REAL ROM size is the $1A4 ROM-end + * address + 1, VALIDATED against a mirror/fill tail. The $18E checksum is a + * WEAK trim oracle — 0x00-fill and 128KB-aligned 0xFF-fill are checksum-neutral + * word-sum-wise — so it is surfaced for confidence only and is NEVER the trim + * tie-breaker. We never round a size UP past detected real content. + */ + +export interface GenesisHeader { + /** System / console name field, $100-$10F, trimmed. */ + systemName: string; + /** + * Game name for display / filename: the overseas (international) name field + * at $150, falling back to the domestic name at $120 when the overseas field + * is blank. Space-runs collapsed and trimmed; "" when both fields are blank. + */ + name: string; + /** Overseas (international) title, $150-$17F, trimmed. */ + title: string; + /** Serial / product code, $180-$18D, trimmed. */ + serial: string; + /** ROM start address $1A0 (inclusive first cartridge byte). */ + romStart: number; + /** ROM end address $1A4 (inclusive last cartridge byte). */ + romEnd: number; + /** Stored internal checksum word, $18E (big-endian u16). */ + checksum: number; + /** Region field, $1F0-$1F2, trimmed. */ + region: string; + /** True when the system-name field marks a bank-switched ("SEGA SSF") cart. */ + bankSwitched: boolean; + save: GenesisSave | null; +} + +export interface GenesisSave { + /** "SRAM" (16-bit), "SRAM (odd)" (8-bit odd-mapped), or "EEPROM"/"backup". */ + type: string; + /** Effective save size in bytes. */ + size: number; + /** Backup-RAM start address ($1B4, u32 BE). */ + start: number; + /** Backup-RAM end address ($1B8, u32 BE). */ + end: number; +} + +/** + * Natural Genesis mask-ROM sizes in bytes (ascending), incl. non-power-of-two + * carts (10/20 Mbit etc.). Used as the trailing-fill scan ladder; trimming is + * driven by the $1A4 end address, not by snapping to this ladder. + */ +export const GENESIS_SIZE_LADDER = [ + 0x040000, // 256 KB (2 Mbit) + 0x080000, // 512 KB (4 Mbit) + 0x0c0000, // 768 KB (6 Mbit) + 0x100000, // 1 MB (8 Mbit) + 0x140000, // 1.25 MB (10 Mbit) + 0x180000, // 1.5 MB (12 Mbit) + 0x1c0000, // 1.75 MB (14 Mbit) + 0x200000, // 2 MB (16 Mbit) + 0x280000, // 2.5 MB (20 Mbit) + 0x300000, // 3 MB (24 Mbit) + 0x380000, // 3.5 MB (28 Mbit) + 0x400000, // 4 MB (32 Mbit) +]; + +/** Smallest common Genesis mask-ROM increment (128 KB). */ +const ROM_GRANULARITY = 0x20000; + +function readU32BE(data: Uint8Array, off: number): number { + return ( + ((data[off] << 24) | + (data[off + 1] << 16) | + (data[off + 2] << 8) | + data[off + 3]) >>> + 0 + ); +} + +function decodeAscii(data: Uint8Array, start: number, len: number): string { + let s = ""; + for (let i = 0; i < len; i++) { + const b = data[start + i]; + s += b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : " "; + } + return s.replace(/\s+/g, " ").trim(); +} + +/** + * The .smd copier interleave: 512-byte file header, then 16KB blocks each split + * into an 8KB even-byte half followed by an 8KB odd-byte half. A genuine + * Retrode dump is already linear big-endian, so this is an import-only path. + */ +export function isSmdInterleaved(data: Uint8Array): boolean { + if (data.length < 0x4200) return false; + if ((data.length - 512) % 0x4000 !== 0) return false; + // SMD header markers: byte[8]==0xAA, byte[9]==0xBB. + return data[8] === 0xaa && data[9] === 0xbb; +} + +/** De-interleave an .smd file to plain linear big-endian. */ +export function deinterleaveSmd(data: Uint8Array): Uint8Array { + const body = data.subarray(512); + const out = new Uint8Array(body.length); + const blocks = body.length / 0x4000; + for (let blk = 0; blk < blocks; blk++) { + const base = blk * 0x4000; + for (let k = 0; k < 0x2000; k++) { + out[base + 2 * k] = body[base + 0x2000 + k]; // even half (offset +8KB) + out[base + 2 * k + 1] = body[base + k]; // odd half (offset +0) + } + } + return out; +} + +/** + * Decode the backup-RAM block at $1B0. Present iff $1B0-$1B1 == "RA". + * $1B2 high nibble 0xA marks backup RAM; 0xB0 means SRAM mapped to ODD bytes + * only (8-bit, the usual battery case). $1B4/$1B8 are the u32 BE start/end. + */ +function decodeSave(data: Uint8Array): GenesisSave | null { + if (data[0x1b0] !== 0x52 || data[0x1b1] !== 0x41) return null; // "RA" + const flag = data[0x1b2]; + const start = readU32BE(data, 0x1b4); + const end = readU32BE(data, 0x1b8); + if (end < start) return null; + const oddOnly = (flag & 0xb0) === 0xb0; + const size = oddOnly ? (end - start) / 2 + 1 : end - start + 1; + const type = oddOnly ? "SRAM (odd)" : (flag & 0xa0) === 0xa0 ? "SRAM" : "backup"; + return { type, size: Math.max(0, Math.trunc(size)), start, end }; +} + +/** + * Parse the internal header at the fixed offset $100. Returns null when the + * system-name field doesn't carry a SEGA console string (so we don't "detect" a + * header in noise / an interleaved or header-less file). + */ +export function detectHeader(data: Uint8Array): GenesisHeader | null { + if (data.length < 0x200) return null; + const systemName = decodeAscii(data, 0x100, 16); + const upper = systemName.toUpperCase(); + if (!upper.includes("SEGA")) return null; + const overseas = decodeAscii(data, 0x150, 48); + return { + systemName, + // Overseas name preferred; fall back to the domestic name at $120. + name: overseas || decodeAscii(data, 0x120, 48), + title: overseas, + serial: decodeAscii(data, 0x180, 14), + romStart: readU32BE(data, 0x1a0), + romEnd: readU32BE(data, 0x1a4), + checksum: (data[0x18e] << 8) | data[0x18f], + region: decodeAscii(data, 0x1f0, 3), + bankSwitched: upper.includes("SSF"), + save: decodeSave(data), + }; +} + +/** + * Internal $18E checksum: a UNIFORM 16-bit big-endian word sum over $200..end + * inclusive, mod 0x10000. No SNES-style high/low position weighting. Vectors + * and the header ($000-$1FF) are excluded. + */ +export function genesisChecksum(data: Uint8Array): number { + let sum = 0; + for (let i = 0x200; i + 1 < data.length; i += 2) { + sum += (data[i] << 8) | data[i + 1]; + } + return sum & 0xffff; +} + +/** The tail [L:end] is a byte-exact mirror of the equal-length block before L. */ +function isTailMirror(data: Uint8Array, L: number): boolean { + const t = data.length - L; + if (t <= 0 || t > L) return false; + for (let i = 0; i < t; i++) { + if (data[L + i] !== data[L - t + i]) return false; + } + return true; +} + +/** The tail [L:end] is entirely 0x00 or entirely 0xFF. */ +function isTailFill(data: Uint8Array, L: number): boolean { + const end = data.length; + if (end <= L) return false; + const v = data[L]; + if (v !== 0x00 && v !== 0xff) return false; + for (let i = L + 1; i < end; i++) if (data[i] !== v) return false; + return true; +} + +/** Offset of the first byte of the longest constant 0x00/0xFF tail run. */ +function fillTrimOffset(data: Uint8Array): number { + const end = data.length; + if (end < 2) return end; + const v = data[end - 1]; + if (v !== 0x00 && v !== 0xff) return end; + let f = end; + while (f > 0 && data[f - 1] === v) f--; + return f; +} + +function mb(bytes: number): string { + const m = bytes / (1024 * 1024); + return `${m.toFixed(Number.isInteger(m) ? 0 : 2)} MB`; +} + +/** + * Trim a Retrode over-dump to the real ROM size. Returns the real bytes (a + * prefix of the input) plus an optional human note. + * + * Primary signal: the $1A4 ROM-end address + 1, ACCEPTED only when the bytes + * above it are a provable mirror of lower content or constant fill (this + * validates the header against the actual dump). When the header is missing or + * $1A4 is implausible we trim ONLY a constant-fill tail — never auto-folding a + * mirror, since a small game physically mirrored across a larger mask ROM is + * byte-indistinguishable from the smaller game and No-Intro often keys the full + * mask size. The $18E checksum is surfaced as confidence only, never the + * tie-breaker (0x00- and 128KB-aligned 0xFF-pads are checksum-neutral). + * + * SSF / bank-switched carts UNDERSTATE $1A4 (it reports only the static + * window); we flag a possible incomplete dump rather than reporting a clean + * trim, since a self-consistent 4MB result can mask missing upper banks. + */ +export function trimGenesis(input: Uint8Array): { + data: Uint8Array; + note?: string; +} { + let data = input; + let prefixNote: string | undefined; + if (isSmdInterleaved(data)) { + data = deinterleaveSmd(data); + prefixNote = "de-interleaved .smd to linear big-endian"; + } + + const header = detectHeader(data); + const D = data.length; + const decorate = ( + body: Uint8Array, + note?: string, + ): { data: Uint8Array; note?: string } => { + const parts = [prefixNote, note].filter(Boolean); + return parts.length ? { data: body, note: parts.join("; ") } : { data: body }; + }; + + // No trustworthy header: trim only a constant-fill tail; never fold a mirror. + if (!header) { + const f = fillTrimOffset(data); + if (f >= 0x200 && f < D) { + return decorate(data.subarray(0, f), `trimmed fill tail to ${mb(f)}`); + } + return decorate(data); + } + + const stored = header.checksum; + const checksumNote = (len: number): string => { + const hex = stored.toString(16).toUpperCase().padStart(4, "0"); + const ok = genesisChecksum(data.subarray(0, len)) === stored; + return `header checksum 0x${hex} ${ok ? "ok" : "mismatch"}`; + }; + + const headerSize = header.romEnd + 1; + const plausible = + headerSize > 0x200 && headerSize <= D && headerSize <= 0x800000; + + // Possible incomplete bank-switched dump: $1A4 understates for SSF/mapper + // carts, and a mirror/fill tail at/below 4MB can masquerade as a clean trim. + // The EXPLICIT mapper flag (header.bankSwitched) is the reliable SSF signal + // and additionally SUPPRESSES the mirror-fold below; the heuristic arm only + // annotates, so an ordinary over-dump that happens to be ≤4MB at a 512KB + // boundary still trims normally. + const bankSwitchWarn = + header.bankSwitched || + (headerSize <= 0x400000 && + (isTailMirror(data, headerSize) || isTailFill(data, headerSize)) && + headerSize % 0x80000 === 0) + ? "possible incomplete bank-switched dump" + : undefined; + + // Accept the header end address only when the bytes above it are a provable + // mirror or constant fill (validates the header against the dump) — but NEVER + // fold when the cart explicitly declares a bank-switched mapper: its upper + // banks can hold real, un-mirrored data the static dump captured, and folding + // would discard it. Such a dump falls through to fill-only stripping (which + // removes 0xFF padding but keeps any non-constant upper banks) or passthrough. + if ( + !header.bankSwitched && + plausible && + headerSize < D && + (isTailMirror(data, headerSize) || isTailFill(data, headerSize)) + ) { + const note = [ + `over-dump trimmed to ${mb(headerSize)}`, + checksumNote(headerSize), + bankSwitchWarn, + headerSize % ROM_GRANULARITY !== 0 + ? "warning: size not a 128KB multiple" + : undefined, + ] + .filter(Boolean) + .join("; "); + return decorate(data.subarray(0, headerSize), note); + } + + // Real content extends past $1A4 (bank-switched understatement): never trim + // it away. Strip only a trailing constant-fill run, if any. + const f = fillTrimOffset(data); + if (f >= 0x200 && f < D && f >= headerSize) { + const note = [`trimmed fill tail to ${mb(f)}`, bankSwitchWarn] + .filter(Boolean) + .join("; "); + return decorate(data.subarray(0, f), note); + } + + // Already-correct size (or header end == dump length): leave untouched. + return decorate(data, bankSwitchWarn); +} diff --git a/src/lib/systems/genesis/genesis-system-handler.test.ts b/src/lib/systems/genesis/genesis-system-handler.test.ts new file mode 100644 index 0000000..f25bff9 --- /dev/null +++ b/src/lib/systems/genesis/genesis-system-handler.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; +import { GenesisSystemHandler } from "./genesis-system-handler"; +import { genesisChecksum } from "./genesis-rom"; + +const MB = 1024 * 1024; + +/** Deterministic, well-varied byte at index i. */ +const fillByte = (i: number) => (i ^ (i >>> 7) ^ (i >>> 13) ^ 0x5a) & 0xff; + +function writeAscii(data: Uint8Array, off: number, s: string, len: number) { + for (let i = 0; i < len; i++) + data[off + i] = i < s.length ? s.charCodeAt(i) : 0x20; +} + +function writeU32BE(data: Uint8Array, off: number, v: number) { + data[off] = (v >>> 24) & 0xff; + data[off + 1] = (v >>> 16) & 0xff; + data[off + 2] = (v >>> 8) & 0xff; + data[off + 3] = v & 0xff; +} + +/** + * Build a synthetic linear big-endian Genesis ROM with a valid internal header + * at $100, writing the domestic name at $120 and the overseas name at $150. + */ +function buildRom( + size: number, + opts: { domestic?: string; overseas?: string } = {}, +): Uint8Array { + const data = new Uint8Array(size); + for (let i = 0; i < size; i++) data[i] = fillByte(i); + writeAscii(data, 0x100, "SEGA MEGA DRIVE ", 16); + writeAscii(data, 0x120, opts.domestic ?? "DOMESTIC GAME", 48); + writeAscii(data, 0x150, opts.overseas ?? "TEST GAME", 48); + writeAscii(data, 0x180, "GM 00000000-00", 14); + writeU32BE(data, 0x1a0, 0x000000); + writeU32BE(data, 0x1a4, size - 1); + writeAscii(data, 0x1f0, "U", 3); + const c = genesisChecksum(data); + data[0x18e] = (c >> 8) & 0xff; + data[0x18f] = c & 0xff; + return data; +} + +describe("GenesisSystemHandler.headerTitle", () => { + const handler = new GenesisSystemHandler(); + + it("returns the overseas name ($150) for a synthesized ROM", () => { + expect(handler.headerTitle(buildRom(2 * MB))).toBe("TEST GAME"); + }); + + it("falls back to the domestic name ($120) when overseas is blank", () => { + const rom = buildRom(1 * MB, { domestic: "DOMESTIC NAME", overseas: "" }); + expect(handler.headerTitle(rom)).toBe("DOMESTIC NAME"); + }); + + it("collapses internal space runs into a clean filename stem", () => { + const rom = buildRom(1 * MB, { overseas: " TEST THE GAME " }); + expect(handler.headerTitle(rom)).toBe("TEST THE GAME"); + }); + + it("returns undefined when no SEGA header is present (noise)", () => { + const noise = new Uint8Array(0x10000); + for (let i = 0; i < noise.length; i++) noise[i] = (i * 91) & 0xff; + expect(handler.headerTitle(noise)).toBeUndefined(); + }); + + it("returns undefined for a buffer too small to hold a header", () => { + expect(handler.headerTitle(new Uint8Array(0x100))).toBeUndefined(); + }); + + it("returns undefined when both name fields are blank", () => { + const rom = buildRom(2 * MB, { domestic: "", overseas: "" }); + expect(handler.headerTitle(rom)).toBeUndefined(); + }); +}); diff --git a/src/lib/systems/genesis/genesis-system-handler.ts b/src/lib/systems/genesis/genesis-system-handler.ts new file mode 100644 index 0000000..d9eea37 --- /dev/null +++ b/src/lib/systems/genesis/genesis-system-handler.ts @@ -0,0 +1,181 @@ +import type { + SystemHandler, + ConfigValues, + CartridgeInfo, + DumpSummary, + ResolvedConfigField, + ValidationResult, + ReadConfig, + OutputFile, + VerificationHashes, + VerificationDB, + VerificationResult, +} from "@/lib/types"; +import { crc32, sha1Hex, sha256Hex } from "@/lib/core/hashing"; +import { detectHeader, genesisChecksum, trimGenesis } from "./genesis-rom"; +import { consoleLabel, parseSegaHeader } from "./genesis-header"; + +/** + * Sega Genesis / Mega Drive. ROMs are plain linear BIG-ENDIAN binaries (.bin) + * with an INTERNAL header at $100 ("SEGA …") — so, like SNES/GBA and unlike + * NES, the No-Intro hash is over the whole (trimmed) ROM with no external + * header to splice. Over-dump trimming (and any .smd de-interleave) is offered + * post-dump via suggestTrim (see genesis-rom.ts trimGenesis); this handler + * validates and packages the bytes. + */ +export class GenesisSystemHandler implements SystemHandler { + readonly systemId = "genesis"; + readonly displayName = "Genesis / Mega Drive"; + readonly fileExtension = ".bin"; + + getConfigFields( + _currentValues: ConfigValues, + autoDetected?: CartridgeInfo, + ): ResolvedConfigField[] { + const fields: ResolvedConfigField[] = []; + if (autoDetected?.title) { + fields.push({ + key: "title", + label: "Game Title", + type: "readonly", + value: autoDetected.title, + autoDetected: true, + group: "cartridge", + order: 0, + }); + } + const region = autoDetected?.meta?.region; + if (typeof region === "string") { + fields.push({ + key: "region", + label: "Region", + type: "readonly", + value: region, + autoDetected: true, + group: "cartridge", + order: 1, + }); + } + return fields; + } + + validate(_values: ConfigValues): ValidationResult { + return { valid: true }; + } + + /** + * Game name parsed from the internal header: the overseas name field at $150 + * with a domestic ($120) fallback (decodeHeader collapses space runs and + * trims, so the result is already a clean filename stem). Parses the LINEAR + * big-endian header; an .smd-interleaved import has its header de-interleaved + * by trimGenesis before reaching here. Returns undefined when no SEGA header + * is present or both name fields are blank. + */ + headerTitle(content: Uint8Array): string | undefined { + const name = detectHeader(content)?.name; + return name ? name : undefined; + } + + suggestTrim(content: Uint8Array): { size: number; note: string } | null { + const { data, note } = trimGenesis(content); + return data.length < content.length + ? { size: data.length, note: note ?? "heuristic over-dump trim" } + : null; + } + + buildReadConfig(_values: ConfigValues): ReadConfig { + return { systemId: "genesis", params: {} }; + } + + buildOutputFile(rawData: Uint8Array, _config: ReadConfig): OutputFile { + const header = detectHeader(rawData); + const meta: Record<string, string> = { Format: "Genesis / Mega Drive" }; + if (header) { + if (header.region) meta.Region = header.region; + if (header.serial) meta.Serial = header.serial; + if (header.save) { + meta.Save = `${header.save.type} (${header.save.size} B)`; + } + const computed = genesisChecksum(rawData); + meta.Checksum = + `0x${header.checksum.toString(16).toUpperCase().padStart(4, "0")}` + + (computed === header.checksum ? " (ok)" : " (mismatch)"); + } + return { + data: rawData, + filename: "dump.bin", + mimeType: "application/octet-stream", + meta, + }; + } + + async computeHashes(rawData: Uint8Array): Promise<VerificationHashes> { + const [sha1, sha256] = await Promise.all([ + sha1Hex(rawData), + sha256Hex(rawData), + ]); + return { crc32: crc32(rawData), sha1, sha256, size: rawData.length }; + } + + verify( + hashes: VerificationHashes, + db: VerificationDB | null, + ): VerificationResult { + if (!db) return { matched: false, confidence: "none" }; + const entry = db.lookup(hashes); + if (entry) return { matched: true, entry, confidence: "exact" }; + return { + matched: false, + confidence: "none", + suggestions: [ + "No No-Intro match. If this is an over-dump, the $1A4 end address " + + "may still point past the real ROM, or a >4MB bank-switched cart " + + "may have dumped incompletely — try trimming, or import the " + + "Mega Drive DAT.", + ], + }; + } + + /** + * Internal-header self-check, available with NO No-Intro database. The Sega + * header at $100 carries a 16-bit checksum at $18E; a clean dump's recomputed + * word-sum over $200..ROM-end equals it. When no DAT is loaded this is the + * only consistency signal we can offer — surfaced as a neutral integrity + * result, never a No-Intro "verified". Null when no Sega signature is found + * (don't fabricate a verdict over noise / an interleaved import). + */ + summarizeDump(rawData: Uint8Array): DumpSummary | null { + const info = parseSegaHeader(rawData); + if (!info) return null; + + // Only surface fields the header actually populated (blank fields are + // common on homebrew / region-specific carts and would just be noise). + const rows: [string, string][] = []; + const push = (field: string, value: string) => { + if (value) rows.push([field, value]); + }; + push("Console", consoleLabel(info.console)); + push("Domestic title", info.domesticTitle); + push("Overseas title", info.overseasTitle); + push("Copyright", info.copyright); + push("Device support", info.deviceSupport); + push("Region", `${info.regionDescription} (${info.regions})`); + rows.push([ + "Checksum", + `0x${info.storedChecksum.toString(16).toUpperCase().padStart(4, "0")}`, + ]); + + return { + title: "Genesis header", + columns: ["Field", "Value"], + monoColumns: [1], + rows, + integrity: { + ok: info.checksumValid, + message: info.checksumValid + ? "Internal checksum consistent" + : "Genesis internal checksum mismatch", + }, + }; + } +} diff --git a/src/lib/systems/n64/controller-pak.test.ts b/src/lib/systems/n64/controller-pak.test.ts new file mode 100644 index 0000000..9124b5d --- /dev/null +++ b/src/lib/systems/n64/controller-pak.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { + CONTROLLER_PAK_SIZE, + decodeNoteName, + parseControllerPak, +} from "./controller-pak"; + +// Encode a name into the N64 menu font (inverse of the parser's table). +function encodeName(s: string): Uint8Array { + const out = new Uint8Array(16); + for (let i = 0; i < s.length && i < 16; i++) { + const c = s[i]; + if (c === " ") out[i] = 0x0f; + else if (c >= "0" && c <= "9") out[i] = 0x10 + (c.charCodeAt(0) - 48); + else if (c >= "A" && c <= "Z") out[i] = 0x1a + (c.charCodeAt(0) - 65); + else if (c === "-") out[i] = 0x3b; + } + return out; +} + +function be16set(d: Uint8Array, off: number, v: number) { + d[off] = (v >> 8) & 0xff; + d[off + 1] = v & 0xff; +} + +/** A minimal valid Controller Pak: one ID block + one 3-page note. */ +function buildPak(): Uint8Array { + const d = new Uint8Array(CONTROLLER_PAK_SIZE); + + // ID block @ 0x20: arbitrary data words, then checksum + complement. + be16set(d, 0x20, 0x1234); + be16set(d, 0x28, 0x00ab); + let sum = 0; + for (let i = 0; i < 28; i += 2) + sum = (sum + ((d[0x20 + i] << 8) | d[0x20 + i + 1])) & 0xffff; + be16set(d, 0x3c, sum); + be16set(d, 0x3e, (0xfff2 - sum) & 0xffff); + + // Inode: note chain 5 → 6 → 7 (last); pages 8-127 free. + const INODE = 0x100; + be16set(d, INODE + 5 * 2, 6); + be16set(d, INODE + 6 * 2, 7); + be16set(d, INODE + 7 * 2, 0x01); // last page + for (let p = 8; p < 128; p++) be16set(d, INODE + p * 2, 0x03); // free + + // Note-table entry 0: code (ASCII) + start page + font-encoded name. + const NOTE = 0x300; + d.set(new TextEncoder().encode("NXXE01"), NOTE); + d[NOTE + 0x07] = 5; + d.set(encodeName("TEST SAVE"), NOTE + 0x10); + + return d; +} + +describe("parseControllerPak", () => { + it("parses a valid pak: ID checks out, note + page chain decoded", () => { + const pak = parseControllerPak(buildPak())!; + expect(pak.idValid).toBe(true); + expect(pak.notes).toEqual([ + { code: "NXXE01", name: "TEST SAVE", startPage: 5, pages: 3 }, + ]); + expect(pak.pagesUsed).toBe(3); + expect(pak.pagesFree).toBe(120); // 123 data pages − 3 used + expect(pak.totalDataPages).toBe(123); + }); + + it("flags an invalid/unformatted pak and lists no notes", () => { + const noise = new Uint8Array(CONTROLLER_PAK_SIZE).fill(0xa5); + const pak = parseControllerPak(noise)!; + expect(pak.idValid).toBe(false); + expect(pak.notes).toEqual([]); + }); + + it("returns null for a wrong-size image", () => { + expect(parseControllerPak(new Uint8Array(1024))).toBeNull(); + }); +}); + +describe("decodeNoteName", () => { + it("decodes the N64 menu font and trims trailing space", () => { + expect(decodeNoteName(encodeName("MY GAME"))).toBe("MY GAME"); + }); + + it("stops at the NUL terminator", () => { + expect(decodeNoteName(Uint8Array.from([0x1a, 0x00, 0x1b]))).toBe("A"); + }); +}); diff --git a/src/lib/systems/n64/controller-pak.ts b/src/lib/systems/n64/controller-pak.ts new file mode 100644 index 0000000..015bec8 --- /dev/null +++ b/src/lib/systems/n64/controller-pak.ts @@ -0,0 +1,145 @@ +/** + * N64 Controller Pak ("Mempak") parsing — pure, no DOM. The Controller Pak is a + * 32 KB battery-backed memory accessory: 128 pages × 256 bytes. + * + * page 0 ID area — four 32-byte ID-block copies (@ 0x20/0x60/0x80/0xC0), + * each ending in a checksum + complement + * page 1 inode table — per-page chain links (0x01 = last, 0x03 = free, + * 5-127 = next page); page 2 is its backup + * pages 3-4 note table — 16 × 32-byte directory entries + * pages 5-127 note data + * + * The note-name field uses the N64 menu font (not ASCII). This mirrors the PS1 + * memory-card parsing that feeds that system's dump summary. + */ + +export const CONTROLLER_PAK_SIZE = 32768; +const PAGE_SIZE = 256; +const FIRST_DATA_PAGE = 5; +const TOTAL_PAGES = 128; +const INODE_OFFSET = 1 * PAGE_SIZE; // page 1 +const NOTE_TABLE_OFFSET = 3 * PAGE_SIZE; // pages 3-4 +const NOTE_ENTRY_SIZE = 32; +const NOTE_COUNT = 16; +const ID_BLOCK_OFFSETS = [0x20, 0x60, 0x80, 0xc0]; +const INODE_LAST = 0x01; +const INODE_FREE = 0x03; + +export interface ControllerPakNote { + /** 4-char game code + 2-char publisher, e.g. "ABCD01". */ + code: string; + /** Decoded note name in the N64 menu font, e.g. "MY SAVE". */ + name: string; + /** First data page of the note. */ + startPage: number; + /** Pages the note occupies (256 bytes each). */ + pages: number; +} + +export interface ControllerPak { + /** A valid ID block (checksum + complement) was found — i.e. a real, + * formatted Controller Pak rather than noise / a bad dump. */ + idValid: boolean; + notes: ControllerPakNote[]; + /** Data pages in use across all notes. */ + pagesUsed: number; + /** Free (0x03) data pages. */ + pagesFree: number; + /** Total addressable data pages (123: pages 5-127). */ + totalDataPages: number; +} + +// N64 Controller Pak note-name font. Confirmed against real Controller Pak +// dumps: 0x0F = space, 0x10-0x19 = 0-9, 0x1A-0x33 = A-Z, then punctuation. +const FONT: Record<number, string> = (() => { + const t: Record<number, string> = { 0x0f: " " }; + for (let i = 0; i < 10; i++) t[0x10 + i] = String(i); + for (let i = 0; i < 26; i++) t[0x1a + i] = String.fromCharCode(65 + i); + const punct = '!"#`*+,-./:=?@'; + for (let i = 0; i < punct.length; i++) t[0x34 + i] = punct[i]; + return t; +})(); + +/** Decode an N64-menu-font note name; stops at NUL, drops trailing spaces. */ +export function decodeNoteName(bytes: Uint8Array): string { + let s = ""; + for (const b of bytes) { + if (b === 0x00) break; + s += FONT[b] ?? "?"; + } + return s.replace(/\s+$/, ""); +} + +const be16 = (data: Uint8Array, off: number): number => + (data[off] << 8) | data[off + 1]; + +/** + * Validate an ID block: the sum of its 14 leading big-endian words equals the + * stored checksum at +0x1C, and 0xFFF2 − checksum equals the stored complement + * at +0x1E. (The four copies let the console tolerate a damaged block.) + */ +function idBlockValid(data: Uint8Array, base: number): boolean { + let sum = 0; + for (let i = 0; i < 28; i += 2) sum = (sum + be16(data, base + i)) & 0xffff; + const checksum = be16(data, base + 0x1c); + const complement = be16(data, base + 0x1e); + return sum === checksum && ((0xfff2 - sum) & 0xffff) === complement; +} + +/** + * Parse a 32 KB Controller Pak image. Returns null only when the size is wrong; + * a structurally-invalid pak still parses (with `idValid: false`) so the caller + * can report a bad/unformatted dump rather than silently failing. + */ +export function parseControllerPak(data: Uint8Array): ControllerPak | null { + if (data.length !== CONTROLLER_PAK_SIZE) return null; + + const idValid = ID_BLOCK_OFFSETS.some((o) => idBlockValid(data, o)); + + // Inode link for a page: low byte of its big-endian entry. + const link = (page: number) => be16(data, INODE_OFFSET + page * 2) & 0xff; + + const notes: ControllerPakNote[] = []; + let pagesUsed = 0; + // Only trust the note directory when the pak validated — otherwise we'd be + // decoding noise into bogus entries. + if (idValid) { + for (let i = 0; i < NOTE_COUNT; i++) { + const off = NOTE_TABLE_OFFSET + i * NOTE_ENTRY_SIZE; + if (data[off] === 0) continue; // empty directory slot + const startPage = data[off + 0x07]; + if (startPage < FIRST_DATA_PAGE || startPage >= TOTAL_PAGES) continue; + + const code = Array.from(data.subarray(off, off + 6)) + .map((b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : "")) + .join(""); + const name = decodeNoteName(data.subarray(off + 0x10, off + 0x20)); + + // Walk the page chain (cap hops as a loop/bad-link guard). + let pages = 0; + let page = startPage; + for (let hop = 0; hop < TOTAL_PAGES; hop++) { + pages++; + const next = link(page); + if (next === INODE_LAST) break; + if (next < FIRST_DATA_PAGE || next >= TOTAL_PAGES) break; + page = next; + } + pagesUsed += pages; + notes.push({ code, name, startPage, pages }); + } + } + + let pagesFree = 0; + for (let p = FIRST_DATA_PAGE; p < TOTAL_PAGES; p++) { + if (link(p) === INODE_FREE) pagesFree++; + } + + return { + idValid, + notes, + pagesUsed, + pagesFree, + totalDataPages: TOTAL_PAGES - FIRST_DATA_PAGE, + }; +} diff --git a/src/lib/systems/n64/n64-controller-pak-handler.test.ts b/src/lib/systems/n64/n64-controller-pak-handler.test.ts new file mode 100644 index 0000000..3ad859e --- /dev/null +++ b/src/lib/systems/n64/n64-controller-pak-handler.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { N64ControllerPakHandler } from "./n64-controller-pak-handler"; +import { CONTROLLER_PAK_SIZE } from "./controller-pak"; + +function be16set(d: Uint8Array, off: number, v: number) { + d[off] = (v >> 8) & 0xff; + d[off + 1] = v & 0xff; +} + +/** A minimal valid Controller Pak with one 2-page note "TSTG01 / MY GAME". */ +function buildPak(): Uint8Array { + const d = new Uint8Array(CONTROLLER_PAK_SIZE); + + // ID block @ 0x20 with a valid checksum + complement. + be16set(d, 0x20, 0x1234); + let sum = 0; + for (let i = 0; i < 28; i += 2) + sum = (sum + ((d[0x20 + i] << 8) | d[0x20 + i + 1])) & 0xffff; + be16set(d, 0x3c, sum); + be16set(d, 0x3e, (0xfff2 - sum) & 0xffff); + + // Inode: chain 5 → 6 (last); pages 7-127 free. + const INODE = 0x100; + be16set(d, INODE + 5 * 2, 6); + be16set(d, INODE + 6 * 2, 0x01); + for (let p = 7; p < 128; p++) be16set(d, INODE + p * 2, 0x03); + + // Note entry 0: code + start page + font-encoded "MY GAME". + const NOTE = 0x300; + d.set(new TextEncoder().encode("TSTG01"), NOTE); + d[NOTE + 0x07] = 5; + // M Y <sp> G A M E in the N64 menu font. + const name = [0x26, 0x32, 0x0f, 0x20, 0x1a, 0x26, 0x1e]; + d.set(Uint8Array.from(name), NOTE + 0x10); + + return d; +} + +describe("N64ControllerPakHandler.summarizeDump", () => { + const handler = new N64ControllerPakHandler(); + + it("lists notes and reports a clean pak as valid", () => { + const summary = handler.summarizeDump(buildPak())!; + expect(summary.title).toBe("Controller Pak contents"); + expect(summary.integrity).toEqual({ ok: true }); + expect(summary.rows).toEqual([["MY GAME", "TSTG01", "2"]]); + expect(summary.footer).toContain("1 note"); + expect(summary.footer).toContain("2/123 pages used"); + }); + + it("flags a corrupt/unformatted pak and lists no notes", () => { + const noise = new Uint8Array(CONTROLLER_PAK_SIZE).fill(0xa5); + const summary = handler.summarizeDump(noise)!; + expect(summary.integrity?.ok).toBe(false); + expect(summary.integrity?.message).toMatch(/corrupt or unformatted/i); + expect(summary.rows).toEqual([]); + expect(summary.footer).toBe("Not a valid Controller Pak"); + }); + + it("returns null for a wrong-size image", () => { + expect(handler.summarizeDump(new Uint8Array(100))).toBeNull(); + }); +}); diff --git a/src/lib/systems/n64/n64-controller-pak-handler.ts b/src/lib/systems/n64/n64-controller-pak-handler.ts new file mode 100644 index 0000000..4d11fef --- /dev/null +++ b/src/lib/systems/n64/n64-controller-pak-handler.ts @@ -0,0 +1,118 @@ +import type { + SystemHandler, + ConfigValues, + CartridgeInfo, + ResolvedConfigField, + ValidationResult, + ReadConfig, + OutputFile, + VerificationHashes, + VerificationDB, + VerificationResult, + DumpSummary, +} from "@/lib/types"; +import { crc32, sha1Hex, sha256Hex } from "@/lib/core/hashing"; +import { CONTROLLER_PAK_SIZE, parseControllerPak } from "./controller-pak"; + +/** + * N64 Controller Pak ("Mempak") — a 32 KB battery-backed memory accessory. + * Save-only, like the PS1 memory card: there's no verification database, so the + * dump's worth is its internal consistency (the ID-block checksum) and its + * note directory, surfaced via {@link summarizeDump}. The Retrode writes these + * as `.mpk` files on its volume; the driver routes them here. + */ +export class N64ControllerPakHandler implements SystemHandler { + readonly systemId = "n64_controller_pak"; + readonly displayName = "N64 Controller Pak"; + readonly fileExtension = ".mpk"; + + getConfigFields( + _currentValues: ConfigValues, + _autoDetected?: CartridgeInfo, + ): ResolvedConfigField[] { + return []; + } + + estimateDumpSize(_values: ConfigValues): number { + return CONTROLLER_PAK_SIZE; + } + + validate(_values: ConfigValues): ValidationResult { + return { valid: true }; + } + + buildReadConfig(_values: ConfigValues): ReadConfig { + return { systemId: "n64_controller_pak", params: {} }; + } + + buildOutputFile(rawData: Uint8Array, _config: ReadConfig): OutputFile { + const now = new Date(); + const stamp = + now.getFullYear().toString() + + (now.getMonth() + 1).toString().padStart(2, "0") + + now.getDate().toString().padStart(2, "0"); + return { + data: rawData, + filename: `n64_controller_pak_${stamp}.mpk`, + mimeType: "application/octet-stream", + meta: { Format: "N64 Controller Pak", Size: `${rawData.length} bytes` }, + acceptExtensions: [".mpk", ".pak", ".n64"], + actionLabel: "Save Controller Pak", + }; + } + + async computeHashes(rawData: Uint8Array): Promise<VerificationHashes> { + const [sha1, sha256] = await Promise.all([ + sha1Hex(rawData), + sha256Hex(rawData), + ]); + return { crc32: crc32(rawData), sha1, sha256, size: rawData.length }; + } + + verify( + _hashes: VerificationHashes, + _db: VerificationDB | null, + ): VerificationResult { + // No verification DB for user-written Controller Pak contents — the + // ID-checksum integrity (summarizeDump) is the consistency signal. + return { matched: false, confidence: "none" }; + } + + summarizeDump(rawData: Uint8Array): DumpSummary | null { + const pak = parseControllerPak(rawData); + if (!pak) return null; + + // The ID-block checksum is the "is this a real, formatted pak" signal — + // a failing one means a corrupt / unformatted / bad dump (e.g. dirty + // contacts), so we don't list the (garbage) note directory. + const integrity = pak.idValid + ? { ok: true as const } + : { + ok: false as const, + message: + "No valid Controller Pak ID block — corrupt or unformatted dump", + }; + + const rows = pak.notes.map((n) => [ + n.name || "(unnamed)", + n.code, + String(n.pages), + ]); + + const noteCount = pak.notes.length; + const footer = pak.idValid + ? `${noteCount} note${noteCount === 1 ? "" : "s"} · ` + + `${pak.pagesUsed}/${pak.totalDataPages} pages used` + : "Not a valid Controller Pak"; + + return { + title: "Controller Pak contents", + columns: ["Note", "Game", "Pages"], + monoColumns: [1], + rightAlignColumns: [2], + rows, + footer, + integrity, + }; + } +} diff --git a/src/lib/systems/n64/n64-header.test.ts b/src/lib/systems/n64/n64-header.test.ts new file mode 100644 index 0000000..bfcf190 --- /dev/null +++ b/src/lib/systems/n64/n64-header.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from "vitest"; +import { + calculateCrc, + detectByteOrder, + normalizeByteOrder, + parseN64Header, +} from "./n64-header"; + +const Z64_MAGIC = [0x80, 0x37, 0x12, 0x40]; + +function setBe32(d: Uint8Array, off: number, v: number) { + d[off] = (v >>> 24) & 0xff; + d[off + 1] = (v >>> 16) & 0xff; + d[off + 2] = (v >>> 8) & 0xff; + d[off + 3] = v & 0xff; +} + +/** + * Build a synthetic big-endian (.z64) ROM with a deterministic boot region, + * then stamp the CRC1/CRC2 the parser would compute so the header self-checks. + * The buffer spans the full CRC window (0x1000 + 1 MB) so the boot-block CRC is + * computed over real-ish data, not a truncated region. + */ +function buildRom(opts?: { + title?: string; + media?: number; // 0x3B + cartId?: [number, number]; // 0x3C-0x3D + country?: number; // 0x3E + revision?: number; // 0x3F + cicSelector?: number; // 0x29B +}): Uint8Array { + const o = { + title: "TEST CART", + media: 0x4e, // 'N' cartridge + cartId: [0x53, 0x4d] as [number, number], // "SM" + country: 0x45, // 'E' North America + revision: 0, + cicSelector: 0x3f, // none of the special selectors -> default seed + ...opts, + }; + + const data = new Uint8Array(0x1000 + 0x100000); + // Fill the CRC region with a non-trivial deterministic pattern. + for (let i = 0x1000; i < data.length; i++) data[i] = (i * 31 + 7) & 0xff; + + data.set(Z64_MAGIC, 0); + data[0x29b] = o.cicSelector; + + // 20-byte image name (ASCII subset of Shift-JIS), space-padded. + for (let i = 0; i < 20; i++) { + data[0x20 + i] = i < o.title.length ? o.title.charCodeAt(i) : 0x20; + } + data[0x3b] = o.media; + data[0x3c] = o.cartId[0]; + data[0x3d] = o.cartId[1]; + data[0x3e] = o.country; + data[0x3f] = o.revision; + + // Stamp the matching stored CRC pair. + const crc = calculateCrc(data)!; + setBe32(data, 0x10, crc.crc1); + setBe32(data, 0x14, crc.crc2); + return data; +} + +describe("detectByteOrder", () => { + it("recognizes z64, v64, and n64 magics", () => { + expect(detectByteOrder(Uint8Array.from([0x80, 0x37, 0x12, 0x40]))).toBe( + "z64", + ); + expect(detectByteOrder(Uint8Array.from([0x37, 0x80, 0x40, 0x12]))).toBe( + "v64", + ); + expect(detectByteOrder(Uint8Array.from([0x40, 0x12, 0x37, 0x80]))).toBe( + "n64", + ); + }); + + it("returns null for unrecognized magic", () => { + expect( + detectByteOrder(Uint8Array.from([0x00, 0x01, 0x02, 0x03])), + ).toBeNull(); + }); +}); + +describe("normalizeByteOrder", () => { + it("byte-swaps a v64 image to canonical z64", () => { + const z64 = buildRom(); + // Construct a v64 (16-bit swapped) copy and confirm it normalizes back. + const v64 = new Uint8Array(z64.length); + for (let i = 0; i + 1 < z64.length; i += 2) { + v64[i] = z64[i + 1]; + v64[i + 1] = z64[i]; + } + expect(detectByteOrder(v64)).toBe("v64"); + const norm = normalizeByteOrder(v64)!; + expect(norm.byteOrder).toBe("v64"); + expect(Array.from(norm.data)).toEqual(Array.from(z64)); + }); +}); + +describe("parseN64Header", () => { + it("parses a valid z64 header with matching CRCs", () => { + const header = parseN64Header(buildRom())!; + expect(header.byteOrder).toBe("z64"); + expect(header.title).toBe("TEST CART"); + expect(header.gameCode).toBe("SME"); // cart ID "SM" + region 'E' + expect(header.countryCode).toBe(0x45); + expect(header.region).toBe("North America"); + expect(header.mediaFormat).toBe("Cartridge"); + expect(header.revision).toBe(0); + expect(header.cic).toBe("CIC-NUS-6102/7101"); // default selector + expect(header.checksumValid).toBe(true); + expect(header.calculatedCrc1).toBe(header.crc1); + expect(header.calculatedCrc2).toBe(header.crc2); + }); + + it("self-checks identically through a v64 byte order", () => { + const z64 = buildRom({ country: 0x4a }); // Japan + const v64 = new Uint8Array(z64.length); + for (let i = 0; i + 1 < z64.length; i += 2) { + v64[i] = z64[i + 1]; + v64[i + 1] = z64[i]; + } + const header = parseN64Header(v64)!; + expect(header.byteOrder).toBe("v64"); + expect(header.region).toBe("Japan"); + expect(header.checksumValid).toBe(true); + }); + + it("flags a CRC mismatch when boot-region data is corrupted", () => { + const rom = buildRom(); + rom[0x2000] ^= 0xff; // mutate a byte inside the CRC window + const header = parseN64Header(rom)!; + expect(header.checksumValid).toBe(false); + expect(header.calculatedCrc1).not.toBe(header.crc1); + }); + + it("returns null for unrecognized magic / noise", () => { + const noise = new Uint8Array(0x1000 + 0x100000).fill(0xa5); + expect(parseN64Header(noise)).toBeNull(); + }); + + it("returns null when the buffer is shorter than the header", () => { + const short = new Uint8Array(0x20); + short.set(Z64_MAGIC, 0); + expect(parseN64Header(short)).toBeNull(); + }); +}); diff --git a/src/lib/systems/n64/n64-header.ts b/src/lib/systems/n64/n64-header.ts new file mode 100644 index 0000000..000959e --- /dev/null +++ b/src/lib/systems/n64/n64-header.ts @@ -0,0 +1,334 @@ +/** + * Nintendo 64 internal-header parsing, byte-order normalization, CIC-chip + * detection, and boot-block CRC computation. Pure functions, no DOM. + * + * Unlike SNES there is no whole-ROM internal checksum, but the header CRC1/CRC2 + * (0x10/0x14) cover the IPL3 boot block + first 1 MB and are reproducible: the + * console's CIC (copy-protection) chip seeds an HLE checksum that the boot ROM + * recomputes and compares. We mirror that algorithm here, so a clean dump whose + * recomputed CRC1/CRC2 equal the stored pair is internally consistent (the same + * check the real boot ROM performs). The seed depends on the CIC variant, which + * is fingerprinted from a byte of the IPL3. + * + * References: + * N64 ROM header / cartridge format: https://n64brew.dev/wiki/ROM_Header + * N64 CIC chips and boot checksums: https://n64brew.dev/wiki/CIC-NUS + * N64 ROM byte ordering: https://n64brew.dev/wiki/ROM_Header#Byte_Swap + */ + +export type N64ByteOrder = "z64" | "v64" | "n64"; + +export interface N64Header { + /** Detected source byte order of the input (canonical big-endian is z64). */ + byteOrder: N64ByteOrder; + /** Internal image name (0x20, 20 bytes, Shift-JIS, trailing space/zero trimmed). */ + title: string; + /** Conventional 4-char game serial: cart-ID pair (0x3C-0x3D) + region (0x3E). */ + gameCode: string; + /** Raw country/region byte (0x3E). */ + countryCode: number; + /** Resolved region label for the country byte. */ + region: string; + /** Media-format byte (0x3B, ASCII) and its resolved label. */ + mediaCode: string; + mediaFormat: string; + /** ROM revision / version (0x3F). */ + revision: number; + /** Stored CRC1 (0x10) and CRC2 (0x14), big-endian. */ + crc1: number; + crc2: number; + /** CRC1/CRC2 recomputed from the IPL3 + boot region via the CIC algorithm. */ + calculatedCrc1: number; + calculatedCrc2: number; + /** Detected CIC selector byte (0x29B of the boot block) and its variant name. */ + cicSelector: number; + cic: string; + /** Recomputed CRC pair matches the stored pair (boot-ROM self-check). */ + checksumValid: boolean; +} + +// Header magic words (read big-endian from offset 0). +const MAGIC_BIG_ENDIAN = 0x80371240; // z64, No-Intro canonical +const MAGIC_LITTLE_ENDIAN = 0x40123780; // n64, 32-bit word-swapped +const MAGIC_BYTE_SWAPPED = 0x37804012; // v64, 16-bit byte-swapped + +const FORMAT_MAGIC_LEN = 4; +const CRC1_OFFSET = 0x10; +const CRC2_OFFSET = 0x14; +const TITLE_START = 0x20; +const TITLE_END = 0x34; +const MEDIA_OFFSET = 0x3b; +const CART_ID_START = 0x3c; +const COUNTRY_CODE_OFFSET = 0x3e; +const REVISION_OFFSET = 0x3f; +const HEADER_LEN = 0x40; + +// CIC fingerprinting + boot-block CRC computation. +const CRC_MIN_ROM_SIZE = 0x1000; +const CIC_SELECTOR_OFFSET = 0x29b; +const CIC_6105_SELECTOR = 0x1c; +const CIC_6103_SELECTOR = 0x8d; +const CIC_6106_SELECTOR = 0x9e; +const CIC_6105_SEED = 0xdf26f436; +const CIC_6103_SEED = 0xa3886759; +const CIC_6106_SEED = 0x1fea617a; +const CIC_DEFAULT_SEED = 0xf8ca4ddc; +const CRC_DATA_START = 0x1000; +const CRC_DATA_LEN = 0x100000; +const CIC_6105_TABLE_BASE = 0x0750; + +const REGION_NAMES: Record<number, string> = { + 0x37: "Beta", + 0x41: "Asia (NTSC)", + 0x42: "Brazil", + 0x43: "China", + 0x44: "Germany", + 0x45: "North America", + 0x46: "France", + 0x47: "Gateway 64 (NTSC)", + 0x48: "Netherlands", + 0x49: "Italy", + 0x4a: "Japan", + 0x4b: "Korea", + 0x4c: "Gateway 64 (PAL)", + 0x4e: "Canada", + 0x50: "Europe (basic spec.)", + 0x53: "Spain", + 0x55: "Australia", + 0x57: "Scandinavia", + 0x58: "Europe", + 0x59: "Europe", +}; + +const MEDIA_FORMATS: Record<string, string> = { + N: "Cartridge", + D: "64DD disk", + C: "Cartridge part of expandable game", + E: "64DD expansion for cart", + Z: "Aleck64 cart", +}; + +const CIC_NAMES: Record<number, string> = { + [CIC_6105_SELECTOR]: "CIC-NUS-6105", + [CIC_6103_SELECTOR]: "CIC-NUS-6103", + [CIC_6106_SELECTOR]: "CIC-NUS-6106", +}; + +export function regionName(code: number): string { + return REGION_NAMES[code] ?? `Unknown (0x${code.toString(16).toUpperCase()})`; +} + +export function mediaFormat(code: string): string { + return MEDIA_FORMATS[code] ?? "Unknown"; +} + +export function cicName(selector: number): string { + return CIC_NAMES[selector] ?? "CIC-NUS-6102/7101"; +} + +function be32(data: Uint8Array, base: number): number { + return ( + ((data[base] << 24) | + (data[base + 1] << 16) | + (data[base + 2] << 8) | + data[base + 3]) >>> + 0 + ); +} + +function add32(a: number, b: number): number { + return (a + b) >>> 0; +} + +function mul32(a: number, b: number): number { + return Math.imul(a, b) >>> 0; +} + +/** Rotate-left a 32-bit value (matches Rust u32::rotate_left). */ +function rotl32(v: number, bits: number): number { + const n = bits & 0x1f; + return n === 0 ? v >>> 0 : ((v << n) | (v >>> (32 - n))) >>> 0; +} + +/** Identify byte order from the offset-0 magic, or null if unrecognized. */ +export function detectByteOrder(data: Uint8Array): N64ByteOrder | null { + if (data.length < FORMAT_MAGIC_LEN) return null; + switch (be32(data, 0)) { + case MAGIC_BIG_ENDIAN: + return "z64"; + case MAGIC_LITTLE_ENDIAN: + return "n64"; + case MAGIC_BYTE_SWAPPED: + return "v64"; + default: + return null; + } +} + +/** Swap each adjacent pair of bytes across the buffer (v64 -> z64). */ +function swap16(data: Uint8Array): Uint8Array { + const out = new Uint8Array(data.length); + const n = data.length & ~1; + for (let i = 0; i < n; i += 2) { + out[i] = data[i + 1]; + out[i + 1] = data[i]; + } + if (n !== data.length) out[n] = data[n]; + return out; +} + +/** Reverse each 4-byte word across the buffer (n64 -> z64). */ +function swap32(data: Uint8Array): Uint8Array { + const out = new Uint8Array(data.length); + const n = data.length & ~3; + for (let i = 0; i < n; i += 4) { + out[i] = data[i + 3]; + out[i + 1] = data[i + 2]; + out[i + 2] = data[i + 1]; + out[i + 3] = data[i]; + } + for (let i = n; i < data.length; i++) out[i] = data[i]; + return out; +} + +/** + * Normalize any of the three N64 byte orders to canonical big-endian (z64). + * Returns the (possibly new) buffer plus the detected source order, or null if + * the offset-0 magic matches none of the three known orderings. + */ +export function normalizeByteOrder( + data: Uint8Array, +): { data: Uint8Array; byteOrder: N64ByteOrder } | null { + const order = detectByteOrder(data); + if (order === null) return null; + if (order === "z64") return { data, byteOrder: "z64" }; + if (order === "v64") return { data: swap16(data), byteOrder: "v64" }; + return { data: swap32(data), byteOrder: "n64" }; +} + +const SJIS = new TextDecoder("shift-jis", { fatal: false }); + +/** + * Decode the 20-byte internal image name. The field is conventionally ASCII but + * Japanese carts use Shift-JIS; nabu has a built-in shift-jis TextDecoder (as + * used by the PS1 memory-card parser), so we decode through it (it subsumes + * ASCII) and trim NUL/space padding from both ends. + */ +function decodeTitle(data: Uint8Array): string { + return SJIS.decode(data) + .replace(/[\s\0]+$/, "") + .replace(/^[\s\0]+/, ""); +} + +function asciiChar(b: number): string { + return b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : "?"; +} + +/** + * Recompute the boot-block CRC1/CRC2 the way the N64 boot ROM does. The CIC + * variant (fingerprinted from a byte of IPL3) selects the seed; CRC-6105 also + * folds in a rolling table read from the IPL3 itself. Operates on the 1 MB + * region from 0x1000. Returns null if the buffer is too small to cover it. + */ +export function calculateCrc( + data: Uint8Array, +): { crc1: number; crc2: number; selector: number } | null { + if (data.length < CRC_MIN_ROM_SIZE) return null; + + const selector = data[CIC_SELECTOR_OFFSET]; + const seed = + selector === CIC_6105_SELECTOR + ? CIC_6105_SEED + : selector === CIC_6103_SELECTOR + ? CIC_6103_SEED + : selector === CIC_6106_SELECTOR + ? CIC_6106_SEED + : CIC_DEFAULT_SEED; + + let t1 = seed; + let t2 = seed; + let t3 = seed; + let t4 = seed; + let t5 = seed; + let t6 = seed; + + const end = Math.min(data.length, CRC_DATA_START + CRC_DATA_LEN) & ~3; + for (let i = CRC_DATA_START; i + 4 <= end; i += 4) { + const d = be32(data, i); + const r = rotl32(d, d & 0x1f); + + if (selector === CIC_6105_SELECTOR) { + const tableIndex = CIC_6105_TABLE_BASE + (i & 0xff); + t1 = add32(t1, (be32(data, tableIndex) ^ d) >>> 0); + } else { + t1 = add32(t1, (add32(t5, r) ^ d) >>> 0); + } + t2 = (t2 ^ (t2 > d >>> 0 ? r : (add32(t6, d) ^ d) >>> 0)) >>> 0; + t3 = (t3 ^ d) >>> 0; + // t4 += 1 when t6 + d overflows 32 bits (Rust checked_add is None). + t4 = add32(t4, t6 + d > 0xffffffff ? 1 : 0); + t5 = add32(t5, r); + t6 = add32(t6, d); + } + + let crc1: number; + let crc2: number; + if (selector === CIC_6106_SELECTOR) { + crc1 = add32(mul32(t6, t4), t3); + crc2 = add32(mul32(t5, t2), t1); + } else if (selector === CIC_6103_SELECTOR) { + crc1 = add32((t6 ^ t4) >>> 0, t3); + crc2 = add32((t5 ^ t2) >>> 0, t1); + } else { + crc1 = (t6 ^ t4 ^ t3) >>> 0; + crc2 = (t5 ^ t2 ^ t1) >>> 0; + } + + return { crc1, crc2, selector }; +} + +/** + * Parse the 64-byte internal header. Detects byte order, normalizes the whole + * buffer to big-endian (the boot-block CRC needs the full 1 MB region, not just + * the 64-byte window), then reads the fields and recomputes CRC1/CRC2. Returns + * null if the offset-0 magic is not a recognizable N64 ordering or the buffer + * is shorter than the header. + */ +export function parseN64Header(input: Uint8Array): N64Header | null { + const norm = normalizeByteOrder(input); + if (!norm || norm.data.length < HEADER_LEN) return null; + const data = norm.data; + + const crc1 = be32(data, CRC1_OFFSET); + const crc2 = be32(data, CRC2_OFFSET); + const computed = calculateCrc(data); + const calculatedCrc1 = computed?.crc1 ?? 0; + const calculatedCrc2 = computed?.crc2 ?? 0; + const cicSelector = computed?.selector ?? 0; + + const gameCode = + asciiChar(data[CART_ID_START]) + + asciiChar(data[CART_ID_START + 1]) + + asciiChar(data[COUNTRY_CODE_OFFSET]); + const mediaCode = asciiChar(data[MEDIA_OFFSET]); + const countryCode = data[COUNTRY_CODE_OFFSET]; + + return { + byteOrder: norm.byteOrder, + title: decodeTitle(data.subarray(TITLE_START, TITLE_END)), + gameCode, + countryCode, + region: regionName(countryCode), + mediaCode, + mediaFormat: mediaFormat(mediaCode), + revision: data[REVISION_OFFSET], + crc1, + crc2, + calculatedCrc1, + calculatedCrc2, + cicSelector, + cic: cicName(cicSelector), + checksumValid: + computed !== null && calculatedCrc1 === crc1 && calculatedCrc2 === crc2, + }; +} diff --git a/src/lib/systems/n64/n64-rom.test.ts b/src/lib/systems/n64/n64-rom.test.ts new file mode 100644 index 0000000..fdb975d --- /dev/null +++ b/src/lib/systems/n64/n64-rom.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect } from "vitest"; +import { + detectByteOrder, + detectHeader, + normalizeByteOrder, + trimN64, +} from "./n64-rom"; + +const MB = 1024 * 1024; + +/** Deterministic, well-varied byte at index i. */ +const fillByte = (i: number) => (i ^ (i >>> 7) ^ (i >>> 13) ^ 0x5a) & 0xff; + +/** + * Build a synthetic canonical big-endian (.z64) ROM with a sane 64-byte + * internal header at offset 0: magic 80 37 12 40, an internal name at 0x20, + * a 4-char game code (media/cartID/region at 0x3B..0x3E), and a version byte. + */ +function buildRom(size: number): Uint8Array { + const data = new Uint8Array(size); + for (let i = 0; i < size; i++) data[i] = fillByte(i); + // PI config magic (big-endian) + data[0] = 0x80; + data[1] = 0x37; + data[2] = 0x12; + data[3] = 0x40; + // boot address 0x80000400 + data[0x08] = 0x80; + data[0x09] = 0x00; + data[0x0a] = 0x04; + data[0x0b] = 0x00; + // internal name "TESTROM" padded with spaces + const name = "TESTROM"; + for (let i = 0; i < 20; i++) { + data[0x20 + i] = i < name.length ? name.charCodeAt(i) : 0x20; + } + // game code N X X E (media / cartID hi / cartID lo / region) + data[0x3b] = "N".charCodeAt(0); + data[0x3c] = "X".charCodeAt(0); + data[0x3d] = "X".charCodeAt(0); + data[0x3e] = "E".charCodeAt(0); + data[0x3f] = 0x00; // revision 0 + return data; +} + +/** Reference v64 (16-bit byte-swapped) form of a big-endian buffer. */ +function toV64(z64: Uint8Array): Uint8Array { + const out = new Uint8Array(z64.length); + for (let i = 0; i < z64.length; i += 2) { + out[i] = z64[i + 1]; + out[i + 1] = z64[i]; + } + return out; +} + +/** Reference n64 (32-bit word-swapped) form of a big-endian buffer. */ +function toN64(z64: Uint8Array): Uint8Array { + const out = new Uint8Array(z64.length); + for (let i = 0; i < z64.length; i += 4) { + out[i] = z64[i + 3]; + out[i + 1] = z64[i + 2]; + out[i + 2] = z64[i + 1]; + out[i + 3] = z64[i]; + } + return out; +} + +/** Over-dump where the tail mirrors the block immediately before `realSize`. */ +function mirrorOverdump(real: Uint8Array, total: number): Uint8Array { + const out = new Uint8Array(total); + out.set(real, 0); + const t = total - real.length; + out.set(real.subarray(real.length - t, real.length), real.length); + return out; +} + +function fillOverdump(real: Uint8Array, total: number, v: number): Uint8Array { + const out = new Uint8Array(total); + out.set(real, 0); + out.fill(v, real.length); + return out; +} + +/** Fast byte equality (vitest's toEqual is far too slow on multi-MB arrays). */ +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +describe("detectByteOrder", () => { + it("identifies each of the three byte orders from the offset-0 magic", () => { + const z64 = buildRom(2 * MB); + expect(detectByteOrder(z64)).toBe("z64"); + expect(detectByteOrder(toV64(z64))).toBe("v64"); + expect(detectByteOrder(toN64(z64))).toBe("n64"); + }); + + it("returns null when the magic matches none of the three orderings", () => { + const noise = new Uint8Array(0x40); + for (let i = 0; i < noise.length; i++) noise[i] = (i * 37) & 0xff; + expect(detectByteOrder(noise)).toBeNull(); + }); +}); + +describe("normalizeByteOrder", () => { + it("leaves a z64 image unchanged", () => { + const z64 = buildRom(2 * MB); + const norm = normalizeByteOrder(z64); + expect(norm).not.toBeNull(); + expect(norm!.byteOrder).toBe("z64"); + expect(norm!.data).toBe(z64); // same reference, no copy + }); + + it("normalizes a v64 (byte-swapped) image back to the z64 bytes", () => { + const z64 = buildRom(2 * MB); + const norm = normalizeByteOrder(toV64(z64)); + expect(norm!.byteOrder).toBe("v64"); + expect(bytesEqual(norm!.data, z64)).toBe(true); + }); + + it("normalizes an n64 (word-swapped) image back to the z64 bytes", () => { + const z64 = buildRom(2 * MB); + const norm = normalizeByteOrder(toN64(z64)); + expect(norm!.byteOrder).toBe("n64"); + expect(bytesEqual(norm!.data, z64)).toBe(true); + }); + + it("returns null for an unrecognized magic", () => { + const noise = new Uint8Array(0x40); + expect(normalizeByteOrder(noise)).toBeNull(); + }); +}); + +describe("detectHeader", () => { + it("parses the header regardless of input byte order", () => { + const z64 = buildRom(2 * MB); + for (const buf of [z64, toV64(z64), toN64(z64)]) { + const h = detectHeader(buf); + expect(h).not.toBeNull(); + expect(h!.name).toBe("TESTROM"); + expect(h!.gameCode).toBe("NXXE"); + expect(h!.regionCode).toBe("E"); + expect(h!.bootAddress).toBe(0x80000400); + expect(h!.version).toBe(0); + } + }); + + it("reports the source byte order it detected", () => { + const z64 = buildRom(2 * MB); + expect(detectHeader(z64)!.byteOrder).toBe("z64"); + expect(detectHeader(toV64(z64))!.byteOrder).toBe("v64"); + expect(detectHeader(toN64(z64))!.byteOrder).toBe("n64"); + }); + + it("returns null for noise", () => { + const noise = new Uint8Array(0x40); + for (let i = 0; i < noise.length; i++) noise[i] = (i * 91) & 0xff; + expect(detectHeader(noise)).toBeNull(); + }); +}); + +describe("trimN64 byte-order normalization", () => { + it("normalizes all three orders to identical z64 bytes (no trim needed)", () => { + const z64 = buildRom(2 * MB); + const fromZ = trimN64(z64).data; + const fromV = trimN64(toV64(z64)).data; + const fromN = trimN64(toN64(z64)).data; + expect(bytesEqual(fromZ, z64)).toBe(true); + expect(bytesEqual(fromV, z64)).toBe(true); + expect(bytesEqual(fromN, z64)).toBe(true); + }); + + it("notes the swap when input was not z64", () => { + const z64 = buildRom(2 * MB); + expect(trimN64(z64).note).toBeUndefined(); + expect(trimN64(toV64(z64)).note).toMatch(/v64/); + expect(trimN64(toN64(z64)).note).toMatch(/n64/); + }); +}); + +describe("trimN64 over-dump trimming", () => { + it("trims a 2MB cart 0xFF-padded to 4MB down to 2MB", () => { + const z64 = buildRom(2 * MB); + const over = fillOverdump(z64, 4 * MB, 0xff); + const { data } = trimN64(over); + expect(data.length).toBe(2 * MB); + expect(bytesEqual(data, z64)).toBe(true); + }); + + it("trims a 2MB cart 0x00-padded to 4MB down to 2MB", () => { + const z64 = buildRom(2 * MB); + const over = fillOverdump(z64, 4 * MB, 0x00); + expect(trimN64(over).data.length).toBe(2 * MB); + }); + + it("does NOT trim an exact-halving 4MB-in-8MB clean mirror (fail-safe)", () => { + // tail [4:8]MB === [0:4]MB is a clean two-fold repetition, indistinguishable + // from a legitimate identical-halves ROM without a DAT probe; the heuristic + // must keep all 8MB rather than risk silently halving a real cart. + const z64 = buildRom(4 * MB); + const over = mirrorOverdump(z64, 8 * MB); // tail == [0:4MB] + expect(trimN64(over).data.length).toBe(8 * MB); + }); + + it("trims the non-divisor 6MB-in-8MB partial-tail-mirror case to 6MB", () => { + // 6 MB does not divide 8 MB: the trailing 2 MB mirrors [4:6]MB, which a + // whole-dump clean-repeat test would miss but the preceding-block mirror + // test catches. + const z64 = buildRom(6 * MB); + const over = mirrorOverdump(z64, 8 * MB); + const { data } = trimN64(over); + expect(data.length).toBe(6 * MB); + expect(bytesEqual(data, z64)).toBe(true); + }); + + it("trims the documented 12MB-in-16MB partial over-dump to 12MB", () => { + const z64 = buildRom(12 * MB); + const over = fillOverdump(z64, 16 * MB, 0x00); + expect(trimN64(over).data.length).toBe(12 * MB); + }); + + it("trims a byte-swapped over-dump after normalizing first", () => { + const z64 = buildRom(4 * MB); + const over = fillOverdump(z64, 8 * MB, 0xff); + const { data } = trimN64(toV64(over)); + expect(data.length).toBe(4 * MB); + expect(bytesEqual(data, z64)).toBe(true); + }); + + it("leaves an already-correct ROM untouched", () => { + const z64 = buildRom(4 * MB); + expect(trimN64(z64).data.length).toBe(4 * MB); + }); + + it("does not collapse a legitimate identical-halves ROM", () => { + // Two byte-identical 1MB halves form a clean two-fold repetition, which is + // NOT by itself proof of an over-dump (verdict CASE C). The tail [1:2]MB + // equals the preceding block [0:1]MB, but because the tail length equals + // the kept length (exact halving) the heuristic refuses to trim — only the + // authoritative DAT hash probe may confirm that case. Keep all 2MB. + const half = buildRom(1 * MB); + const whole = new Uint8Array(2 * MB); + whole.set(half, 0); + whole.set(half, 1 * MB); + expect(trimN64(whole).data.length).toBe(2 * MB); + }); + + it("does not trim when the magic is unrecognized", () => { + const over = new Uint8Array(4 * MB); + for (let i = 0; i < over.length; i++) over[i] = (i * 91) & 0xff; + expect(trimN64(over).data.length).toBe(4 * MB); + }); +}); diff --git a/src/lib/systems/n64/n64-rom.ts b/src/lib/systems/n64/n64-rom.ts new file mode 100644 index 0000000..066c281 --- /dev/null +++ b/src/lib/systems/n64/n64-rom.ts @@ -0,0 +1,282 @@ +/** + * Nintendo 64 internal-header parsing, byte-order normalization, and over-dump + * trimming. Shared by the N64 SystemHandler (validation/summary) and the + * Retrode 2 over-dump trimmer. Pure functions, no DOM dependencies. + * + * The defining N64 concern is BYTE ORDER: the same physical cart can present as + * .z64 (big-endian, No-Intro canonical), .v64 (16-bit byte-swapped), or .n64 + * (32-bit word-swapped). The file extension is unreliable — the Retrode's + * `[n64RomExt] z64` default only labels the FILENAME, it does not byte-swap the + * data — so byte order is detected from the 4 magic bytes at offset 0 and + * normalized to big-endian z64 BEFORE hashing, trimming, or No-Intro matching. + * + * Unlike SNES there is NO whole-ROM internal checksum to recompute. The header + * CRC1/CRC2 (0x10/0x14) cover only the IPL3 boot block + first 1 MB and depend + * on the cart's CIC variant; they are fixed-region and size-independent, so + * they CANNOT confirm a trim length (an over-dump validates them just as + * readily as the correct dump). Trimming is therefore shape-based (trailing + * mirror/fill), and No-Intro SHA-1/CRC32 over the normalized image is the + * authority. When a DAT is loaded its per-length hash probe must win over any + * heuristic; the heuristic here is the DB-less fallback registered in the + * synchronous Retrode trimmer. + */ + +export type N64ByteOrder = "z64" | "v64" | "n64"; + +export interface N64Header { + /** Detected source byte order of the input. */ + byteOrder: N64ByteOrder; + /** Initial PC / boot entry point (0x08, big-endian). */ + bootAddress: number; + /** CRC1 (0x10) and CRC2 (0x14) — fixed-region, advisory only. */ + crc1: number; + crc2: number; + /** Internal image name (0x20, 20 bytes ASCII, trailing space/zero trimmed). */ + name: string; + /** + * Conventional 4-char game code: media byte (0x3B) + cart ID (0x3C-0x3D) + + * region (0x3E), e.g. "NXXE". + */ + gameCode: string; + /** Region/country byte (0x3E, ASCII). */ + regionCode: string; + /** ROM version / revision (0x3F). */ + version: number; +} + +/** Real N64 mask-ROM byte capacities (ascending). 6 MB / 12 MB are the + * notable NON-power-of-two cases that defeat any "round to power of two". */ +export const N64_SIZE_LADDER = [ + 0x80000, // 512 KB (4 Mbit, rare) + 0x100000, // 1 MB (8 Mbit) + 0x200000, // 2 MB (16 Mbit) + 0x400000, // 4 MB (32 Mbit) + 0x600000, // 6 MB (48 Mbit) + 0x800000, // 8 MB (64 Mbit) + 0xc00000, // 12 MB (96 Mbit) + 0x1000000, // 16 MB (128 Mbit) + 0x2000000, // 32 MB (256 Mbit) + 0x4000000, // 64 MB (512 Mbit) +]; + +const Z64_MAGIC = [0x80, 0x37, 0x12, 0x40]; // big-endian, No-Intro canonical +const V64_MAGIC = [0x37, 0x80, 0x40, 0x12]; // 16-bit byte-swapped (Doctor V64) +const N64_MAGIC = [0x40, 0x12, 0x37, 0x80]; // 32-bit word-swapped / little-endian + +function magicMatches(data: Uint8Array, magic: number[]): boolean { + return ( + data.length >= 4 && + data[0] === magic[0] && + data[1] === magic[1] && + data[2] === magic[2] && + data[3] === magic[3] + ); +} + +/** Identify the byte order from the offset-0 magic, or null if unrecognized. */ +export function detectByteOrder(data: Uint8Array): N64ByteOrder | null { + if (magicMatches(data, Z64_MAGIC)) return "z64"; + if (magicMatches(data, V64_MAGIC)) return "v64"; + if (magicMatches(data, N64_MAGIC)) return "n64"; + return null; +} + +/** Swap each adjacent pair of bytes across the whole buffer (v64 -> z64). */ +function swap16(data: Uint8Array): Uint8Array { + const out = new Uint8Array(data.length); + const n = data.length & ~1; + for (let i = 0; i < n; i += 2) { + out[i] = data[i + 1]; + out[i + 1] = data[i]; + } + // Carry any odd trailing byte verbatim (shouldn't happen on real dumps). + if (n !== data.length) out[n] = data[n]; + return out; +} + +/** Reverse each 4-byte word across the whole buffer (n64 -> z64). */ +function swap32(data: Uint8Array): Uint8Array { + const out = new Uint8Array(data.length); + const n = data.length & ~3; + for (let i = 0; i < n; i += 4) { + out[i] = data[i + 3]; + out[i + 1] = data[i + 2]; + out[i + 2] = data[i + 1]; + out[i + 3] = data[i]; + } + // Carry any trailing remainder verbatim (shouldn't happen on real dumps). + for (let i = n; i < data.length; i++) out[i] = data[i]; + return out; +} + +/** + * Normalize any of the three N64 byte orders to canonical big-endian (z64). + * Returns the (possibly new) buffer plus the detected source order, or null if + * the offset-0 magic matches none of the three known orderings. + */ +export function normalizeByteOrder( + data: Uint8Array, +): { data: Uint8Array; byteOrder: N64ByteOrder } | null { + const order = detectByteOrder(data); + if (order === null) return null; + if (order === "z64") return { data, byteOrder: "z64" }; + if (order === "v64") return { data: swap16(data), byteOrder: "v64" }; + return { data: swap32(data), byteOrder: "n64" }; +} + +function decodeName(data: Uint8Array, base: number, len: number): string { + let s = ""; + for (let i = 0; i < len; i++) { + const b = data[base + i]; + s += b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : " "; + } + return s.replace(/\s+$/, ""); +} + +function asciiChar(b: number): string { + return b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : "?"; +} + +function be32(data: Uint8Array, base: number): number { + return ( + ((data[base] << 24) | + (data[base + 1] << 16) | + (data[base + 2] << 8) | + data[base + 3]) >>> + 0 + ); +} + +/** + * Parse the 64-byte internal header. Detects byte order and reads the fields + * from a big-endian view. Only the 64-byte header window is normalized (not the + * whole multi-MB buffer), so this is cheap to call on a raw v64/n64 dump. + * Returns null if the offset-0 magic is not a recognizable N64 ordering or the + * buffer is too short. + */ +export function detectHeader(input: Uint8Array): N64Header | null { + const order = detectByteOrder(input); + if (order === null || input.length < 0x40) return null; + const window = input.subarray(0, 0x40); + const data = + order === "z64" + ? window + : order === "v64" + ? swap16(window) + : swap32(window); + const gameCode = + asciiChar(data[0x3b]) + + asciiChar(data[0x3c]) + + asciiChar(data[0x3d]) + + asciiChar(data[0x3e]); + return { + byteOrder: order, + bootAddress: be32(data, 0x08), + crc1: be32(data, 0x10), + crc2: be32(data, 0x14), + name: decodeName(data, 0x20, 20), + gameCode, + regionCode: asciiChar(data[0x3e]), + version: data[0x3f], + }; +} + +/** The tail [L:end] is entirely 0x00 or entirely 0xFF. */ +function isTailFill(data: Uint8Array, L: number): boolean { + const end = data.length; + if (end <= L) return false; + const v = data[L]; + if (v !== 0x00 && v !== 0xff) return false; + for (let i = L + 1; i < end; i++) if (data[i] !== v) return false; + return true; +} + +function regionsEqual( + data: Uint8Array, + aStart: number, + bStart: number, + len: number, +): boolean { + for (let i = 0; i < len; i++) { + if (data[aStart + i] !== data[bStart + i]) return false; + } + return true; +} + +/** + * The tail [L:end] is a byte-exact mirror of the equal-length block that + * immediately precedes L. Catches the dominant Retrode partial over-dump shape + * (e.g. 6 MB die in an 8 MB window, or 12 MB in 16 MB), which a whole-dump + * clean-repeat test misses because the size is not an even divisor. + * + * The exact-halving case (tail length === kept length) is deliberately + * REFUSED: a dump whose two halves are byte-identical is a clean two-fold + * repetition that can be a legitimate duplicated-data ROM, not proof of an + * over-dump (verdict CASE C). Collapsing it would silently halve a real cart; + * the authoritative per-length No-Intro hash probe (async, DB-backed) is what + * resolves that case, not this DB-less heuristic. + */ +function isTailMirror(data: Uint8Array, L: number): boolean { + const t = data.length - L; + if (t <= 0 || t >= L) return false; // partial tail only; never exact halving + return regionsEqual(data, L, L - t, t); +} + +/** + * Trim a Retrode N64 over-dump to the true mask-ROM length. Normalizes byte + * order to big-endian first (so mirror/fill detection sees real ROM bytes), + * then returns the smallest known capacity whose trailing region is a provable + * fill run or a mirror of the block before it. If the magic is unrecognized, + * nothing shorter qualifies, or the dump is already a clean capacity with no + * trailing redundancy, the (normalized) input is returned unchanged. + * + * Heuristic only — there is no internal checksum to gate the trim on (CRC1/CRC2 + * are fixed-region) and this synchronous trimmer has no No-Intro DB. It is + * deliberately fail-safe: it keeps too much rather than too little, leaving the + * authoritative per-length DAT hash probe to a later async verify stage. In + * particular it does NOT collapse a clean whole-dump repetition (identical + * halves can be a legitimate ROM, not an over-dump), so it only ever trims a + * tail that mirrors the block before it or is uniform fill. + * + * Save type (EEPROM 4k/16k, SRAM, FlashRAM) is NOT in the header — see + * saveDetection in the spec; left to a cart-ID database / user selection. + */ +export function trimN64(input: Uint8Array): { data: Uint8Array; note?: string } { + const norm = normalizeByteOrder(input); + if (!norm) return { data: input }; + const data = norm.data; + const swapNote = + norm.byteOrder !== "z64" + ? `normalized ${norm.byteOrder} byte order to big-endian z64` + : undefined; + const passthrough = swapNote ? { data, note: swapNote } : { data }; + + const D = data.length; + for (const L of N64_SIZE_LADDER) { + if (L >= D) break; // ladder is ascending + if (!isTailFill(data, L) && !isTailMirror(data, L)) continue; + const mb = (L / (1024 * 1024)).toFixed(L % (1024 * 1024) === 0 ? 0 : 1); + const note = + (swapNote ? `${swapNote}; ` : "") + `over-dump trimmed to ${mb} MB`; + return { data: data.subarray(0, L), note }; + } + return passthrough; +} + +const REGION_NAMES: Record<string, string> = { + E: "USA", + J: "Japan", + P: "Europe (PAL)", + D: "Germany", + F: "France", + I: "Italy", + S: "Spain", + A: "Asia", + U: "Australia", + X: "Europe (alt)", + Y: "Europe (alt)", +}; + +export function regionName(code: string): string { + return REGION_NAMES[code] ?? `Region '${code}'`; +} diff --git a/src/lib/systems/n64/n64-system-handler.test.ts b/src/lib/systems/n64/n64-system-handler.test.ts new file mode 100644 index 0000000..213b1ff --- /dev/null +++ b/src/lib/systems/n64/n64-system-handler.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; +import { N64SystemHandler } from "./n64-system-handler"; + +const handler = new N64SystemHandler(); + +/** + * Build a synthetic canonical big-endian (.z64) ROM with a sane 64-byte + * internal header: magic 80 37 12 40 and a 20-byte internal name at 0x20, + * padded with the given pad byte (space by default). + */ +function buildRom(name: string, pad = 0x20): Uint8Array { + const data = new Uint8Array(0x40); + data[0] = 0x80; + data[1] = 0x37; + data[2] = 0x12; + data[3] = 0x40; + for (let i = 0; i < 20; i++) { + data[0x20 + i] = i < name.length ? name.charCodeAt(i) : pad; + } + return data; +} + +/** Reference v64 (16-bit byte-swapped) form of a big-endian buffer. */ +function toV64(z64: Uint8Array): Uint8Array { + const out = new Uint8Array(z64.length); + for (let i = 0; i < z64.length; i += 2) { + out[i] = z64[i + 1]; + out[i + 1] = z64[i]; + } + return out; +} + +/** Reference n64 (32-bit word-swapped) form of a big-endian buffer. */ +function toN64(z64: Uint8Array): Uint8Array { + const out = new Uint8Array(z64.length); + for (let i = 0; i < z64.length; i += 4) { + out[i] = z64[i + 3]; + out[i + 1] = z64[i + 2]; + out[i + 2] = z64[i + 1]; + out[i + 3] = z64[i]; + } + return out; +} + +describe("N64SystemHandler.headerTitle", () => { + it("returns the internal name from a canonical z64 image", () => { + expect(handler.headerTitle(buildRom("TESTROM"))).toBe("TESTROM"); + }); + + it("yields the same title for a byte-swapped (.v64) input", () => { + const z64 = buildRom("TESTROM"); + expect(handler.headerTitle(toV64(z64))).toBe("TESTROM"); + }); + + it("yields the same title for a word-swapped (.n64) input", () => { + const z64 = buildRom("TESTROM"); + expect(handler.headerTitle(toN64(z64))).toBe("TESTROM"); + }); + + it("trims trailing spaces and zero padding from the name", () => { + expect(handler.headerTitle(buildRom("TEST ROM NAME"))).toBe( + "TEST ROM NAME", + ); + expect(handler.headerTitle(buildRom("TESTGAME", 0x00))).toBe("TESTGAME"); + }); + + it("returns undefined when the magic is unrecognized (no header)", () => { + const noise = new Uint8Array(0x40); + for (let i = 0; i < noise.length; i++) noise[i] = (i * 91) & 0xff; + expect(handler.headerTitle(noise)).toBeUndefined(); + }); + + it("returns undefined when the buffer is too short for a header", () => { + const short = new Uint8Array([0x80, 0x37, 0x12, 0x40]); + expect(handler.headerTitle(short)).toBeUndefined(); + }); + + it("returns undefined when the name field is entirely blank", () => { + // Valid magic, but the 20-byte name is all spaces -> blank title. + expect(handler.headerTitle(buildRom(""))).toBeUndefined(); + }); + + it("returns undefined when the name is all non-printable bytes", () => { + const data = buildRom("X"); + for (let i = 0; i < 20; i++) data[0x20 + i] = 0x01; // control bytes + expect(handler.headerTitle(data)).toBeUndefined(); + }); +}); diff --git a/src/lib/systems/n64/n64-system-handler.ts b/src/lib/systems/n64/n64-system-handler.ts new file mode 100644 index 0000000..56709b6 --- /dev/null +++ b/src/lib/systems/n64/n64-system-handler.ts @@ -0,0 +1,205 @@ +import type { + SystemHandler, + ConfigValues, + CartridgeInfo, + DumpSummary, + DumpSummaryCell, + ResolvedConfigField, + ValidationResult, + ReadConfig, + OutputFile, + VerificationHashes, + VerificationDB, + VerificationResult, +} from "@/lib/types"; +import { crc32, sha1Hex, sha256Hex } from "@/lib/core/hashing"; +import { + detectHeader, + normalizeByteOrder, + regionName, + trimN64, +} from "./n64-rom"; +import { parseN64Header } from "./n64-header"; + +const hex32 = (v: number): string => + `0x${(v >>> 0).toString(16).toUpperCase().padStart(8, "0")}`; + +/** + * Force any of the three N64 byte orders (.z64/.v64/.n64) to canonical + * big-endian z64 before hashing or packaging. This is CORRECTNESS, not a + * heuristic: a .v64/.n64-ordered dump can never match the z64 No-Intro DAT, so + * normalization is always applied (and is idempotent for an already-z64 image). + * Over-dump TRIMMING, by contrast, is shape-based guesswork and is offered + * opt-in via suggestTrim. Unrecognized magic is passed through unchanged. + */ +function canonical(data: Uint8Array): Uint8Array { + return normalizeByteOrder(data)?.data ?? data; +} + +/** + * Nintendo 64. ROMs are raw mask-ROM images with an INTERNAL 64-byte header at + * offset 0 (part of the ROM content), so — like GBA/SNES, unlike NES — the + * No-Intro hash is over the whole (normalized) ROM with no external header + * splice. Byte-order normalization to canonical big-endian (.z64) is applied + * here, always, in computeHashes/buildOutputFile; over-dump trimming is offered + * post-dump via suggestTrim (see n64-rom.ts trimN64). + * + * Save type (EEPROM 4k/16k, SRAM, FlashRAM) is NOT encoded in the header and is + * out of scope for the Retrode's mass-storage path — left to a cart-ID + * database / user selection, not derived here. + */ +export class N64SystemHandler implements SystemHandler { + readonly systemId = "n64"; + readonly displayName = "Nintendo 64"; + readonly fileExtension = ".z64"; + + getConfigFields( + _currentValues: ConfigValues, + autoDetected?: CartridgeInfo, + ): ResolvedConfigField[] { + const fields: ResolvedConfigField[] = []; + if (autoDetected?.title) { + fields.push({ + key: "title", + label: "Game Title", + type: "readonly", + value: autoDetected.title, + autoDetected: true, + group: "cartridge", + order: 0, + }); + } + const region = autoDetected?.meta?.region; + if (typeof region === "string") { + fields.push({ + key: "region", + label: "Region", + type: "readonly", + value: region, + autoDetected: true, + group: "cartridge", + order: 1, + }); + } + return fields; + } + + validate(_values: ConfigValues): ValidationResult { + return { valid: true }; + } + + suggestTrim(content: Uint8Array): { size: number; note: string } | null { + // Normalize FIRST (byte-order is always-on, not part of this opt-in), then + // trim, so the note describes only the trim — not the byte-swap that the + // emitted file already carries regardless of this toggle. Lengths are + // unchanged by the swap, so the size indexes the raw content identically. + const { data, note } = trimN64(canonical(content)); + return data.length < content.length + ? { size: data.length, note: note ?? "heuristic over-dump trim" } + : null; + } + + buildReadConfig(_values: ConfigValues): ReadConfig { + return { systemId: "n64", params: {} }; + } + + /** + * Device-independent title from the ROM's INTERNAL 20-byte image name at + * 0x20. Byte order is normalized to canonical big-endian FIRST so a .v64/.n64 + * dump reads the same name a .z64 does — detectHeader handles the swap on the + * 64-byte window. Returns the trimmed name, or undefined when the header is + * absent (unrecognized magic / too short) or the name is blank. + */ + headerTitle(content: Uint8Array): string | undefined { + const name = detectHeader(canonical(content))?.name.trim(); + return name ? name : undefined; + } + + buildOutputFile(rawData: Uint8Array, _config: ReadConfig): OutputFile { + const data = canonical(rawData); + const header = detectHeader(data); + const meta: Record<string, string> = { Format: "Nintendo 64" }; + if (header) { + if (header.name) meta["Internal Name"] = header.name; + meta["Game Code"] = header.gameCode; + meta.Region = regionName(header.regionCode); + // CRC1/CRC2 are fixed-region (IPL3 + first 1 MB) and CIC-dependent; + // surfaced for reference only — never used to validate the dump. + meta.CRC1 = `0x${header.crc1.toString(16).toUpperCase().padStart(8, "0")}`; + meta.CRC2 = `0x${header.crc2.toString(16).toUpperCase().padStart(8, "0")}`; + } + return { + data, + filename: "dump.z64", + mimeType: "application/octet-stream", + meta, + }; + } + + async computeHashes(rawData: Uint8Array): Promise<VerificationHashes> { + const data = canonical(rawData); + const [sha1, sha256] = await Promise.all([sha1Hex(data), sha256Hex(data)]); + return { crc32: crc32(data), sha1, sha256, size: data.length }; + } + + verify( + hashes: VerificationHashes, + db: VerificationDB | null, + ): VerificationResult { + if (!db) return { matched: false, confidence: "none" }; + const entry = db.lookup(hashes); + if (entry) return { matched: true, entry, confidence: "exact" }; + return { + matched: false, + confidence: "none", + suggestions: [ + "No No-Intro match. Confirm the dump is normalized to big-endian " + + "(.z64) and trimmed — a .v64/.n64 byte order or an over-dump tail " + + "will never match the z64 DAT.", + ], + }; + } + + /** + * Internal-header self-check, available with NO No-Intro database. The N64 + * boot ROM recomputes CRC1/CRC2 over the IPL3 + first 1 MB (seeded by the + * cart's CIC variant) and compares them to the stored pair before booting; + * we replicate that here. A clean dump's recomputed CRCs match the stored + * ones, so this is a genuine consistency signal even when no DAT is loaded — + * surfaced as a neutral integrity result, never a No-Intro "verified". Null + * when no recognizable N64 header is present (don't fabricate over noise). + */ + summarizeDump(rawData: Uint8Array): DumpSummary | null { + const header = parseN64Header(rawData); + if (!header) return null; + + const rows: DumpSummaryCell[][] = []; + if (header.title) rows.push(["Internal Name", header.title]); + rows.push(["Game Code", header.gameCode]); + rows.push(["Region", header.region]); + rows.push(["Media Format", header.mediaFormat]); + rows.push(["Revision", String(header.revision)]); + rows.push(["CIC Chip", header.cic]); + rows.push([ + "CRC1", + hex32(header.crc1) + (header.checksumValid ? " (ok)" : " (mismatch)"), + ]); + rows.push([ + "CRC2", + hex32(header.crc2) + (header.checksumValid ? " (ok)" : " (mismatch)"), + ]); + + return { + title: "N64 header", + columns: ["Field", "Value"], + monoColumns: [1], + rows, + integrity: { + ok: header.checksumValid, + message: header.checksumValid + ? "Internal checksum consistent" + : "Nintendo 64 internal checksum mismatch", + }, + }; + } +} diff --git a/src/lib/systems/sms/sms-header.test.ts b/src/lib/systems/sms/sms-header.test.ts new file mode 100644 index 0000000..fcf01f9 --- /dev/null +++ b/src/lib/systems/sms/sms-header.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect } from "vitest"; +import { SMSSystemHandler } from "./sms-system-handler"; +import { calculateChecksum, parseSmsHeader } from "./sms-header"; + +const TMR_SEGA = [0x54, 0x4d, 0x52, 0x20, 0x53, 0x45, 0x47, 0x41]; + +/** Deterministic, well-varied byte at index i (so the body isn't all-zero). */ +const fillByte = (i: number) => (i ^ (i >>> 5) ^ (i >>> 11) ^ 0x3c) & 0xff; + +interface HeaderFields { + size: number; + /** Header offset; defaults to $7FF0. */ + offset?: number; + /** Product code as packed BCD source (low/mid bytes + high digit). */ + productLow?: number; + productMid?: number; + productHighDigit?: number; + /** High nibble of byte $0E. */ + romSizeCode: number; + /** High nibble of byte $0F. */ + regionCode: number; + /** Low nibble of byte $0F. */ + version?: number; +} + +/** + * Build a synthetic SMS/GG ROM carrying a "TMR SEGA" header with a VALID stored + * checksum: we write the header, recompute the checksum the same way the parser + * does (calculateChecksum), then store it LE — so parseSmsHeader reports + * checksumValid === true. + */ +function buildRom(f: HeaderFields): Uint8Array { + const data = new Uint8Array(f.size); + for (let i = 0; i < f.size; i++) data[i] = fillByte(i); + + const base = f.offset ?? 0x7ff0; + for (let i = 0; i < TMR_SEGA.length; i++) data[base + i] = TMR_SEGA[i]; + data[base + 0x0c] = f.productLow ?? 0x00; + data[base + 0x0d] = f.productMid ?? 0x00; + data[base + 0x0e] = + ((f.romSizeCode & 0x0f) << 4) | ((f.productHighDigit ?? 0) & 0x0f); + data[base + 0x0f] = ((f.regionCode & 0x0f) << 4) | ((f.version ?? 0) & 0x0f); + + // Zero the stored-checksum field, compute over the size-code range, store LE. + data[base + 0x0a] = 0; + data[base + 0x0b] = 0; + const c = calculateChecksum(data, base, f.romSizeCode); + data[base + 0x0a] = c & 0xff; + data[base + 0x0b] = (c >> 8) & 0xff; + return data; +} + +describe("parseSmsHeader", () => { + it("parses a 32 KB SMS export header with a valid checksum", () => { + // Product code BCD: high digit 1, mid byte 0x23, low byte 0x45 -> 12345. + const rom = buildRom({ + size: 0x8000, + romSizeCode: 0xc, // 32 KB + regionCode: 0x4, // Export (SMS) + version: 2, + productHighDigit: 1, + productMid: 0x23, + productLow: 0x45, + }); + const h = parseSmsHeader(rom)!; + expect(h).not.toBeNull(); + expect(h.console).toBe("Sega Master System"); + expect(h.regionDescription).toBe("Export (SMS)"); + expect(h.productCode).toBe(12345); + expect(h.version).toBe(2); + expect(h.romSizeCode).toBe(0xc); + expect(h.romSizeBytes).toBe(32 * 1024); + expect(h.headerOffset).toBe(0x7ff0); + expect(h.checksumValid).toBe(true); + expect(h.sizeHeaderMismatch).toBe(false); + }); + + it("classifies a Game Gear region nibble as Game Gear", () => { + const rom = buildRom({ + size: 0x8000, + romSizeCode: 0xc, + regionCode: 0x6, // Export (GG) + }); + const h = parseSmsHeader(rom)!; + expect(h.console).toBe("Sega Game Gear"); + expect(h.regionDescription).toBe("Export (GG)"); + }); + + it("detects a mirrored header at $1FF0 for an 8 KB size code", () => { + const rom = buildRom({ + size: 0x2000, + offset: 0x1ff0, + romSizeCode: 0xa, // 8 KB -> expects header at $1FF0 + regionCode: 0x4, + }); + const h = parseSmsHeader(rom)!; + expect(h.headerOffset).toBe(0x1ff0); + expect(h.romSizeBytes).toBe(8 * 1024); + expect(h.checksumValid).toBe(true); + expect(h.sizeHeaderMismatch).toBe(false); + }); + + it("flags a header offset that disagrees with the size code", () => { + // 8 KB size code but the header sits at $7FF0 (not the expected $1FF0). + const rom = buildRom({ + size: 0x8000, + offset: 0x7ff0, + romSizeCode: 0xa, + regionCode: 0x4, + }); + const h = parseSmsHeader(rom)!; + expect(h.sizeHeaderMismatch).toBe(true); + }); + + it("reports checksumValid === false when a body byte changes after the fact", () => { + const rom = buildRom({ size: 0x8000, romSizeCode: 0xc, regionCode: 0x4 }); + rom[0x10] ^= 0xff; // perturb a checksummed byte, leave the header intact + const h = parseSmsHeader(rom)!; + expect(h.checksumValid).toBe(false); + }); + + it("returns null when the TMR SEGA signature is absent (noise)", () => { + const noise = new Uint8Array(0x8000); + for (let i = 0; i < noise.length; i++) noise[i] = (i * 91) & 0xff; + expect(parseSmsHeader(noise)).toBeNull(); + }); +}); + +describe("SMSSystemHandler.summarizeDump (DB-free internal-checksum check)", () => { + const handler = new SMSSystemHandler("sms"); + + it("summarizes a valid header with integrity.ok === true", () => { + const rom = buildRom({ + size: 0x8000, + romSizeCode: 0xc, + regionCode: 0x4, + version: 1, + productHighDigit: 1, + productMid: 0x23, + productLow: 0x45, + }); + const summary = handler.summarizeDump(rom)!; + expect(summary).not.toBeNull(); + expect(summary.integrity).toEqual({ + ok: true, + message: "Internal checksum consistent", + }); + expect(summary.title).toBe("Sega Master System header"); + expect(summary.rows).toContainEqual(["Console", "Sega Master System"]); + expect(summary.rows).toContainEqual(["Region", "Export (SMS)"]); + expect(summary.rows).toContainEqual(["Product code", "12345"]); + expect(summary.rows).toContainEqual(["Version", "1"]); + expect(summary.rows).toContainEqual(["ROM size", "32 KB"]); + }); + + it("omits the Version row when the version nibble is zero", () => { + const rom = buildRom({ size: 0x8000, romSizeCode: 0xc, regionCode: 0x4 }); + const summary = handler.summarizeDump(rom)!; + expect(summary.rows.some(([field]) => field === "Version")).toBe(false); + }); + + it("flags integrity.ok === false with a mismatch message on a corrupt checksum", () => { + const rom = buildRom({ size: 0x8000, romSizeCode: 0xc, regionCode: 0x4 }); + rom[0x10] ^= 0xff; // break the checksum without removing the header + const summary = handler.summarizeDump(rom)!; + expect(summary.integrity?.ok).toBe(false); + expect(summary.integrity?.message).toBe( + "Master System internal checksum mismatch", + ); + }); + + it("uses the Game Gear display name in the mismatch message for gg", () => { + const ggHandler = new SMSSystemHandler("gg"); + const rom = buildRom({ size: 0x8000, romSizeCode: 0xc, regionCode: 0x6 }); + rom[0x10] ^= 0xff; + const summary = ggHandler.summarizeDump(rom)!; + expect(summary.integrity?.message).toBe( + "Game Gear internal checksum mismatch", + ); + }); + + it("returns null when no header is detected (noise)", () => { + const noise = new Uint8Array(0x8000); + for (let i = 0; i < noise.length; i++) noise[i] = (i * 91) & 0xff; + expect(handler.summarizeDump(noise)).toBeNull(); + }); +}); diff --git a/src/lib/systems/sms/sms-header.ts b/src/lib/systems/sms/sms-header.ts new file mode 100644 index 0000000..2382186 --- /dev/null +++ b/src/lib/systems/sms/sms-header.ts @@ -0,0 +1,212 @@ +/** + * Sega Master System / Game Gear internal "TMR SEGA" header parsing — pure, no + * DOM. Drives the post-dump SUMMARY screen for both the `sms` and `gg` system + * IDs (one 8-bit cartridge bus, one header format). + * + * The only header is a 16-byte block embedded IN the ROM, mirrored at one of + * three fixed boundaries depending on the dump size / mapper convention. It + * carries a BCD product code, a version + region nibble, a ROM-size nibble, and + * a stored 16-bit checksum. The checksum range varies with the size nibble and + * excludes the header bytes; only the export SMS BIOS actually enforces it, so + * GG / Japanese carts routinely ship a wrong value — surface it, don't gate on + * it. + * + * Format reference (offsets, region / size nibbles, checksum range): + * https://www.smspower.org/Development/ROMHeader + */ + +/** "TMR SEGA" signature bytes. */ +const TMR_SEGA_SIGNATURE = [0x54, 0x4d, 0x52, 0x20, 0x53, 0x45, 0x47, 0x41]; + +/** Candidate header offsets, largest first (essentially all carts use $7FF0). */ +const HEADER_OFFSETS = [0x7ff0, 0x3ff0, 0x1ff0]; + +const HEADER_BYTES = 16; +const STORED_CHECKSUM_OFFSET = 0x0a; // u16 LE +const PRODUCT_LOW_OFFSET = 0x0c; +const PRODUCT_MID_OFFSET = 0x0d; +const PRODUCT_HIGH_SIZE_OFFSET = 0x0e; // low nibble = product high digit, high nibble = ROM size code +const REGION_VERSION_OFFSET = 0x0f; // high nibble = region, low nibble = version +const LOW_NIBBLE_MASK = 0x0f; + +/** Region nibble values from the Sega header docs. */ +const REGION_SMS_JAPAN = 0x3; +const REGION_SMS_EXPORT = 0x4; +const REGION_GG_JAPAN = 0x5; +const REGION_GG_EXPORT = 0x6; +const REGION_GG_INTERNATIONAL = 0x7; + +/** ROM-size codes used in the high nibble of byte $7FFE. */ +const SIZE_CODE_256KB = 0x0; +const SIZE_CODE_512KB = 0x1; +const SIZE_CODE_1MB = 0x2; +const SIZE_CODE_8KB = 0xa; +const SIZE_CODE_16KB = 0xb; +const SIZE_CODE_32KB = 0xc; +const SIZE_CODE_48KB = 0xd; +const SIZE_CODE_64KB = 0xe; +const SIZE_CODE_128KB = 0xf; + +/** Byte length declared by each ROM-size code (null = reserved/unknown). */ +const SIZE_CODE_BYTES: Record<number, number> = { + [SIZE_CODE_8KB]: 8 * 1024, + [SIZE_CODE_16KB]: 16 * 1024, + [SIZE_CODE_32KB]: 32 * 1024, + [SIZE_CODE_48KB]: 48 * 1024, + [SIZE_CODE_64KB]: 64 * 1024, + [SIZE_CODE_128KB]: 128 * 1024, + [SIZE_CODE_256KB]: 256 * 1024, + [SIZE_CODE_512KB]: 512 * 1024, + [SIZE_CODE_1MB]: 1024 * 1024, +}; + +/** Region nibble -> human-readable description (console qualifier in parens). */ +const REGION_DESCRIPTIONS: Record<number, string> = { + [REGION_SMS_JAPAN]: "Japan (SMS)", + [REGION_SMS_EXPORT]: "Export (SMS)", + [REGION_GG_JAPAN]: "Japan (GG)", + [REGION_GG_EXPORT]: "Export (GG)", + [REGION_GG_INTERNATIONAL]: "International (GG)", +}; + +export type SmsConsole = "Sega Master System" | "Sega Game Gear"; + +export interface SmsHeaderInfo { + /** Console type, derived from the region nibble (GG regions -> Game Gear). */ + console: SmsConsole; + /** Product code decoded from packed BCD across bytes $0C..$0E. */ + productCode: number; + /** Version: low nibble of byte $7FFF. */ + version: number; + /** Region nibble: high nibble of byte $7FFF. */ + regionCode: number; + /** Human-readable region (e.g. "Export (SMS)"). */ + regionDescription: string; + /** ROM-size code: high nibble of byte $7FFE. */ + romSizeCode: number; + /** Declared ROM size in bytes, or null when the size code is reserved. */ + romSizeBytes: number | null; + /** File offset where the "TMR SEGA" signature was found. */ + headerOffset: number; + /** Total dumped file size in bytes. */ + fileSize: number; + /** Stored 16-bit LE checksum at headerOffset+$0A. */ + storedChecksum: number; + /** Checksum recomputed over the size-code's range (header gap excluded). */ + calculatedChecksum: number; + /** Stored checksum matches the recomputed one. */ + checksumValid: boolean; + /** The found header offset differs from the one the size code expects. */ + sizeHeaderMismatch: boolean; +} + +function signatureMatches(data: Uint8Array, offset: number): boolean { + if (offset + HEADER_BYTES > data.length) return false; + return TMR_SEGA_SIGNATURE.every((b, i) => data[offset + i] === b); +} + +/** + * BCD-decode the packed product code: bytes $0C/$0D each hold two decimal digits + * (tens in the high nibble) and the low nibble of byte $0E holds the most + * significant digit. + */ +function decodeProductCode(header: Uint8Array): number { + const bcd = (b: number) => (b >> 4) * 10 + (b & LOW_NIBBLE_MASK); + const low = bcd(header[PRODUCT_LOW_OFFSET]); + const mid = bcd(header[PRODUCT_MID_OFFSET]); + const high = header[PRODUCT_HIGH_SIZE_OFFSET] & LOW_NIBBLE_MASK; + return high * 10000 + mid * 100 + low; +} + +/** + * Header offset the size code expects: + * - 8 KB (0xA) -> $1FF0 + * - 16 KB (0xB) -> $3FF0 + * - 32 KB and larger -> $7FF0 + */ +function expectedHeaderOffset(romSizeCode: number): number { + if (romSizeCode === SIZE_CODE_8KB) return 0x1ff0; + if (romSizeCode === SIZE_CODE_16KB) return 0x3ff0; + return 0x7ff0; +} + +function sumRange(data: Uint8Array, start: number, end: number): number { + let sum = 0; + const stop = Math.min(end, data.length); + for (let i = Math.min(start, data.length); i < stop; i++) { + sum = (sum + data[i]) & 0xffff; + } + return sum; +} + +/** + * Recompute the Sega 16-bit byte-sum checksum. The range is always + * [0, headerOffset) — which excludes the 16 header bytes — plus an extra upper + * region [$8000, end) whose end is named by the ROM-size code (64 KB and up). + * Sub-64 KB codes are fully covered by the first range. + */ +export function calculateChecksum( + data: Uint8Array, + headerOffset: number, + romSizeCode: number, +): number { + let checksum = sumRange(data, 0, headerOffset); + const upperEnd: Record<number, number> = { + [SIZE_CODE_64KB]: 0x10000, + [SIZE_CODE_128KB]: 0x20000, + [SIZE_CODE_256KB]: 0x40000, + [SIZE_CODE_512KB]: 0x80000, + [SIZE_CODE_1MB]: 0x100000, + }; + const end = upperEnd[romSizeCode]; + if (end != null) { + checksum = (checksum + sumRange(data, 0x8000, end)) & 0xffff; + } + return checksum; +} + +/** + * Locate and parse the "TMR SEGA" header. Tries $7FF0, then the small-ROM + * mirrors $3FF0 / $1FF0, accepting on signature match alone — the checksum is + * NOT a precondition (GG / JP carts routinely ship a wrong one). Returns null + * when no candidate carries the signature. + */ +export function parseSmsHeader(data: Uint8Array): SmsHeaderInfo | null { + const headerOffset = HEADER_OFFSETS.find((o) => signatureMatches(data, o)); + if (headerOffset == null) return null; + + // Indexed from the block start so the named field offsets read the same as + // the on-disk layout. + const header = data.subarray(headerOffset, headerOffset + HEADER_BYTES); + + const storedChecksum = + header[STORED_CHECKSUM_OFFSET] | (header[STORED_CHECKSUM_OFFSET + 1] << 8); + const productCode = decodeProductCode(header); + const romSizeCode = header[PRODUCT_HIGH_SIZE_OFFSET] >> 4; + const regionByte = header[REGION_VERSION_OFFSET]; + const regionCode = regionByte >> 4; + const version = regionByte & LOW_NIBBLE_MASK; + + const isGameGear = + regionCode === REGION_GG_JAPAN || + regionCode === REGION_GG_EXPORT || + regionCode === REGION_GG_INTERNATIONAL; + + const calculatedChecksum = calculateChecksum(data, headerOffset, romSizeCode); + + return { + console: isGameGear ? "Sega Game Gear" : "Sega Master System", + productCode, + version, + regionCode, + regionDescription: REGION_DESCRIPTIONS[regionCode] ?? "Unknown", + romSizeCode, + romSizeBytes: SIZE_CODE_BYTES[romSizeCode] ?? null, + headerOffset, + fileSize: data.length, + storedChecksum, + calculatedChecksum, + checksumValid: calculatedChecksum === storedChecksum, + sizeHeaderMismatch: expectedHeaderOffset(romSizeCode) !== headerOffset, + }; +} diff --git a/src/lib/systems/sms/sms-rom.test.ts b/src/lib/systems/sms/sms-rom.test.ts new file mode 100644 index 0000000..bd68e03 --- /dev/null +++ b/src/lib/systems/sms/sms-rom.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect } from "vitest"; +import { + detectHeader, + detectAltHeader, + smsChecksum, + systemFromRegion, + trimSms, +} from "./sms-rom"; + +const KB = 1024; + +/** Deterministic, well-varied byte at index i (head differs from later blocks). */ +const fillByte = (i: number) => (i ^ (i >>> 7) ^ (i >>> 13) ^ 0x5a) & 0xff; + +const TMR = [0x54, 0x4d, 0x52, 0x20, 0x53, 0x45, 0x47, 0x41]; + +/** + * Build a synthetic SMS ROM with a self-consistent "TMR SEGA" header at $7FF0. + * `sizeCode` names the checksummed range; `region` sets the high nibble of + * $7FFF. The stored checksum is set to the computed value (the standard scheme + * stores it directly — no complement trick). + */ +function buildRom( + size: number, + sizeCode: number, + region: number, +): { data: Uint8Array; checksum: number } { + const data = new Uint8Array(size); + for (let i = 0; i < size; i++) data[i] = fillByte(i); + const b = 0x7ff0; + for (let i = 0; i < TMR.length; i++) data[b + i] = TMR[i]; + data[b + 8] = 0x00; // reserved + data[b + 9] = 0x00; + data[b + 0x0e] = 0x01; // version 1 + data[b + 0x0f] = ((region & 0x0f) << 4) | (sizeCode & 0x0f); + // placeholder checksum so smsChecksum sums real data, not the stored word + data[b + 0x0a] = 0x00; + data[b + 0x0b] = 0x00; + const c = smsChecksum(data, sizeCode); + data[b + 0x0a] = c & 0xff; + data[b + 0x0b] = (c >> 8) & 0xff; + return { data, checksum: c }; +} + +/** + * Over-dump where the tail [real:total] mirrors the equal-length block ending + * at `real` — the TAIL-BLOCK-MIRROR shape (not whole-buffer periodicity). + */ +function tailMirrorOverdump(real: Uint8Array, total: number): Uint8Array { + const out = new Uint8Array(total); + out.set(real, 0); + const t = total - real.length; + out.set(real.subarray(real.length - t, real.length), real.length); + return out; +} + +function fillOverdump(real: Uint8Array, total: number, v: number): Uint8Array { + const out = new Uint8Array(total); + out.set(real, 0); + out.fill(v, real.length); + return out; +} + +/** Fast byte equality (vitest's toEqual is far too slow on multi-MB arrays). */ +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +describe("smsChecksum", () => { + it("is self-consistent for a built 32 KB ROM (range cap)", () => { + const { data, checksum } = buildRom(32 * KB, 0xc, 0x4); + expect(smsChecksum(data, 0xc)).toBe(checksum); + }); + + it("sums low+high halves for a 64 KB ROM, skipping the header gap", () => { + const { data, checksum } = buildRom(64 * KB, 0xe, 0x4); + expect(smsChecksum(data, 0xe)).toBe(checksum); + }); +}); + +describe("detectHeader", () => { + it("finds the TMR SEGA header and decodes region/size", () => { + const { data, checksum } = buildRom(32 * KB, 0xc, 0x4); + const h = detectHeader(data); + expect(h).not.toBeNull(); + expect(h!.offset).toBe(0x7ff0); + expect(h!.region).toBe(0x4); + expect(h!.sizeCode).toBe(0xc); + expect(h!.checksum).toBe(checksum); + }); + + it("returns null when the magic is absent", () => { + const noise = new Uint8Array(32 * KB); + for (let i = 0; i < noise.length; i++) noise[i] = (i * 37) & 0xff; + expect(detectHeader(noise)).toBeNull(); + }); +}); + +describe("systemFromRegion", () => { + it("maps SMS region nibbles to sms", () => { + expect(systemFromRegion(0x3)).toBe("sms"); + expect(systemFromRegion(0x4)).toBe("sms"); + }); + + it("maps GG region nibbles to gg", () => { + expect(systemFromRegion(0x5)).toBe("gg"); + expect(systemFromRegion(0x6)).toBe("gg"); + expect(systemFromRegion(0x7)).toBe("gg"); + }); + + it("returns null for unknown nibbles", () => { + expect(systemFromRegion(0x0)).toBeNull(); + expect(systemFromRegion(0xf)).toBeNull(); + }); +}); + +describe("detectAltHeader", () => { + it("detects a header whose checksum and inverse cancel", () => { + const data = new Uint8Array(64 * KB); + const b = 0x7fe0; + data[b + 0] = 16; // NumPages + const checksum = 0x1234; + const inverse = (0x10000 - checksum) & 0xffff; + data[b + 6] = checksum & 0xff; + data[b + 7] = (checksum >> 8) & 0xff; + data[b + 8] = inverse & 0xff; + data[b + 9] = (inverse >> 8) & 0xff; + const h = detectAltHeader(data); + expect(h).not.toBeNull(); + expect(h!.numPages).toBe(16); + expect(h!.checksum).toBe(checksum); + }); + + it("rejects when checksum + inverse do not cancel", () => { + const data = new Uint8Array(64 * KB); + const b = 0x7fe0; + data[b + 0] = 16; + data[b + 6] = 0x34; + data[b + 7] = 0x12; + data[b + 8] = 0x00; // wrong inverse + data[b + 9] = 0x00; + expect(detectAltHeader(data)).toBeNull(); + }); +}); + +describe("trimSms", () => { + it("trims a 32 KB cart tail-mirrored to 64 KB down to exactly 32 KB", () => { + const { data } = buildRom(32 * KB, 0xc, 0x4); + const over = tailMirrorOverdump(data, 64 * KB); + const { data: trimmed } = trimSms(over); + expect(trimmed.length).toBe(32 * KB); + expect(bytesEqual(trimmed, data)).toBe(true); + }); + + it("trims a 48 KB cart tail-mirrored to 64 KB down to 48 KB (non-power-of-two)", () => { + const { data } = buildRom(48 * KB, 0xd, 0x4); + // tail [48K:64K] mirrors [32K:48K] — the block immediately before L. + const over = tailMirrorOverdump(data, 64 * KB); + expect(trimSms(over).data.length).toBe(48 * KB); + }); + + it("trims a 32 KB cart 0xFF-padded to 64 KB down to 32 KB", () => { + const { data } = buildRom(32 * KB, 0xc, 0x4); + const over = fillOverdump(data, 64 * KB, 0xff); + expect(trimSms(over).data.length).toBe(32 * KB); + }); + + it("trims a 32 KB cart 0x00-padded to 64 KB down to 32 KB", () => { + const { data } = buildRom(32 * KB, 0xc, 0x4); + const over = fillOverdump(data, 64 * KB, 0x00); + expect(trimSms(over).data.length).toBe(32 * KB); + }); + + it("leaves an already-correct ROM untouched", () => { + const { data } = buildRom(32 * KB, 0xc, 0x4); + expect(trimSms(data).data.length).toBe(32 * KB); + }); + + it("does not trim a dump with no mirror or fill tail", () => { + const over = new Uint8Array(64 * KB); + for (let i = 0; i < over.length; i++) over[i] = (i * 91) & 0xff; + expect(trimSms(over).data.length).toBe(64 * KB); + }); +}); diff --git a/src/lib/systems/sms/sms-rom.ts b/src/lib/systems/sms/sms-rom.ts new file mode 100644 index 0000000..489d039 --- /dev/null +++ b/src/lib/systems/sms/sms-rom.ts @@ -0,0 +1,239 @@ +/** + * Sega Master System / Game Gear internal-header parsing, over-dump trimming, + * and the (advisory) Sega checksum. Shared by the SMS/GG SystemHandler + * (validation/summary) and the Retrode 2 over-dump trimmer. Pure functions, no + * DOM dependencies. + * + * The only "header" is an INTERNAL 16-byte "TMR SEGA" block embedded in the ROM + * at $7FF0 (with mirrors at $3FF0/$1FF0 for tiny ROMs); there is no external + * wrapper, so the No-Intro SHA-1/CRC32 is computed over the WHOLE trimmed ROM + * (like SNES/GBA). The internal Sega checksum is enforced only by the export + * SMS BIOS — GG and Japanese SMS carts routinely ship a wrong/absent value — so + * it is ADVISORY only and never gates a trim. The real size arbiter is the + * mirror/fill geometry snapped to the known SMS size ladder, confirmed + * downstream by the No-Intro DAT. + */ + +export interface SmsHeader { + /** File offset of the "TMR SEGA" magic. */ + offset: number; + /** Region nibble: high nibble of byte offset+0x0f. */ + region: number; + /** Size code: low nibble of byte offset+0x0f. */ + sizeCode: number; + /** Version: low nibble of byte offset+0x0e. */ + version: number; + /** Stored 16-bit LE checksum at offset+0x0a. */ + checksum: number; +} + +export interface SmsAltHeader { + /** Page count (0x2000-byte pages). */ + numPages: number; + /** Stored 16-bit LE checksum at $7FE6. */ + checksum: number; + /** Stored 16-bit LE inverse checksum at $7FE8. */ + inverseChecksum: number; +} + +/** "TMR SEGA" magic. */ +const TMR_SEGA = [0x54, 0x4d, 0x52, 0x20, 0x53, 0x45, 0x47, 0x41]; + +/** Candidate header offsets, largest first (essentially all carts use $7FF0). */ +const HEADER_OFFSETS = [0x7ff0, 0x3ff0, 0x1ff0]; + +/** Offset of the magic-less alternate header at $7FE0. */ +const ALT_HEADER_OFFSET = 0x7fe0; + +/** + * Real SMS/GG cart sizes in bytes (ascending), including the genuine + * non-power-of-two early-SMS carts (24 KB, 48 KB). + */ +export const SMS_SIZE_LADDER = [ + 0x2000, // 8 KB + 0x4000, // 16 KB + 0x6000, // 24 KB + 0x8000, // 32 KB + 0xc000, // 48 KB + 0x10000, // 64 KB + 0x20000, // 128 KB + 0x40000, // 256 KB + 0x80000, // 512 KB + 0x100000, // 1 MB +]; + +/** Checksummed range named by the size-code low nibble of byte $7FFF. */ +const SIZE_CODE_RANGE: Record<number, number> = { + 0xa: 0x2000, // 8 KB + 0xb: 0x4000, // 16 KB + 0xc: 0x8000, // 32 KB + 0xd: 0xc000, // 48 KB + 0xe: 0x10000, // 64 KB + 0xf: 0x20000, // 128 KB + 0x0: 0x40000, // 256 KB + 0x1: 0x80000, // 512 KB + 0x2: 0x100000, // 1 MB +}; + +function magicMatches(data: Uint8Array, offset: number): boolean { + if (offset + 16 > data.length) return false; + for (let i = 0; i < TMR_SEGA.length; i++) { + if (data[offset + i] !== TMR_SEGA[i]) return false; + } + return true; +} + +/** + * Locate and parse the "TMR SEGA" header. Tries $7FF0, then $3FF0, then $1FF0 + * (the small-ROM mirrors), accepting on magic match alone — the checksum is NOT + * required, since GG/JP carts routinely ship a wrong one. Returns null when no + * candidate carries the magic (early/JP SMS and all carts using the magic-less + * alternate header at $7FE0). + */ +export function detectHeader(data: Uint8Array): SmsHeader | null { + for (const offset of HEADER_OFFSETS) { + if (!magicMatches(data, offset)) continue; + const flags = data[offset + 0x0f]; + return { + offset, + region: (flags >> 4) & 0x0f, + sizeCode: flags & 0x0f, + version: data[offset + 0x0e] & 0x0f, + checksum: data[offset + 0x0a] | (data[offset + 0x0b] << 8), + }; + } + return null; +} + +/** + * Detect the magic-less alternate mapper header at $7FE0. The defining invariant + * is (checksum + inverseChecksum) & 0xFFFF === 0; corroborated by a plausible + * page count (8..32) to avoid false positives on a TMR-SEGA-less raw region. + */ +export function detectAltHeader(data: Uint8Array): SmsAltHeader | null { + const base = ALT_HEADER_OFFSET; + if (base + 16 > data.length) return null; + const numPages = data[base + 0]; + const checksum = data[base + 6] | (data[base + 7] << 8); + const inverseChecksum = data[base + 8] | (data[base + 9] << 8); + if (((checksum + inverseChecksum) & 0xffff) !== 0) return null; + if (numPages < 8 || numPages > 32) return null; + return { numPages, checksum, inverseChecksum }; +} + +function sumBytes(data: Uint8Array, start: number, end: number): number { + let s = 0; + for (let i = start; i < end && i < data.length; i++) s += data[i]; + return s & 0xffff; +} + +/** + * Standard Sega 16-bit byte-sum checksum over the range named by the header's + * size code, with the 16 header bytes $7FF0..$7FFF excluded (the low range ends + * at $7FEF). For ranges >= 64 KB the low half [$0000-$7FEF] is summed and the + * high half [$8000..size-1] is added — the header gap is what makes a naive + * whole-file sum differ. ADVISORY ONLY: many GG/JP carts store a wrong value. + */ +export function smsChecksum(data: Uint8Array, sizeCode: number): number { + const range = SIZE_CODE_RANGE[sizeCode]; + if (range == null) return 0; + if (range <= 0x8000) { + // Low range ends at $7FEF (or earlier for sub-32 KB ranges). + const end = Math.min(range, 0x7ff0); + return sumBytes(data, 0, end); + } + // >= 64 KB: low [$0000-$7FEF] + high [$8000..range-1] (header gap skipped). + return (sumBytes(data, 0, 0x7ff0) + sumBytes(data, 0x8000, range)) & 0xffff; +} + +/** + * The tail [L:end] is a byte-exact mirror of the equal-length block before L. + * Exact halving (t === L) IS accepted here — unlike N64. On the 8-bit Sega bus + * a small ROM mirrored to fill a larger addressable space is the norm (the + * mapper mirrors), and No-Intro catalogs the base size, so collapsing two + * byte-identical 32 KB halves to a 32 KB cart is the correct, expected result. + * (N64 refuses halving because, on its large ROMs, a genuine 2× duplication is + * plausible; that risk doesn't apply to a header-gated SMS cart.) + */ +function isTailMirror(data: Uint8Array, L: number): boolean { + const t = data.length - L; + if (t <= 0 || t > L) return false; + for (let i = 0; i < t; i++) { + if (data[L + i] !== data[L - t + i]) return false; + } + return true; +} + +/** The tail [L:end] is entirely 0x00 or entirely 0xFF. */ +function isTailFill(data: Uint8Array, L: number): boolean { + const end = data.length; + if (end <= L) return false; + const v = data[L]; + if (v !== 0x00 && v !== 0xff) return false; + for (let i = L + 1; i < end; i++) if (data[i] !== v) return false; + return true; +} + +/** One 8 KB page — the smallest fill run we treat as padding. */ +const MIN_FILL_RUN = 0x2000; + +/** + * Trim a Retrode over-dump to the real ROM size. The Retrode rounds the cart up + * to a power of two and never trims SMS/GG on-device, so the tail is either a + * byte-exact MIRROR of earlier address space or constant 0x00/0xFF fill. + * + * Per the verdict: the decision is GEOMETRY-driven, snapped to the known SMS + * size ladder — NOT gated on the internal Sega checksum (advisory, frequently + * wrong on GG/JP) and NOT a whole-buffer periodicity test (which misses the + * tail-block-mirror shape of non-power-of-two carts). Returns the smallest + * ladder length whose tail is a provable mirror or fill of earlier data; the + * No-Intro DAT is the downstream arbiter. If nothing qualifies, the input is + * returned unchanged. + */ +export function trimSms(input: Uint8Array): { data: Uint8Array; note?: string } { + // Only trim something that actually looks like an SMS/GG ROM. Geometry alone + // is not enough (arbitrary/periodic data can self-mirror); require a real + // header — TMR SEGA or the magic-less alternate at $7FE0 — exactly as the + // verdict demands. A real Retrode dump always carries one; headerless data is + // returned unchanged. + if (!detectHeader(input) && !detectAltHeader(input)) { + return { data: input }; + } + const D = input.length; + for (const L of SMS_SIZE_LADDER) { + if (L >= D) break; // ladder is ascending + const mirror = isTailMirror(input, L); + const fill = !mirror && isTailFill(input, L) && D - L >= MIN_FILL_RUN; + if (!mirror && !fill) continue; + const kb = L / 1024; + const how = mirror ? "tail mirror" : `0x${input[L].toString(16).toUpperCase().padStart(2, "0")} fill`; + return { data: input.subarray(0, L), note: `over-dump trimmed to ${kb} KB (${how})` }; + } + return { data: input }; +} + +/** Region nibble -> Master System vs Game Gear (drives extension/DAT). */ +export type SmsSystem = "sms" | "gg"; + +/** + * Classify a region nibble as Master System or Game Gear, or null when the + * nibble is unknown/absent (the driver then falls back to the file extension). + * $3=SMS Japan, $4=SMS Export; $5=GG Japan, $6=GG Export, $7=GG International. + */ +export function systemFromRegion(region: number): SmsSystem | null { + if (region === 0x3 || region === 0x4) return "sms"; + if (region === 0x5 || region === 0x6 || region === 0x7) return "gg"; + return null; +} + +const REGION_NAMES: Record<number, string> = { + 0x3: "SMS Japan", + 0x4: "SMS Export", + 0x5: "GG Japan", + 0x6: "GG Export", + 0x7: "GG International", +}; + +export function regionName(code: number): string { + return REGION_NAMES[code] ?? `Region 0x${code.toString(16).toUpperCase()}`; +} diff --git a/src/lib/systems/sms/sms-system-handler.ts b/src/lib/systems/sms/sms-system-handler.ts new file mode 100644 index 0000000..12f2d5f --- /dev/null +++ b/src/lib/systems/sms/sms-system-handler.ts @@ -0,0 +1,185 @@ +import type { + SystemHandler, + ConfigValues, + CartridgeInfo, + DumpSummary, + ResolvedConfigField, + ValidationResult, + ReadConfig, + OutputFile, + VerificationHashes, + VerificationDB, + VerificationResult, +} from "@/lib/types"; +import { crc32, sha1Hex, sha256Hex } from "@/lib/core/hashing"; +import { + detectHeader, + detectAltHeader, + regionName, + smsChecksum, + trimSms, +} from "./sms-rom"; +import { parseSmsHeader } from "./sms-header"; + +/** + * Sega Master System / Game Gear. Same 8-bit cartridge bus — the Retrode reads + * both identically — so one handler drives both, parameterized by systemId + * ("sms" -> Master System / .sms; "gg" -> Game Gear / .gg). ROMs are raw + * binaries whose only "header" is the INTERNAL "TMR SEGA" block embedded in the + * ROM, so — like SNES/GBA, unlike NES — the No-Intro hash is over the whole + * (trimmed) ROM with no external-header splice. Over-dump trimming is offered + * post-dump via suggestTrim (see sms-rom.ts trimSms); this handler validates + * and packages the bytes. The internal Sega checksum + * is advisory only (GG/JP carts routinely ship a wrong one) and never a match + * key — verification is by SHA-1 over the trimmed content. + */ +export class SMSSystemHandler implements SystemHandler { + readonly systemId: "sms" | "gg"; + readonly displayName: string; + readonly fileExtension: string; + + constructor(systemId: "sms" | "gg") { + this.systemId = systemId; + this.displayName = systemId === "gg" ? "Game Gear" : "Master System"; + this.fileExtension = systemId === "gg" ? ".gg" : ".sms"; + } + + getConfigFields( + _currentValues: ConfigValues, + autoDetected?: CartridgeInfo, + ): ResolvedConfigField[] { + const fields: ResolvedConfigField[] = []; + if (autoDetected?.title) { + fields.push({ + key: "title", + label: "Game Title", + type: "readonly", + value: autoDetected.title, + autoDetected: true, + group: "cartridge", + order: 0, + }); + } + const region = autoDetected?.meta?.region; + if (typeof region === "string") { + fields.push({ + key: "region", + label: "Region", + type: "readonly", + value: region, + autoDetected: true, + group: "cartridge", + order: 1, + }); + } + return fields; + } + + validate(_values: ConfigValues): ValidationResult { + return { valid: true }; + } + + suggestTrim(content: Uint8Array): { size: number; note: string } | null { + const { data, note } = trimSms(content); + return data.length < content.length + ? { size: data.length, note: note ?? "heuristic over-dump trim" } + : null; + } + + buildReadConfig(_values: ConfigValues): ReadConfig { + return { systemId: this.systemId, params: {} }; + } + + buildOutputFile(rawData: Uint8Array, _config: ReadConfig): OutputFile { + const meta: Record<string, string> = { Format: this.displayName }; + const header = detectHeader(rawData); + if (header) { + meta.Region = regionName(header.region); + const computed = smsChecksum(rawData, header.sizeCode); + meta.Checksum = + `0x${header.checksum.toString(16).toUpperCase().padStart(4, "0")}` + + (computed === header.checksum ? " (ok)" : " (advisory mismatch)"); + } else if (detectAltHeader(rawData)) { + meta.Mapper = "Alternate ($7FE0)"; + } + return { + data: rawData, + filename: `dump${this.fileExtension}`, + mimeType: "application/octet-stream", + meta, + }; + } + + async computeHashes(rawData: Uint8Array): Promise<VerificationHashes> { + const [sha1, sha256] = await Promise.all([ + sha1Hex(rawData), + sha256Hex(rawData), + ]); + return { crc32: crc32(rawData), sha1, sha256, size: rawData.length }; + } + + verify( + hashes: VerificationHashes, + db: VerificationDB | null, + ): VerificationResult { + if (!db) return { matched: false, confidence: "none" }; + const entry = db.lookup(hashes); + if (entry) return { matched: true, entry, confidence: "exact" }; + return { + matched: false, + confidence: "none", + suggestions: [ + "No No-Intro match. If this is an over-dump, the tail may still " + + `mirror earlier data — try trimming, or import the ${this.displayName} DAT.`, + ], + }; + } + + /** + * Internal "TMR SEGA" header summary, available with NO No-Intro database. + * Surfaces the product code, region/console type, version, and declared ROM + * size from the header (located by auto-detection across $7FF0/$3FF0/$1FF0), + * and an integrity verdict from the stored 16-bit checksum recomputed over the + * size code's range. Null when no header signature is present (don't fabricate + * fields over noise). The checksum is advisory in practice — many GG / JP carts + * ship a wrong value — but a mismatch is still the only DB-free consistency + * signal we can offer, so it gates `integrity.ok`. + */ + summarizeDump(rawData: Uint8Array): DumpSummary | null { + const header = parseSmsHeader(rawData); + if (!header) return null; + + const rows: [string, string][] = [ + ["Console", header.console], + ["Product code", header.productCode.toString()], + ["Region", header.regionDescription], + ]; + if (header.version !== 0) { + rows.push(["Version", header.version.toString()]); + } + if (header.romSizeBytes != null) { + rows.push(["ROM size", `${header.romSizeBytes / 1024} KB`]); + } + rows.push([ + "Header location", + `0x${header.headerOffset.toString(16).toUpperCase().padStart(4, "0")}`, + ]); + rows.push([ + "Checksum", + `0x${header.storedChecksum.toString(16).toUpperCase().padStart(4, "0")}`, + ]); + + return { + title: `${header.console} header`, + columns: ["Field", "Value"], + monoColumns: [1], + rows, + integrity: header.checksumValid + ? { ok: true, message: "Internal checksum consistent" } + : { + ok: false, + message: `${this.displayName} internal checksum mismatch`, + }, + }; + } +} diff --git a/src/lib/systems/snes/snes-rom.test.ts b/src/lib/systems/snes/snes-rom.test.ts new file mode 100644 index 0000000..6c3d7c2 --- /dev/null +++ b/src/lib/systems/snes/snes-rom.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect } from "vitest"; +import { + cartTypeName, + decodeSizeCode, + detectHeader, + formatSize, + mapModeName, + romSpeed, + snesChecksum, + stripCopierHeader, + trimSnes, +} from "./snes-rom"; + +const MB = 1024 * 1024; + +/** Deterministic, well-varied byte at index i (1st MB differs from later MBs). */ +const fillByte = (i: number) => (i ^ (i >>> 7) ^ (i >>> 13) ^ 0x5a) & 0xff; + +/** + * Build a synthetic LoROM ROM with a self-consistent internal header at + * $7FC0. The checksum/complement use the standard cancellation trick: the + * four bytes always contribute a constant to the sum, so writing C and ~C + * makes snesChecksum(rom) === C regardless of C. + */ +function buildRom(size: number): { data: Uint8Array; checksum: number } { + const data = new Uint8Array(size); + for (let i = 0; i < size; i++) data[i] = fillByte(i); + const b = 0x7fc0; + for (let i = 0; i < 21; i++) data[b + i] = "TESTROM".charCodeAt(i) || 0x20; + data[b + 0x15] = 0x20; // map mode: LoROM, SlowROM + data[b + 0x16] = 0x00; // cart type: ROM only + data[b + 0x17] = 0x0b; // ROM size code (advisory) + data[b + 0x18] = 0x00; // no SRAM + data[b + 0x19] = 0x01; // region: USA + // reset vector -> $8000 so the header scores + data[b + 0x3c] = 0x00; + data[b + 0x3d] = 0x80; + // placeholder checksum 0x0000 / complement 0xFFFF + data[b + 0x1c] = 0xff; + data[b + 0x1d] = 0xff; + data[b + 0x1e] = 0x00; + data[b + 0x1f] = 0x00; + const c = snesChecksum(data); + data[b + 0x1e] = c & 0xff; + data[b + 0x1f] = (c >> 8) & 0xff; + const comp = c ^ 0xffff; + data[b + 0x1c] = comp & 0xff; + data[b + 0x1d] = (comp >> 8) & 0xff; + return { data, checksum: c }; +} + +/** Over-dump where the tail mirrors the block immediately before `realSize`. */ +function mirrorOverdump(real: Uint8Array, total: number): Uint8Array { + const out = new Uint8Array(total); + out.set(real, 0); + const t = total - real.length; + out.set(real.subarray(real.length - t, real.length), real.length); + return out; +} + +function fillOverdump(real: Uint8Array, total: number, v: number): Uint8Array { + const out = new Uint8Array(total); + out.set(real, 0); + out.fill(v, real.length); + return out; +} + +/** Fast byte equality (vitest's toEqual is far too slow on multi-MB arrays). */ +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +describe("snesChecksum", () => { + it("is self-consistent for a built ROM", () => { + const { data, checksum } = buildRom(2 * MB); + expect(snesChecksum(data)).toBe(checksum); + }); + + it("3MB split-sum equals the (naive) checksum of its 4MB mirror over-dump", () => { + const { data, checksum } = buildRom(3 * MB); + expect(snesChecksum(data)).toBe(checksum); // split-sum over 3MB + const over = mirrorOverdump(data, 4 * MB); + // 4MB is a power of two, so snesChecksum is a plain byte sum; the mirror + // makes it equal the 3MB split-sum — the real-cart identity that lets a + // 4MB over-dump pass the stored checksum yet still need trimming. + expect(snesChecksum(over)).toBe(checksum); + }); +}); + +describe("detectHeader", () => { + it("finds a valid LoROM header", () => { + const { data, checksum } = buildRom(2 * MB); + const h = detectHeader(data); + expect(h).not.toBeNull(); + expect(h!.map).toBe("lorom"); + expect(h!.checksumValid).toBe(true); + expect(h!.checksum).toBe(checksum); + }); + + it("returns null for noise", () => { + const noise = new Uint8Array(0x10000); + for (let i = 0; i < noise.length; i++) noise[i] = (i * 37) & 0xff; + expect(detectHeader(noise)).toBeNull(); + }); + + it("returns null for an all-zero buffer (no phantom LoROM header)", () => { + // All-zero used to score 3 (mapMode nibble 0 = LoROM + 0x00-counts-as- + // printable) and report a bogus LoROM header. It must score below threshold. + expect(detectHeader(new Uint8Array(0x10000))).toBeNull(); + }); +}); + +describe("trimSnes", () => { + it("trims a 3MB cart over-dumped to 4MB (mirror) down to exactly 3MB", () => { + const { data } = buildRom(3 * MB); + const over = mirrorOverdump(data, 4 * MB); + const { data: trimmed } = trimSnes(over); + expect(trimmed.length).toBe(3 * MB); + expect(bytesEqual(trimmed, data)).toBe(true); + }); + + it("trims a 2MB cart full-mirrored to 4MB down to 2MB", () => { + const { data } = buildRom(2 * MB); + const over = mirrorOverdump(data, 4 * MB); // tail == [0:2MB] + expect(trimSnes(over).data.length).toBe(2 * MB); + }); + + it("trims a 3MB cart 0xFF-padded to 4MB down to 3MB", () => { + const { data } = buildRom(3 * MB); + const over = fillOverdump(data, 4 * MB, 0xff); + expect(trimSnes(over).data.length).toBe(3 * MB); + }); + + it("leaves an already-correct ROM untouched", () => { + const { data } = buildRom(2 * MB); + expect(trimSnes(data).data.length).toBe(2 * MB); + }); + + it("does not trim when no header/checksum is trustworthy", () => { + const over = new Uint8Array(4 * MB); + for (let i = 0; i < over.length; i++) over[i] = (i * 91) & 0xff; + expect(trimSnes(over).data.length).toBe(4 * MB); + }); +}); + +describe("header field resolvers", () => { + it("decodes the low nibble of $FFD5 to a map-mode name", () => { + expect(mapModeName(0x20)).toBe("LoROM"); // FastROM bit set, nibble 0 + expect(mapModeName(0x01)).toBe("HiROM"); + expect(mapModeName(0x02)).toBe("SA-1 ROM"); + expect(mapModeName(0x03)).toBe("ExLoROM"); + expect(mapModeName(0x05)).toBe("ExHiROM"); + expect(mapModeName(0x0a)).toBe("Mode 0xA"); + }); + + it("reads the FastROM/SlowROM speed bit (bit 4 of $FFD5)", () => { + expect(romSpeed(0x20)).toBe("SlowROM"); // 0x20 has bit 4 clear + expect(romSpeed(0x30)).toBe("FastROM"); // bit 4 set + expect(romSpeed(0x31)).toBe("FastROM"); + }); + + it("names the cartridge-type byte ($FFD6) with its hex code", () => { + expect(cartTypeName(0x00)).toBe("ROM Only (0x00)"); + expect(cartTypeName(0x02)).toBe("ROM + RAM + Battery (0x02)"); + expect(cartTypeName(0x13)).toBe("ROM + Super FX (0x13)"); + expect(cartTypeName(0x1a)).toBe("ROM + SA-1 (0x1A)"); + expect(cartTypeName(0xff)).toBe("Unknown (0xFF)"); + }); + + it("decodes size codes as 0x400 << code and formats them", () => { + expect(decodeSizeCode(0)).toBe(0x400); // 1 KiB unit + expect(decodeSizeCode(0x0b)).toBe(2 * MB); + expect(formatSize(decodeSizeCode(0x0b))).toBe("2 MiB"); + expect(formatSize(decodeSizeCode(0x05))).toBe("32 KiB"); + expect(formatSize(0)).toBe("None"); + }); +}); + +describe("stripCopierHeader", () => { + it("removes a 512-byte copier header when present", () => { + const { data } = buildRom(2 * MB); + const withHdr = new Uint8Array(data.length + 512); + withHdr.set(data, 512); + expect(stripCopierHeader(withHdr).length).toBe(2 * MB); + }); + + it("leaves a clean power-of-two ROM alone", () => { + const { data } = buildRom(2 * MB); + expect(stripCopierHeader(data).length).toBe(2 * MB); + }); +}); diff --git a/src/lib/systems/snes/snes-rom.ts b/src/lib/systems/snes/snes-rom.ts new file mode 100644 index 0000000..0a9adb0 --- /dev/null +++ b/src/lib/systems/snes/snes-rom.ts @@ -0,0 +1,308 @@ +/** + * SNES/SFC internal-header parsing, over-dump trimming, and checksum. + * Shared by the SNES SystemHandler (validation/summary) and the Retrode 2 + * over-dump trimmer. Pure functions, no DOM dependencies. + * + * Algorithm verified against a real 24 Mbit (3 MB) cart over-dumped to 4 MB: + * the trailing 1 MB byte-mirrors the 3rd MB, and the SNES non-power-of-two + * checksum (sum(low 2MB) + 2·sum(high 1MB)) over the trimmed 3 MB equals the + * stored header checksum — so the trim is gated on that checksum, never on + * shape alone. + */ + +export type SnesMap = "lorom" | "hirom" | "exhirom"; + +export interface SnesHeader { + /** File offset of the header window (the CPU $FFC0 base). */ + offset: number; + map: SnesMap; + mapMode: number; // $FFD5 + cartType: number; // $FFD6 + romSizeCode: number; // $FFD7 + ramSizeCode: number; // $FFD8 + region: number; // $FFD9 + revision: number; // $FFDB (version / mask-ROM revision) + title: string; + checksum: number; // stored $FFDE..$FFDF + complement: number; // stored $FFDC..$FFDD + /** checksum + complement == 0xFFFF. */ + checksumValid: boolean; +} + +const HEADER_CANDIDATES: { offset: number; map: SnesMap; nibble: number[] }[] = + [ + { offset: 0x7fc0, map: "lorom", nibble: [0, 2] }, + { offset: 0xffc0, map: "hirom", nibble: [1] }, + { offset: 0x40ffc0, map: "exhirom", nibble: [5] }, + ]; + +/** Real SNES cart sizes in bytes (ascending), incl. non-power-of-two carts. */ +export const SNES_SIZE_LADDER = [ + 0x40000, // 256 KB + 0x80000, // 512 KB + 0xc0000, // 768 KB + 0x100000, // 1 MB + 0x180000, // 1.5 MB + 0x200000, // 2 MB + 0x280000, // 2.5 MB + 0x300000, // 3 MB + 0x400000, // 4 MB + 0x500000, // 5 MB + 0x600000, // 6 MB + 0x800000, // 8 MB +]; + +/** Strip a 512-byte SWC/SMC/FIG copier header if present. */ +export function stripCopierHeader(data: Uint8Array): Uint8Array { + return data.length % 0x8000 === 512 ? data.subarray(512) : data; +} + +function sumBytes(data: Uint8Array, start: number, end: number): number { + let s = 0; + for (let i = start; i < end; i++) s += data[i]; + return s; +} + +/** + * SNES 16-bit checksum with power-of-two mirror normalization. For a + * power-of-two ROM it's a plain byte sum; for a non-power-of-two ROM the + * high remainder is weighted to model address-space mirroring (e.g. 3 MB = + * sum(2 MB) + 2·sum(1 MB)). + */ +export function snesChecksum(data: Uint8Array): number { + const n = data.length; + if (n === 0) return 0; + let lp = 1; + while (lp * 2 <= n) lp *= 2; + if (lp === n) return sumBytes(data, 0, n) & 0xffff; + const lr = n - lp; + // Non-clean layouts (lr doesn't divide lp) fall back to a naive sum. + if (lp % lr !== 0) return sumBytes(data, 0, n) & 0xffff; + const factor = lp / lr; + return (sumBytes(data, 0, lp) + factor * sumBytes(data, lp, n)) & 0xffff; +} + +function decodeTitle(data: Uint8Array, base: number): string { + let s = ""; + for (let i = 0; i < 21; i++) { + const b = data[base + i]; + s += b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : " "; + } + return s.replace(/\s+$/, ""); +} + +function printableFraction(data: Uint8Array, base: number): number { + let printable = 0; + let realChars = 0; // genuine ASCII, not 0x00 padding + for (let i = 0; i < 21; i++) { + const b = data[base + i]; + if (b >= 0x20 && b < 0x7f) { + printable++; + realChars++; + } else if (b === 0x00) { + printable++; // a title may be 0x00-padded + } + } + // An all-padding window (e.g. a blank/0x00-filled region) is not a title: + // require at least one real character before the padding counts, so an + // all-zero buffer can't score the printable bonus and masquerade as a header. + return realChars > 0 ? printable / 21 : 0; +} + +/** Locate and score the internal header; returns the best candidate or null. */ +export function detectHeader(data: Uint8Array): SnesHeader | null { + let best: SnesHeader | null = null; + let bestScore = -1; + for (const cand of HEADER_CANDIDATES) { + const base = cand.offset; + if (base + 0x40 > data.length) continue; + const checksum = data[base + 0x1e] | (data[base + 0x1f] << 8); + const complement = data[base + 0x1c] | (data[base + 0x1d] << 8); + const mapMode = data[base + 0x15]; + const checksumValid = ((checksum + complement) & 0xffff) === 0xffff; + const reset = data[base + 0x3c] | (data[base + 0x3d] << 8); + + let score = 0; + if (checksumValid) score += 4; + if (cand.nibble.includes(mapMode & 0x0f)) score += 2; + if (printableFraction(data, base) >= 0.75) score += 1; + if (reset >= 0x8000) score += 1; + + if (score > bestScore) { + bestScore = score; + best = { + offset: base, + map: cand.map, + mapMode, + cartType: data[base + 0x16], + romSizeCode: data[base + 0x17], + ramSizeCode: data[base + 0x18], + region: data[base + 0x19], + revision: data[base + 0x1b], + title: decodeTitle(data, base), + checksum, + complement, + checksumValid, + }; + } + } + // Require a minimal score so we don't "detect" a header in noise. + return best && bestScore >= 3 ? best : null; +} + +function regionsEqual( + data: Uint8Array, + aStart: number, + bStart: number, + len: number, +): boolean { + for (let i = 0; i < len; i++) { + if (data[aStart + i] !== data[bStart + i]) return false; + } + return true; +} + +/** The tail [L:end] is a byte-exact mirror of the equal-length block before L. */ +function isTailMirror(data: Uint8Array, L: number): boolean { + const t = data.length - L; + if (t <= 0 || t > L) return false; + return regionsEqual(data, L, L - t, t); +} + +/** The tail [L:end] is entirely 0x00 or entirely 0xFF. */ +function isTailFill(data: Uint8Array, L: number): boolean { + const end = data.length; + if (end <= L) return false; + const v = data[L]; + if (v !== 0x00 && v !== 0xff) return false; + for (let i = L + 1; i < end; i++) if (data[i] !== v) return false; + return true; +} + +/** + * Trim a Retrode over-dump to the real ROM size. Returns the smallest ladder + * length whose tail is a provable mirror/fill of earlier data AND whose + * recomputed internal checksum matches the stored header checksum. If nothing + * qualifies (no valid header, unusual checksum, or already-correct size) the + * input is returned unchanged — No-Intro verification is the backstop. + */ +export function trimSnes(input: Uint8Array): { + data: Uint8Array; + note?: string; +} { + const data = stripCopierHeader(input); + const strippedNote = + data.length !== input.length + ? "stripped 512-byte copier header" + : undefined; + const passthrough = strippedNote + ? { data, note: strippedNote } + : { data: input }; + + const header = detectHeader(data); + if (!header || !header.checksumValid) return passthrough; + + const D = data.length; + const stored = header.checksum; + for (const L of SNES_SIZE_LADDER) { + if (L >= D) break; // ladder is ascending + if (!isTailMirror(data, L) && !isTailFill(data, L)) continue; + if (snesChecksum(data.subarray(0, L)) !== stored) continue; + const mb = (L / (1024 * 1024)).toFixed(L % (1024 * 1024) === 0 ? 0 : 1); + const hex = stored.toString(16).toUpperCase().padStart(4, "0"); + const note = + (strippedNote ? `${strippedNote}; ` : "") + + `over-dump trimmed to ${mb} MB (header checksum 0x${hex})`; + return { data: data.subarray(0, L), note }; + } + return passthrough; +} + +const REGION_NAMES: Record<number, string> = { + 0x00: "Japan", + 0x01: "USA", + 0x02: "Europe", + 0x03: "Sweden/Scandinavia", + 0x06: "France", + 0x07: "Netherlands", + 0x08: "Spain", + 0x09: "Germany", + 0x0a: "Italy", + 0x0b: "China", + 0x0d: "Korea", + 0x0f: "Canada", + 0x10: "Brazil", + 0x11: "Australia", +}; + +export function regionName(code: number): string { + return REGION_NAMES[code] ?? `Region 0x${code.toString(16).toUpperCase()}`; +} + +// Map-mode byte ($FFD5) layout: low nibble selects the memory map, bit 4 is the +// FastROM/SlowROM access-speed flag. +const MAP_MODE_MASK = 0x0f; +const MAP_MODE_FASTROM_BIT = 0x10; + +const MAP_MODE_NAMES: Record<number, string> = { + 0x00: "LoROM", + 0x01: "HiROM", + 0x02: "SA-1 ROM", + 0x03: "ExLoROM", + 0x05: "ExHiROM", +}; + +/** Human name for the memory map selected by the low nibble of $FFD5. */ +export function mapModeName(mapMode: number): string { + const mode = mapMode & MAP_MODE_MASK; + return MAP_MODE_NAMES[mode] ?? `Mode 0x${mode.toString(16).toUpperCase()}`; +} + +export type RomSpeed = "FastROM" | "SlowROM"; + +/** Access speed encoded by bit 4 of $FFD5: set = FastROM (3.58 MHz). */ +export function romSpeed(mapMode: number): RomSpeed { + return mapMode & MAP_MODE_FASTROM_BIT ? "FastROM" : "SlowROM"; +} + +const CART_TYPE_NAMES: Record<number, string> = { + 0x00: "ROM Only", + 0x01: "ROM + RAM", + 0x02: "ROM + RAM + Battery", + 0x03: "ROM + Enhancement Chip", + 0x13: "ROM + Super FX", + 0x1a: "ROM + SA-1", + 0x1b: "ROM + SA-1 + RAM", +}; + +/** Human name for the cartridge-hardware byte at $FFD6. */ +export function cartTypeName(cartType: number): string { + const name = CART_TYPE_NAMES[cartType]; + return name + ? `${name} (0x${cartType.toString(16).toUpperCase().padStart(2, "0")})` + : `Unknown (0x${cartType.toString(16).toUpperCase().padStart(2, "0")})`; +} + +// SNES size encoding: a code byte N maps to 0x400 << N bytes (1 KiB base unit). +const SIZE_CODE_UNIT = 0x400; + +/** Decode a ROM/RAM size code byte ($FFD7 / $FFD8) to a byte count. */ +export function decodeSizeCode(code: number): number { + return SIZE_CODE_UNIT << code; +} + +/** + * Format a byte count as a binary-prefixed size string (KiB / MiB), matching + * the size labels RetroSpector prints. A code of 0 (no RAM) yields "None". + */ +export function formatSize(bytes: number): string { + if (bytes <= 0) return "None"; + const MIB = 1024 * 1024; + const KIB = 1024; + if (bytes >= MIB) { + const mib = bytes / MIB; + return `${Number.isInteger(mib) ? mib : mib.toFixed(2)} MiB`; + } + const kib = bytes / KIB; + return `${Number.isInteger(kib) ? kib : kib.toFixed(2)} KiB`; +} diff --git a/src/lib/systems/snes/snes-system-handler.test.ts b/src/lib/systems/snes/snes-system-handler.test.ts new file mode 100644 index 0000000..b4ce0b5 --- /dev/null +++ b/src/lib/systems/snes/snes-system-handler.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from "vitest"; +import { SNESSystemHandler } from "./snes-system-handler"; +import { snesChecksum } from "./snes-rom"; + +const MB = 1024 * 1024; + +/** Deterministic, well-varied byte at index i. */ +const fillByte = (i: number) => (i ^ (i >>> 7) ^ (i >>> 13) ^ 0x5a) & 0xff; + +/** + * Build a synthetic LoROM ROM with a self-consistent internal header at + * $7FC0 (same cancellation trick as snes-rom.test.ts: writing C and ~C makes + * snesChecksum(rom) === C). `title` is written into the 21-byte title window, + * space-padded to mimic a real header. + */ +function buildRom(size: number, title: string): Uint8Array { + const data = new Uint8Array(size); + for (let i = 0; i < size; i++) data[i] = fillByte(i); + const b = 0x7fc0; + for (let i = 0; i < 21; i++) data[b + i] = title.charCodeAt(i) || 0x20; + data[b + 0x15] = 0x20; // map mode: LoROM, SlowROM + data[b + 0x16] = 0x00; // cart type: ROM only + data[b + 0x17] = 0x0b; // ROM size code (advisory) + data[b + 0x18] = 0x00; // no SRAM + data[b + 0x19] = 0x01; // region: USA + data[b + 0x3c] = 0x00; // reset vector -> $8000 so the header scores + data[b + 0x3d] = 0x80; + data[b + 0x1c] = 0xff; // placeholder complement 0xFFFF + data[b + 0x1d] = 0xff; + data[b + 0x1e] = 0x00; // placeholder checksum 0x0000 + data[b + 0x1f] = 0x00; + const c = snesChecksum(data); + data[b + 0x1e] = c & 0xff; + data[b + 0x1f] = (c >> 8) & 0xff; + const comp = c ^ 0xffff; + data[b + 0x1c] = comp & 0xff; + data[b + 0x1d] = (comp >> 8) & 0xff; + return data; +} + +describe("SNESSystemHandler.headerTitle", () => { + const handler = new SNESSystemHandler(); + + it("returns the trimmed internal title for a synthesized ROM", () => { + const rom = buildRom(2 * MB, "TEST GAME "); + expect(handler.headerTitle(rom)).toBe("TEST GAME"); + }); + + it("returns undefined when no header is detected (noise)", () => { + const noise = new Uint8Array(0x10000); + for (let i = 0; i < noise.length; i++) noise[i] = (i * 91) & 0xff; + expect(handler.headerTitle(noise)).toBeUndefined(); + }); + + it("returns undefined for a buffer too small to hold a header", () => { + expect(handler.headerTitle(new Uint8Array(0x100))).toBeUndefined(); + }); + + it("returns undefined when the detected header's title is blank", () => { + // A self-consistent header whose title window is all spaces: the header + // detects, but the title decodes to "" -> undefined, not an empty stem. + const rom = buildRom(2 * MB, " "); + expect(handler.headerTitle(rom)).toBeUndefined(); + }); +}); + +describe("SNESSystemHandler.summarizeDump (DB-free internal-checksum check)", () => { + const handler = new SNESSystemHandler(); + + it("reports consistent internal checksum for a self-consistent header", () => { + const rom = buildRom(2 * MB, "TEST GAME "); + const summary = handler.summarizeDump(rom); + expect(summary).not.toBeNull(); + expect(summary!.integrity).toEqual({ + ok: true, + message: "Internal checksum consistent", + }); + // Surfaces the decoded header fields for context. Map mode 0x20 = LoROM + // (low nibble 0) at SlowROM speed (bit 4 clear); ROM size code 0x0B + // decodes to 2 MiB; RAM code 0 means no on-cart RAM; region 0x01 = USA. + expect(summary!.rows).toContainEqual(["Title", "TEST GAME"]); + expect(summary!.rows).toContainEqual(["Map mode", "LoROM"]); + expect(summary!.rows).toContainEqual(["ROM speed", "SlowROM"]); + expect(summary!.rows).toContainEqual(["Cartridge type", "ROM Only (0x00)"]); + expect(summary!.rows).toContainEqual(["ROM size", "2 MiB"]); + expect(summary!.rows).toContainEqual(["RAM size", "None"]); + expect(summary!.rows).toContainEqual(["Region", "USA"]); + }); + + it("decodes FastROM speed, an enhancement-chip cart type, and SRAM size", () => { + const rom = buildRom(2 * MB, "TEST GAME "); + const b = 0x7fc0; + rom[b + 0x15] = 0x31; // map mode: HiROM (nibble 1) + FastROM (bit 4) + rom[b + 0x16] = 0x02; // cart type: ROM + RAM + Battery + rom[b + 0x18] = 0x05; // RAM size code 5 -> 0x400 << 5 = 32 KiB + rom[b + 0x1b] = 0x01; // revision 1 + // Re-derive the checksum/complement after mutating header bytes. + rom[b + 0x1c] = 0xff; + rom[b + 0x1d] = 0xff; + rom[b + 0x1e] = 0x00; + rom[b + 0x1f] = 0x00; + const c = snesChecksum(rom); + rom[b + 0x1e] = c & 0xff; + rom[b + 0x1f] = (c >> 8) & 0xff; + const comp = c ^ 0xffff; + rom[b + 0x1c] = comp & 0xff; + rom[b + 0x1d] = (comp >> 8) & 0xff; + + const summary = handler.summarizeDump(rom); + expect(summary!.integrity?.ok).toBe(true); + expect(summary!.rows).toContainEqual(["Map mode", "HiROM"]); + expect(summary!.rows).toContainEqual(["ROM speed", "FastROM"]); + expect(summary!.rows).toContainEqual([ + "Cartridge type", + "ROM + RAM + Battery (0x02)", + ]); + expect(summary!.rows).toContainEqual(["RAM size", "32 KiB"]); + expect(summary!.rows).toContainEqual(["Revision", "Rev 1"]); + }); + + it("reports a mismatch when a byte changes after the checksum was set", () => { + const rom = buildRom(2 * MB, "TEST GAME "); + // Flip a non-header byte: the stored checksum/complement still satisfy + // checksum+complement==0xFFFF, but snesChecksum(rom) no longer equals it. + rom[0] ^= 0xff; + const summary = handler.summarizeDump(rom); + expect(summary!.integrity?.ok).toBe(false); + expect(summary!.integrity?.message).toBe("Internal checksum mismatch"); + }); + + it("returns null when no header is detected (noise)", () => { + const noise = new Uint8Array(0x10000); + for (let i = 0; i < noise.length; i++) noise[i] = (i * 91) & 0xff; + expect(handler.summarizeDump(noise)).toBeNull(); + }); +}); diff --git a/src/lib/systems/snes/snes-system-handler.ts b/src/lib/systems/snes/snes-system-handler.ts new file mode 100644 index 0000000..edaa2e4 --- /dev/null +++ b/src/lib/systems/snes/snes-system-handler.ts @@ -0,0 +1,191 @@ +import type { + SystemHandler, + ConfigValues, + CartridgeInfo, + DumpSummary, + DumpSummaryCell, + ResolvedConfigField, + ValidationResult, + ReadConfig, + OutputFile, + VerificationHashes, + VerificationDB, + VerificationResult, +} from "@/lib/types"; +import { crc32, sha1Hex, sha256Hex } from "@/lib/core/hashing"; +import { + cartTypeName, + decodeSizeCode, + detectHeader, + formatSize, + mapModeName, + regionName, + romSpeed, + snesChecksum, + trimSnes, +} from "./snes-rom"; + +/** + * Super Nintendo / Super Famicom. ROMs are raw binaries with an INTERNAL + * header (part of the ROM), so — like GBA, unlike NES — the No-Intro hash is + * over the whole (trimmed) ROM with no external-header splice. Over-dump + * trimming happens upstream in the Retrode driver's readROM (see snes-rom.ts); + * this handler validates and packages the already-correct bytes. + */ +export class SNESSystemHandler implements SystemHandler { + readonly systemId = "snes"; + readonly displayName = "Super Nintendo"; + readonly fileExtension = ".sfc"; + + getConfigFields( + _currentValues: ConfigValues, + autoDetected?: CartridgeInfo, + ): ResolvedConfigField[] { + const fields: ResolvedConfigField[] = []; + if (autoDetected?.title) { + fields.push({ + key: "title", + label: "Game Title", + type: "readonly", + value: autoDetected.title, + autoDetected: true, + group: "cartridge", + order: 0, + }); + } + const region = autoDetected?.meta?.region; + if (typeof region === "string") { + fields.push({ + key: "region", + label: "Region", + type: "readonly", + value: region, + autoDetected: true, + group: "cartridge", + order: 1, + }); + } + return fields; + } + + validate(_values: ConfigValues): ValidationResult { + return { valid: true }; + } + + suggestTrim(content: Uint8Array): { size: number; note: string } | null { + const { data, note } = trimSnes(content); + return data.length < content.length + ? { size: data.length, note: note ?? "heuristic over-dump trim" } + : null; + } + + buildReadConfig(_values: ConfigValues): ReadConfig { + return { systemId: "snes", params: {} }; + } + + /** + * Title parsed from the SNES internal header at $7FC0 (decoded by + * detectHeader: non-printable bytes already mapped to spaces, trailing + * whitespace stripped). We also strip leading whitespace so the result is a + * clean filename stem, and return undefined when no header is detected or the + * title is blank. + */ + headerTitle(content: Uint8Array): string | undefined { + const title = detectHeader(content)?.title.trim(); + return title ? title : undefined; + } + + buildOutputFile(rawData: Uint8Array, _config: ReadConfig): OutputFile { + const header = detectHeader(rawData); + const meta: Record<string, string> = { Format: "Super Nintendo" }; + if (header) { + meta.Mapping = header.map.toUpperCase(); + meta.Region = regionName(header.region); + const computed = snesChecksum(rawData); + meta.Checksum = + `0x${header.checksum.toString(16).toUpperCase().padStart(4, "0")}` + + (computed === header.checksum ? " (ok)" : " (mismatch)"); + } + return { + data: rawData, + filename: "dump.sfc", + mimeType: "application/octet-stream", + meta, + }; + } + + async computeHashes(rawData: Uint8Array): Promise<VerificationHashes> { + const [sha1, sha256] = await Promise.all([ + sha1Hex(rawData), + sha256Hex(rawData), + ]); + return { crc32: crc32(rawData), sha1, sha256, size: rawData.length }; + } + + verify( + hashes: VerificationHashes, + db: VerificationDB | null, + ): VerificationResult { + if (!db) return { matched: false, confidence: "none" }; + const entry = db.lookup(hashes); + if (entry) return { matched: true, entry, confidence: "exact" }; + return { + matched: false, + confidence: "none", + suggestions: [ + "No No-Intro match. If this is an over-dump, the internal checksum " + + "may still validate — try trimming, or import the SNES DAT.", + ], + }; + } + + /** + * Internal-header self-check, available with NO No-Intro database. The SNES + * header carries a 16-bit checksum and its complement; a clean dump satisfies + * checksum + complement == 0xFFFF AND the checksum recomputed over the bytes + * (mirror-normalized) equals the stored value. When no DAT is loaded, this is + * the only consistency signal we can offer — surfaced as a neutral integrity + * result, never a No-Intro "verified". Null when no header is found (don't + * fabricate a verdict over noise). + */ + summarizeDump(rawData: Uint8Array): DumpSummary | null { + const header = detectHeader(rawData); + if (!header) return null; + const consistent = + header.checksumValid && snesChecksum(rawData) === header.checksum; + + const title = header.title.trim(); + const ramSize = + header.ramSizeCode === 0 + ? "None" + : formatSize(decodeSizeCode(header.ramSizeCode)); + const rows: DumpSummaryCell[][] = []; + if (title) rows.push(["Title", title]); + rows.push( + ["Region", regionName(header.region)], + ["Map mode", mapModeName(header.mapMode)], + ["ROM speed", romSpeed(header.mapMode)], + ["Cartridge type", cartTypeName(header.cartType)], + ["ROM size", formatSize(decodeSizeCode(header.romSizeCode))], + ["RAM size", ramSize], + ["Revision", `Rev ${header.revision}`], + [ + "Checksum", + `0x${header.checksum.toString(16).toUpperCase().padStart(4, "0")}`, + ], + ); + + return { + title: "SNES header", + columns: ["Field", "Value"], + monoColumns: [1], + rows, + integrity: { + ok: consistent, + message: consistent + ? "Internal checksum consistent" + : "Internal checksum mismatch", + }, + }; + } +} diff --git a/src/lib/systems/vboy/vboy-rom.ts b/src/lib/systems/vboy/vboy-rom.ts new file mode 100644 index 0000000..fc187f8 --- /dev/null +++ b/src/lib/systems/vboy/vboy-rom.ts @@ -0,0 +1,147 @@ +/** + * Virtual Boy ROM internal-header parsing — pure, no DOM. + * + * Unlike most cart headers, the Virtual Boy header sits near the END of the + * ROM image, at (file_size − 0x220). It is a 32-byte block: + * + * +0x00 title 20 bytes, Shift-JIS, space/NUL padded + * +0x14 reserved 5 bytes, must be 0x00 + * +0x19 maker code 2 bytes, ASCII uppercase alphanumeric + * +0x1B game code 4 bytes, ASCII uppercase alphanumeric + * (the 4th byte is the region letter) + * +0x1F version 1 byte + * + * There is no internal checksum, so structural validity is established by the + * gate checks (power-of-two size in range, zeroed reserved field, valid + * maker/game codes, a printable title) rather than a stored hash. + * + * Reference: + * https://planetvb.com/modules/dokuwiki/doku.php?id=info_at_the_end_of_the_rom + */ + +const MIN_ROM_SIZE = 0x20000; // 128 KB +const MAX_ROM_SIZE = 0x200000; // 2 MB +const HEADER_FROM_END = 0x220; +const HEADER_SIZE = 32; + +const TITLE_START = 0x00; +const TITLE_END = 0x14; +const RESERVED_START = 0x14; +const RESERVED_END = 0x19; +const MAKER_CODE_START = 0x19; +const MAKER_CODE_END = 0x1b; +const GAME_CODE_START = 0x1b; +const GAME_CODE_END = 0x1f; +const VERSION_OFFSET = 0x1f; + +export interface VirtualBoyHeader { + /** Decoded Shift-JIS title, trimmed of padding. */ + title: string; + /** 4-char game code (e.g. "ZZZE"); 4th char is the region letter. */ + gameCode: string; + /** Region letter — the 4th game-code byte (e.g. "E", "J", "P"). */ + regionCode: string; + /** Human region name for `regionCode`. */ + region: string; + /** 2-char maker code (e.g. "01"). */ + makerCode: string; + /** Mask-ROM revision byte. */ + version: number; +} + +function isAsciiUppercaseAlnum(b: number): boolean { + return (b >= 0x41 && b <= 0x5a) || (b >= 0x30 && b <= 0x39); // A-Z, 0-9 +} + +const sjis = new TextDecoder("shift-jis", { fatal: false }); + +/** + * Decode the 20-byte Shift-JIS title, trimming spaces and NULs from both ends. + * Pure ASCII titles decode identically under Shift-JIS, so we don't special-case + * UTF-8 the way the host tool does — the byte source is the cart, not a file. + */ +function decodeTitle(bytes: Uint8Array): string { + return sjis + .decode(bytes) + .replace(/^[\s\0]+/, "") + .replace(/[\s\0]+$/, ""); +} + +// Nintendo region letters (the 4th game-code byte). Matches the DAT region +// names so the parsed value can feed verification metadata directly. +const REGION_NAMES: Record<string, string> = { + J: "Japan", + E: "USA", + P: "Europe", + D: "Germany", + F: "France", + I: "Italy", + S: "Spain", + H: "Netherlands", + K: "Korea", + A: "Asia", + O: "World", +}; + +export function vboyRegionName(regionCode: string): string { + return REGION_NAMES[regionCode] ?? "Unknown"; +} + +const asciiCode = (bytes: Uint8Array): string => + Array.from(bytes, (b) => String.fromCharCode(b)).join(""); + +/** + * Parse a Virtual Boy ROM image. Returns null when the bytes don't present a + * valid header — wrong/non-power-of-two size, non-zero reserved field, invalid + * maker/game codes, or an unprintable title — so noise never produces a result. + */ +export function parseVirtualBoyRom(data: Uint8Array): VirtualBoyHeader | null { + const len = data.length; + + // File size must be a power of two in range 128 KB – 2 MB. + if (len < MIN_ROM_SIZE || len > MAX_ROM_SIZE || (len & (len - 1)) !== 0) { + return null; + } + + const headerOffset = len - HEADER_FROM_END; + if (headerOffset < 0 || headerOffset + HEADER_SIZE > len) return null; + const header = data.subarray(headerOffset, headerOffset + HEADER_SIZE); + + // Reserved bytes (+0x14, 5 bytes) must all be zero. + for (let i = RESERVED_START; i < RESERVED_END; i++) { + if (header[i] !== 0) return null; + } + + // Maker code (+0x19, 2 bytes) must be ASCII uppercase alphanumeric. + if ( + !isAsciiUppercaseAlnum(header[MAKER_CODE_START]) || + !isAsciiUppercaseAlnum(header[MAKER_CODE_END - 1]) + ) { + return null; + } + + // Game code (+0x1B, 4 bytes) must be ASCII uppercase alphanumeric. + for (let i = GAME_CODE_START; i < GAME_CODE_END; i++) { + if (!isAsciiUppercaseAlnum(header[i])) return null; + } + + // Title (+0x00, 20 bytes): require at least one printable ASCII or space + // character (rejects an all-padding or all-control-byte field as noise). + const title = decodeTitle(header.subarray(TITLE_START, TITLE_END)); + if (![...title].some((ch) => /[\x20-\x7e]/.test(ch))) return null; + + const gameCode = asciiCode(header.subarray(GAME_CODE_START, GAME_CODE_END)); + const makerCode = asciiCode( + header.subarray(MAKER_CODE_START, MAKER_CODE_END), + ); + const regionCode = gameCode.charAt(3); + + return { + title, + gameCode, + regionCode, + region: vboyRegionName(regionCode), + makerCode, + version: header[VERSION_OFFSET], + }; +} diff --git a/src/lib/systems/vboy/vboy-system-handler.test.ts b/src/lib/systems/vboy/vboy-system-handler.test.ts new file mode 100644 index 0000000..99df00a --- /dev/null +++ b/src/lib/systems/vboy/vboy-system-handler.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect } from "vitest"; +import { parseVirtualBoyRom, vboyRegionName } from "./vboy-rom"; +import { VirtualBoySystemHandler } from "./vboy-system-handler"; + +const HEADER_FROM_END = 0x220; + +// Encode an ASCII string into a Shift-JIS-compatible byte run (pure ASCII is +// identical under Shift-JIS). +function ascii(s: string): number[] { + return Array.from(s, (c) => c.charCodeAt(0)); +} + +interface FixtureOpts { + size?: number; + title?: number[]; // raw title bytes (<= 20) + maker?: string; // 2 chars + gameCode?: string; // 4 chars + version?: number; + reserved?: number[]; // 5 reserved bytes; defaults to zeros +} + +/** Build a power-of-two ROM with a valid header at size − 0x220. */ +function buildRom(opts: FixtureOpts = {}): Uint8Array { + const size = opts.size ?? 0x100000; // 1 MB + const rom = new Uint8Array(size).fill(0xaa); // fill body with non-header noise + const base = size - HEADER_FROM_END; + + // Title (+0x00, 20 bytes), space-padded. + const titleBytes = opts.title ?? ascii("TEST TITLE"); + for (let i = 0; i < 20; i++) rom[base + i] = titleBytes[i] ?? 0x20; + + // Reserved (+0x14, 5 bytes). + const reserved = opts.reserved ?? [0, 0, 0, 0, 0]; + for (let i = 0; i < 5; i++) rom[base + 0x14 + i] = reserved[i]; + + // Maker code (+0x19, 2 bytes). + const maker = ascii((opts.maker ?? "01").padEnd(2, "0")); + rom[base + 0x19] = maker[0]; + rom[base + 0x1a] = maker[1]; + + // Game code (+0x1B, 4 bytes). + const gc = ascii((opts.gameCode ?? "ZZZE").padEnd(4, "A")); + for (let i = 0; i < 4; i++) rom[base + 0x1b + i] = gc[i]; + + // Version (+0x1F). + rom[base + 0x1f] = opts.version ?? 1; + + return rom; +} + +describe("parseVirtualBoyRom", () => { + it("parses a valid header at file_size − 0x220", () => { + const header = parseVirtualBoyRom(buildRom())!; + expect(header).toMatchObject({ + title: "TEST TITLE", + gameCode: "ZZZE", + regionCode: "E", + region: "USA", + makerCode: "01", + version: 1, + }); + }); + + it("decodes a Shift-JIS multibyte title and trims padding", () => { + // "ゲーム VB" in Shift-JIS: ゲ=0x83 0x51, ー=0x81 0x5b, ム=0x83 0x80, + // then an ASCII " VB" (the title gate requires a printable ASCII char). + const sjisTitle = [0x83, 0x51, 0x81, 0x5b, 0x83, 0x80, 0x20, 0x56, 0x42]; + const header = parseVirtualBoyRom( + buildRom({ title: sjisTitle, gameCode: "ZZZJ" }), + )!; + expect(header.title).toBe("ゲーム VB"); + expect(header.regionCode).toBe("J"); + expect(header.region).toBe("Japan"); + }); + + it("extracts the raw maker code from the header", () => { + expect(parseVirtualBoyRom(buildRom({ maker: "AF" }))!.makerCode).toBe("AF"); + expect(parseVirtualBoyRom(buildRom({ maker: "ZZ" }))!.makerCode).toBe("ZZ"); + }); + + it("accepts the min and max power-of-two sizes", () => { + expect(parseVirtualBoyRom(buildRom({ size: 0x20000 }))).not.toBeNull(); + expect(parseVirtualBoyRom(buildRom({ size: 0x200000 }))).not.toBeNull(); + }); + + it("rejects a non-power-of-two size", () => { + expect(parseVirtualBoyRom(buildRom({ size: 0x180000 }))).toBeNull(); + }); + + it("rejects sizes outside the 128 KB – 2 MB range", () => { + expect(parseVirtualBoyRom(buildRom({ size: 0x10000 }))).toBeNull(); + expect(parseVirtualBoyRom(buildRom({ size: 0x400000 }))).toBeNull(); + }); + + it("rejects a non-zero reserved field", () => { + expect( + parseVirtualBoyRom(buildRom({ reserved: [0, 0, 1, 0, 0] })), + ).toBeNull(); + }); + + it("rejects an invalid (lowercase) maker code", () => { + expect(parseVirtualBoyRom(buildRom({ maker: "a1" }))).toBeNull(); + }); + + it("rejects an invalid game code", () => { + expect(parseVirtualBoyRom(buildRom({ gameCode: "vv-E" }))).toBeNull(); + }); + + it("returns null for an all-zero (blank) image", () => { + expect(parseVirtualBoyRom(new Uint8Array(0x100000))).toBeNull(); + }); + + it("rejects a title with no printable ASCII / space (control bytes only)", () => { + expect( + parseVirtualBoyRom(buildRom({ title: [0x01, 0x02, 0x03] })), + ).toBeNull(); + }); + + it("returns null for noise — no valid header at the tail offset", () => { + const noise = new Uint8Array(0x100000).fill(0xa5); + expect(parseVirtualBoyRom(noise)).toBeNull(); + }); +}); + +describe("vboy lookups", () => { + it("maps region letters to names", () => { + expect(vboyRegionName("E")).toBe("USA"); + expect(vboyRegionName("P")).toBe("Europe"); + expect(vboyRegionName("Z")).toBe("Unknown"); + }); +}); + +describe("VirtualBoySystemHandler", () => { + const handler = new VirtualBoySystemHandler(); + + it("exposes the expected identity", () => { + expect(handler.systemId).toBe("vboy"); + expect(handler.displayName).toBe("Virtual Boy"); + expect(handler.fileExtension).toBe(".vb"); + }); + + it("headerTitle returns the parsed title, undefined on noise", () => { + expect(handler.headerTitle(buildRom())).toBe("TEST TITLE"); + expect( + handler.headerTitle(new Uint8Array(0x100000).fill(0xa5)), + ).toBeUndefined(); + }); + + it("buildOutputFile names the dump and carries header meta", () => { + const out = handler.buildOutputFile(buildRom(), { + systemId: "vboy", + params: {}, + }); + expect(out.filename).toBe("dump.vb"); + expect(out.mimeType).toBe("application/octet-stream"); + expect(out.meta).toMatchObject({ + Format: "Virtual Boy", + "Game Code": "ZZZE", + Region: "USA", + Maker: "01", + Version: "1", + }); + }); + + it("summarizeDump returns a header table with structural integrity", () => { + const summary = handler.summarizeDump(buildRom())!; + expect(summary.title).toBe("Virtual Boy header"); + expect(summary.columns).toEqual(["Field", "Value"]); + expect(summary.monoColumns).toEqual([1]); + expect(summary.rows).toContainEqual(["Title", "TEST TITLE"]); + expect(summary.rows).toContainEqual(["Game code", "ZZZE"]); + expect(summary.rows).toContainEqual(["Region", "USA"]); + expect(summary.rows).toContainEqual(["Maker", "01"]); + expect(summary.integrity?.ok).toBe(true); + }); + + it("summarizeDump returns null over noise", () => { + expect( + handler.summarizeDump(new Uint8Array(0x100000).fill(0xa5)), + ).toBeNull(); + }); + + it("verify reports not-checked when no DB is loaded", () => { + const result = handler.verify({ crc32: 0, sha1: "", size: 0x100000 }, null); + expect(result).toEqual({ matched: false, confidence: "none" }); + }); +}); diff --git a/src/lib/systems/vboy/vboy-system-handler.ts b/src/lib/systems/vboy/vboy-system-handler.ts new file mode 100644 index 0000000..d393358 --- /dev/null +++ b/src/lib/systems/vboy/vboy-system-handler.ts @@ -0,0 +1,149 @@ +import type { + SystemHandler, + ConfigValues, + CartridgeInfo, + DumpSummary, + DumpSummaryCell, + ResolvedConfigField, + ValidationResult, + ReadConfig, + OutputFile, + VerificationHashes, + VerificationDB, + VerificationResult, +} from "@/lib/types"; +import { crc32, sha1Hex, sha256Hex } from "@/lib/core/hashing"; +import { parseVirtualBoyRom } from "./vboy-rom"; + +/** + * Nintendo Virtual Boy. ROMs are raw binaries with an INTERNAL header that — + * unlike most carts — sits near the END of the image (at file_size − 0x220). + * The No-Intro hash is over the whole ROM with no external-header splice, so + * this handler validates and packages the already-correct bytes. + * + * The format carries no internal checksum; structural integrity is the + * power-of-two size-in-range check plus the header gate checks (zeroed + * reserved field, valid maker/game codes, a printable title) performed by + * {@link parseVirtualBoyRom}. + */ +export class VirtualBoySystemHandler implements SystemHandler { + readonly systemId = "vboy"; + readonly displayName = "Virtual Boy"; + readonly fileExtension = ".vb"; + + getConfigFields( + _currentValues: ConfigValues, + autoDetected?: CartridgeInfo, + ): ResolvedConfigField[] { + const fields: ResolvedConfigField[] = []; + if (autoDetected?.title) { + fields.push({ + key: "title", + label: "Game Title", + type: "readonly", + value: autoDetected.title, + autoDetected: true, + group: "cartridge", + order: 0, + }); + } + const region = autoDetected?.meta?.region; + if (typeof region === "string") { + fields.push({ + key: "region", + label: "Region", + type: "readonly", + value: region, + autoDetected: true, + group: "cartridge", + order: 1, + }); + } + return fields; + } + + validate(_values: ConfigValues): ValidationResult { + return { valid: true }; + } + + buildReadConfig(_values: ConfigValues): ReadConfig { + return { systemId: "vboy", params: {} }; + } + + /** + * Title parsed from the internal header near the end of the ROM. Returns + * undefined when no valid header is present or the title is blank, so it's + * safe to use directly as a filename stem. + */ + headerTitle(content: Uint8Array): string | undefined { + const title = parseVirtualBoyRom(content)?.title.trim(); + return title ? title : undefined; + } + + buildOutputFile(rawData: Uint8Array, _config: ReadConfig): OutputFile { + const header = parseVirtualBoyRom(rawData); + const meta: Record<string, string> = { Format: "Virtual Boy" }; + if (header) { + meta["Game Code"] = header.gameCode; + meta.Region = header.region; + meta.Maker = header.makerCode; + if (header.version !== 0) meta.Version = String(header.version); + } + return { + data: rawData, + filename: "dump.vb", + mimeType: "application/octet-stream", + meta, + }; + } + + async computeHashes(rawData: Uint8Array): Promise<VerificationHashes> { + const [sha1, sha256] = await Promise.all([ + sha1Hex(rawData), + sha256Hex(rawData), + ]); + return { crc32: crc32(rawData), sha1, sha256, size: rawData.length }; + } + + verify( + hashes: VerificationHashes, + db: VerificationDB | null, + ): VerificationResult { + if (!db) return { matched: false, confidence: "none" }; + const entry = db.lookup(hashes); + if (entry) return { matched: true, entry, confidence: "exact" }; + return { matched: false, confidence: "none" }; + } + + /** + * Internal-header summary, available with NO No-Intro database. The Virtual + * Boy format has no stored checksum, so integrity is the structural + * self-check: a valid header at file_size − 0x220 in a power-of-two ROM of + * 128 KB – 2 MB. Null when no valid header is found (don't fabricate a + * verdict over noise). + */ + summarizeDump(rawData: Uint8Array): DumpSummary | null { + const header = parseVirtualBoyRom(rawData); + if (!header) return null; + + const rows: DumpSummaryCell[][] = []; + if (header.title) rows.push(["Title", header.title]); + rows.push( + ["Game code", header.gameCode], + ["Region", header.region], + ["Maker", header.makerCode], + ["Version", String(header.version)], + ); + + return { + title: "Virtual Boy header", + columns: ["Field", "Value"], + monoColumns: [1], + rows, + integrity: { + ok: true, + message: "Header structurally valid (no internal checksum)", + }, + }; + } +} diff --git a/src/lib/transport/directory-transport.test.ts b/src/lib/transport/directory-transport.test.ts new file mode 100644 index 0000000..d56c26c --- /dev/null +++ b/src/lib/transport/directory-transport.test.ts @@ -0,0 +1,537 @@ +import { describe, it, expect } from "vitest"; +import { DirectoryTransport } from "./directory-transport"; + +// ── helpers ────────────────────────────────────────────────────────────────── + +/** Byte-equality without the pathological cost of toEqual on big arrays. */ +function expectBytesEqual(actual: Uint8Array, expected: Uint8Array): void { + expect(actual.length).toBe(expected.length); + for (let i = 0; i < expected.length; i++) { + if (actual[i] !== expected[i]) { + throw new Error( + `byte mismatch at ${i}: got ${actual[i]}, expected ${expected[i]}`, + ); + } + } +} + +function bytes(...vals: number[]): Uint8Array { + return Uint8Array.from(vals); +} + +// ── Fake File System Access API objects ─────────────────────────────────────── +// These are deliberately minimal: only the surface DirectoryTransport touches. +// They are plain objects cast to the DOM handle types where the transport needs +// them, so the cast lives at the boundary and the test body stays untyped-free. + +/** Records the call order across a writable's lifecycle methods. */ +interface WriteRecorder { + readonly order: string[]; + /** Bytes/strings handed to write(). */ + readonly written: (ArrayBufferView | string)[]; + abortCalls: number; +} + +/** + * A fake FileSystemWritableFileStream. `failOn` lets a test force write() or + * close() to throw so we can exercise the abort-and-rethrow path. + */ +function makeWritable( + rec: WriteRecorder, + failOn?: "write" | "close", + failMessage = "disk full", +): FileSystemWritableFileStream { + const writable = { + write(data: ArrayBufferView | string): Promise<void> { + rec.order.push("write"); + if (failOn === "write") return Promise.reject(new Error(failMessage)); + rec.written.push(data); + return Promise.resolve(); + }, + close(): Promise<void> { + rec.order.push("close"); + if (failOn === "close") return Promise.reject(new Error(failMessage)); + return Promise.resolve(); + }, + abort(): Promise<void> { + rec.abortCalls += 1; + rec.order.push("abort"); + return Promise.resolve(); + }, + }; + return writable as unknown as FileSystemWritableFileStream; +} + +/** A fake FileSystemFileHandle whose createWritable returns the given writable. */ +function makeFileHandle(writable: FileSystemWritableFileStream): FileSystemFileHandle { + const handle = { + kind: "file" as const, + createWritable(): Promise<FileSystemWritableFileStream> { + return Promise.resolve(writable); + }, + }; + return handle as unknown as FileSystemFileHandle; +} + +/** + * A directory handle that resolves getFileHandle(name, {create:true}) to a + * handle whose createWritable yields `writable`. Used for the writeFile tests. + */ +function dirForWrite(writable: FileSystemWritableFileStream): FileSystemDirectoryHandle { + const dir = { + name: "vol", + getFileHandle(): Promise<FileSystemFileHandle> { + return Promise.resolve(makeFileHandle(writable)); + }, + }; + return dir as unknown as FileSystemDirectoryHandle; +} + +/** A fake File. `streamChunks`, when present, backs file.stream(). */ +function makeFile( + data: Uint8Array, + streamChunks?: Uint8Array[], +): File { + const file = { + size: data.length, + arrayBuffer(): Promise<ArrayBuffer> { + // Hand back a fresh, exact-length copy (not the backing store's buffer). + const copy = data.slice(); + return Promise.resolve( + copy.buffer.slice(copy.byteOffset, copy.byteOffset + copy.byteLength), + ); + }, + stream(): ReadableStream<Uint8Array> { + let i = 0; + const chunks = streamChunks ?? [data]; + const reader = { + read(): Promise<ReadableStreamReadResult<Uint8Array>> { + if (i >= chunks.length) { + return Promise.resolve({ done: true, value: undefined }); + } + const value = chunks[i]; + i += 1; + return Promise.resolve({ done: false, value }); + }, + releaseLock(): void {}, + }; + return { + getReader() { + return reader; + }, + } as unknown as ReadableStream<Uint8Array>; + }, + }; + return file as unknown as File; +} + +/** A directory handle that serves the given File via getFileHandle/getFile. */ +function dirForRead(name: string, file: File): FileSystemDirectoryHandle { + const dir = { + name: "vol", + getFileHandle(requested: string): Promise<FileSystemFileHandle> { + if (requested !== name) { + return Promise.reject(new Error(`NotFoundError: ${requested}`)); + } + const handle = { + kind: "file" as const, + getFile(): Promise<File> { + return Promise.resolve(file); + }, + }; + return Promise.resolve(handle as unknown as FileSystemFileHandle); + }, + }; + return dir as unknown as FileSystemDirectoryHandle; +} + +/** Mount a directory handle onto a fresh transport via adopt(). */ +function mounted(dir: FileSystemDirectoryHandle): DirectoryTransport { + const t = new DirectoryTransport(); + t.adopt(dir); + return t; +} + +// ── writeFile: success ───────────────────────────────────────────────────────── + +describe("DirectoryTransport.writeFile — success", () => { + it("calls createWritable -> write(data) -> close() in order, bytes reach the writable", async () => { + const rec: WriteRecorder = { order: [], written: [], abortCalls: 0 }; + const writable = makeWritable(rec); + const t = mounted(dirForWrite(writable)); + + const payload = bytes(0xde, 0xad, 0xbe, 0xef); + await t.writeFile("RETRODE.CFG", payload); + + expect(rec.order).toEqual(["write", "close"]); + expect(rec.abortCalls).toBe(0); + expect(rec.written).toHaveLength(1); + expectBytesEqual(rec.written[0] as Uint8Array, payload); + }); + + it("passes a string payload straight through to write()", async () => { + const rec: WriteRecorder = { order: [], written: [], abortCalls: 0 }; + const t = mounted(dirForWrite(makeWritable(rec))); + + await t.writeFile("notes.txt", "hello"); + + expect(rec.order).toEqual(["write", "close"]); + expect(rec.written[0]).toBe("hello"); + }); +}); + +// ── writeFile: failure (the recently-changed behavior) ────────────────────────── + +describe("DirectoryTransport.writeFile — failure aborts + rethrows with context", () => { + it("aborts the writable when write() throws and rejects with 'Failed to write <name>'", async () => { + const rec: WriteRecorder = { order: [], written: [], abortCalls: 0 }; + const writable = makeWritable(rec, "write", "device offline"); + const t = mounted(dirForWrite(writable)); + + await expect(t.writeFile("save.srm", bytes(1, 2, 3))).rejects.toThrow( + /^Failed to write save\.srm/, + ); + + // abort() must have been invoked exactly once to discard staged changes + // and release the stream lock. + expect(rec.abortCalls).toBe(1); + // write() was attempted, then abort fired; close() never ran. + expect(rec.order).toEqual(["write", "abort"]); + }); + + it("includes the original error's message in the thrown context", async () => { + const rec: WriteRecorder = { order: [], written: [], abortCalls: 0 }; + const t = mounted(dirForWrite(makeWritable(rec, "write", "device offline"))); + + const err = await t.writeFile("save.srm", bytes(1)).then( + () => null, + (e: unknown) => e as Error, + ); + expect(err).toBeInstanceOf(Error); + expect(err!.message).toBe("Failed to write save.srm: device offline"); + }); + + it("aborts and rethrows when close() throws (write succeeded but flush failed)", async () => { + const rec: WriteRecorder = { order: [], written: [], abortCalls: 0 }; + const writable = makeWritable(rec, "close", "flush interrupted"); + const t = mounted(dirForWrite(writable)); + + await expect(t.writeFile("RETRODE.CFG", bytes(7, 7))).rejects.toThrow( + "Failed to write RETRODE.CFG: flush interrupted", + ); + + expect(rec.abortCalls).toBe(1); + // write() and close() both ran, then abort cleaned up. + expect(rec.order).toEqual(["write", "close", "abort"]); + }); + + it("does not mask the original failure even if abort() itself rejects", async () => { + const rec: WriteRecorder = { order: [], written: [], abortCalls: 0 }; + const writable = { + write(): Promise<void> { + rec.order.push("write"); + return Promise.reject(new Error("primary failure")); + }, + close(): Promise<void> { + rec.order.push("close"); + return Promise.resolve(); + }, + abort(): Promise<void> { + rec.abortCalls += 1; + rec.order.push("abort"); + return Promise.reject(new Error("abort also failed")); + }, + } as unknown as FileSystemWritableFileStream; + const t = mounted(dirForWrite(writable)); + + // The .catch(() => {}) on abort() means the abort rejection is swallowed and + // the caller still sees the original write failure. + const err = await t.writeFile("save.srm", bytes(0)).then( + () => null, + (e: unknown) => e as Error, + ); + expect(err!.message).toBe("Failed to write save.srm: primary failure"); + expect(rec.abortCalls).toBe(1); + }); + + it("maps QuotaExceededError to a read-only-volume message (the synthesized-FAT swap-file case)", async () => { + const rec: WriteRecorder = { order: [], written: [], abortCalls: 0 }; + const writable = { + write(): Promise<void> { + rec.order.push("write"); + // createWritable's temp .crswap can't be allocated on a read-mostly + // synthesized FAT with no free clusters → QuotaExceededError. + const e = Object.assign(new Error("quota"), { + name: "QuotaExceededError", + }); + return Promise.reject(e); + }, + close: () => Promise.resolve(), + abort: () => { + rec.abortCalls += 1; + return Promise.resolve(); + }, + } as unknown as FileSystemWritableFileStream; + const t = mounted(dirForWrite(writable)); + + await expect(t.writeFile("RETRODE.CFG", bytes(1, 2))).rejects.toThrow( + /read-only through the browser/, + ); + expect(rec.abortCalls).toBe(1); + }); +}); + +// ── readFile: one-shot path (no onProgress) ───────────────────────────────────── + +describe("DirectoryTransport.readFile — without onProgress", () => { + it("returns file.arrayBuffer() bytes in one shot", async () => { + const payload = bytes(10, 20, 30, 40, 50); + const t = mounted(dirForRead("game.bin", makeFile(payload))); + + const out = await t.readFile("game.bin"); + expectBytesEqual(out, payload); + }); + + it("falls back to one-shot read when the File has no stream() method", async () => { + const payload = bytes(1, 2, 3); + // A File whose stream isn't a function: even with onProgress, the transport + // must take the arrayBuffer() path. + const file = { + size: payload.length, + arrayBuffer(): Promise<ArrayBuffer> { + const copy = payload.slice(); + return Promise.resolve( + copy.buffer.slice(copy.byteOffset, copy.byteOffset + copy.byteLength), + ); + }, + stream: undefined, + } as unknown as File; + const t = mounted(dirForRead("game.bin", file)); + + const progress: { read: number; total: number }[] = []; + const out = await t.readFile("game.bin", (read, total) => + progress.push({ read, total }), + ); + expectBytesEqual(out, payload); + expect(progress).toHaveLength(0); // never streamed + }); +}); + +// ── readFile: streaming path (with onProgress) ────────────────────────────────── + +describe("DirectoryTransport.readFile — with onProgress (streamed)", () => { + it("streams via file.stream(), reports monotonic progress, reassembles the source bytes", async () => { + const chunks = [bytes(1, 2, 3), bytes(4, 5), bytes(6, 7, 8, 9)]; + const full = bytes(1, 2, 3, 4, 5, 6, 7, 8, 9); + const t = mounted(dirForRead("rom.bin", makeFile(full, chunks))); + + const progress: { read: number; total: number }[] = []; + const out = await t.readFile("rom.bin", (read, total) => + progress.push({ read, total }), + ); + + expectBytesEqual(out, full); + + // One tick per non-empty chunk. + expect(progress).toHaveLength(chunks.length); + // total is constant and equals file.size for every tick. + for (const tick of progress) expect(tick.total).toBe(full.length); + // read is strictly increasing (monotonic) and the final tick reaches total. + for (let i = 1; i < progress.length; i++) { + expect(progress[i].read).toBeGreaterThan(progress[i - 1].read); + } + expect(progress[progress.length - 1].read).toBe(full.length); + // Fraction reaches read === total. + const last = progress[progress.length - 1]; + expect(last.read).toBe(last.total); + }); + + it("skips empty/undefined chunks without emitting a progress tick for them", async () => { + // A stream that yields a real chunk, then a falsy value, then the rest. + const data = bytes(0xaa, 0xbb, 0xcc, 0xdd); + const file = { + size: data.length, + arrayBuffer(): Promise<ArrayBuffer> { + return Promise.reject(new Error("should not be called on stream path")); + }, + stream(): ReadableStream<Uint8Array> { + const seq: (Uint8Array | undefined)[] = [ + bytes(0xaa, 0xbb), + undefined, + bytes(0xcc, 0xdd), + ]; + let i = 0; + const reader = { + read(): Promise<ReadableStreamReadResult<Uint8Array>> { + if (i >= seq.length) { + return Promise.resolve({ done: true, value: undefined }); + } + const value = seq[i]; + i += 1; + // The undefined slot models a reader yielding {done:false,value:undefined}. + return Promise.resolve({ + done: false, + value: value as Uint8Array, + }); + }, + releaseLock(): void {}, + }; + return { + getReader() { + return reader; + }, + } as unknown as ReadableStream<Uint8Array>; + }, + } as unknown as File; + const t = mounted(dirForRead("rom.bin", file)); + + const progress: number[] = []; + const out = await t.readFile("rom.bin", (read) => progress.push(read)); + + expectBytesEqual(out, data); + // Only the two real chunks produced ticks; the undefined slot was skipped. + expect(progress).toEqual([2, 4]); + }); + + it("releases the reader lock after streaming completes", async () => { + const data = bytes(1, 2, 3, 4); + let released = 0; + const file = { + size: data.length, + arrayBuffer(): Promise<ArrayBuffer> { + return Promise.reject(new Error("unused")); + }, + stream(): ReadableStream<Uint8Array> { + let done = false; + const reader = { + read(): Promise<ReadableStreamReadResult<Uint8Array>> { + if (done) return Promise.resolve({ done: true, value: undefined }); + done = true; + return Promise.resolve({ done: false, value: data }); + }, + releaseLock(): void { + released += 1; + }, + }; + return { + getReader() { + return reader; + }, + } as unknown as ReadableStream<Uint8Array>; + }, + } as unknown as File; + const t = mounted(dirForRead("rom.bin", file)); + + await t.readFile("rom.bin", () => {}); + expect(released).toBe(1); + }); +}); + +// ── listFiles & hasFile ───────────────────────────────────────────────────────── + +describe("DirectoryTransport.listFiles", () => { + it("returns only kind==='file' entries, skipping directories", async () => { + const entries = [ + { kind: "file", name: "rom.bin" }, + { kind: "directory", name: "subdir" }, + { kind: "file", name: "RETRODE.CFG" }, + { kind: "directory", name: "System Volume Information" }, + { kind: "file", name: "save.srm" }, + ]; + const dir = { + name: "vol", + values() { + // An async iterator over the entries. + let i = 0; + return { + [Symbol.asyncIterator]() { + return this; + }, + next(): Promise<IteratorResult<unknown>> { + if (i >= entries.length) { + return Promise.resolve({ done: true, value: undefined }); + } + const value = entries[i]; + i += 1; + return Promise.resolve({ done: false, value }); + }, + }; + }, + } as unknown as FileSystemDirectoryHandle; + const t = mounted(dir); + + const names = await t.listFiles(); + expect(names).toEqual(["rom.bin", "RETRODE.CFG", "save.srm"]); + }); +}); + +describe("DirectoryTransport.hasFile", () => { + it("returns true when getFileHandle resolves", async () => { + const dir = { + name: "vol", + getFileHandle(): Promise<FileSystemFileHandle> { + return Promise.resolve({} as FileSystemFileHandle); + }, + } as unknown as FileSystemDirectoryHandle; + const t = mounted(dir); + + expect(await t.hasFile("rom.bin")).toBe(true); + }); + + it("returns false (does not throw) when getFileHandle rejects", async () => { + const dir = { + name: "vol", + getFileHandle(name: string): Promise<FileSystemFileHandle> { + return Promise.reject(new Error(`NotFoundError: ${name}`)); + }, + } as unknown as FileSystemDirectoryHandle; + const t = mounted(dir); + + await expect(t.hasFile("missing.bin")).resolves.toBe(false); + }); +}); + +// ── directory guard ───────────────────────────────────────────────────────────── + +describe("DirectoryTransport — directory guard", () => { + it("throws when a file helper is used before a directory is mounted", async () => { + const t = new DirectoryTransport(); + expect(t.connected).toBe(false); + await expect(t.listFiles()).rejects.toThrow(/No directory mounted/); + }); +}); + +// ── friendly errors when getFileHandle fails (cart/volume pulled, perm lost) ── + +/** A directory whose getFileHandle always rejects with a DOMException-like + * error carrying the given `.name`. */ +function dirThatRejects(errName: string): FileSystemDirectoryHandle { + const dir = { + name: "vol", + getFileHandle(): Promise<FileSystemFileHandle> { + return Promise.reject(Object.assign(new Error("raw boom"), { name: errName })); + }, + }; + return dir as unknown as FileSystemDirectoryHandle; +} + +describe("DirectoryTransport — friendly getFileHandle errors", () => { + it("maps NotFoundError (file gone) to a clear message", async () => { + const t = mounted(dirThatRejects("NotFoundError")); + await expect(t.readFile("Game.sfc")).rejects.toThrow( + /no longer on the device/, + ); + }); + + it("maps NotAllowedError (permission lost) on the write path", async () => { + const t = mounted(dirThatRejects("NotAllowedError")); + await expect(t.writeFile("RETRODE.CFG", bytes(1, 2))).rejects.toThrow( + /denied/, + ); + }); + + it("passes an unrecognized error through unchanged", async () => { + const t = mounted(dirThatRejects("WeirdError")); + await expect(t.readText("x")).rejects.toThrow(/raw boom/); + }); +}); diff --git a/src/lib/transport/directory-transport.ts b/src/lib/transport/directory-transport.ts new file mode 100644 index 0000000..f37aebc --- /dev/null +++ b/src/lib/transport/directory-transport.ts @@ -0,0 +1,212 @@ +import type { + Transport, + TransportType, + TransportConnectOptions, + TransportEvents, + DeviceIdentity, +} from "@/lib/types"; + +/** + * Transport for "filesystem" devices like the Retrode 2, which present + * themselves to the host as a USB mass-storage volume rather than a streamable + * device. The user grants access to the mounted directory via the File System + * Access API; the driver then reads/writes files directly (the synthesized ROM + * file, RETRODE.CFG, the .srm save) instead of streaming bytes. + * + * The byte-stream surface every other transport exposes — send()/receive() — + * is intentionally unsupported here; the driver calls the file helpers below. + * + * Note: the File System Access API cannot re-authorize a directory handle + * across page reloads (unlike Web Serial's getPorts()), so a filesystem device + * is always re-picked each session — there is no silent auto-reconnect. + */ +export class DirectoryTransport implements Transport { + readonly type: TransportType = "directory"; + private dir: FileSystemDirectoryHandle | null = null; + private readonly events: Partial<TransportEvents> = {}; + + get connected(): boolean { + return this.dir !== null; + } + + /** The mounted directory handle. Throws if nothing is mounted. */ + get directory(): FileSystemDirectoryHandle { + if (!this.dir) throw new Error("No directory mounted"); + return this.dir; + } + + async connect(_options?: TransportConnectOptions): Promise<DeviceIdentity> { + if (!("showDirectoryPicker" in window)) { + throw new Error( + "This browser does not support the File System Access API. Use a " + + "Chromium-based browser to mount the Retrode 2's directory.", + ); + } + const dir = await window.showDirectoryPicker({ + id: "retrode", + mode: "readwrite", + }); + // showDirectoryPicker(mode:"readwrite") bundles the write grant, but the + // user can still downgrade to read-only. Confirm now so a read-only mount + // fails here with a clear message instead of deep inside the first write + // (a failed config/save write is otherwise a late, cryptic surprise). + const perm = await dir.requestPermission?.({ mode: "readwrite" }); + if (perm && perm !== "granted") { + throw new Error( + "Read-write access to the Retrode volume wasn't granted. Re-mount the " + + "directory and allow editing so configuration and save writes can be saved.", + ); + } + return this.adopt(dir); + } + + /** + * getFileHandle with friendly errors for the common post-mount failures — + * the cartridge/volume pulled mid-session (NotFoundError) or permission + * revoked (NotAllowedError) — instead of a raw DOMException reaching the UI. + */ + private async fileHandle( + name: string, + options?: { create?: boolean }, + ): Promise<FileSystemFileHandle> { + try { + return await this.directory.getFileHandle(name, options); + } catch (err) { + const e = err as DOMException; + if (e?.name === "NotFoundError") { + throw new Error( + `"${name}" is no longer on the device — was the cartridge or volume removed?`, + ); + } + if (e?.name === "NotAllowedError") { + throw new Error( + `Access to "${name}" was denied — re-mount the device with read-write permission.`, + ); + } + throw err; + } + } + + /** Adopt an already-granted directory handle. */ + adopt(dir: FileSystemDirectoryHandle): DeviceIdentity { + this.dir = dir; + return { name: dir.name, transport: "directory", raw: dir }; + } + + async disconnect(): Promise<void> { + this.dir = null; + } + + closeNow(): void { + this.dir = null; + } + + // ── File helpers (used by the driver instead of send/receive) ───────────── + + /** Names of all regular files in the mounted directory. */ + async listFiles(): Promise<string[]> { + const names: string[] = []; + for await (const entry of this.directory.values()) { + if (entry.kind === "file") names.push(entry.name); + } + return names; + } + + async hasFile(name: string): Promise<boolean> { + try { + await this.directory.getFileHandle(name); + return true; + } catch { + return false; + } + } + + async readFile( + name: string, + onProgress?: (read: number, total: number) => void, + ): Promise<Uint8Array> { + const handle = await this.fileHandle(name); + const file = await handle.getFile(); + // Without a progress callback (or stream support), read in one shot. + if (!onProgress || typeof file.stream !== "function") { + return new Uint8Array(await file.arrayBuffer()); + } + // The mounted volume reads at cartridge-bus speed, so stream the file and + // report progress per chunk rather than blocking on one big read. + const total = file.size; + const reader = file.stream().getReader(); + const chunks: Uint8Array[] = []; + let read = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + chunks.push(value); + read += value.length; + onProgress(read, total); + } + } finally { + reader.releaseLock(); + } + const out = new Uint8Array(read); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; + } + + async readText(name: string): Promise<string> { + const handle = await this.fileHandle(name); + const file = await handle.getFile(); + return file.text(); + } + + async writeFile(name: string, data: Uint8Array | string): Promise<void> { + const handle = await this.fileHandle(name, { create: true }); + const writable = await handle.createWritable(); + try { + await writable.write(data); + await writable.close(); + } catch (err) { + // A failed/interrupted write must not leave a half-written file or a + // leaked stream lock: abort() discards the staged changes (the original + // is preserved) and releases the lock. + await writable.abort().catch(() => {}); + // QuotaExceededError here is NOT a real out-of-space: createWritable() + // writes via a temp .crswap file + atomic rename, and a read-mostly + // synthesized FAT (e.g. the Retrode's) has no free clusters for that swap. + // An in-place overwrite would work, but the File System Access API offers + // no swap-less write for a picked directory — so this volume is read-only + // through the browser. Say so, rather than implying a retry will help. + if ((err as DOMException)?.name === "QuotaExceededError") { + throw new Error( + `Can't write ${name}: this volume is read-only through the browser. ` + + "The File System Access API can't create the temporary file it needs " + + "on the device's synthesized filesystem. Edit the file with your " + + "computer's file manager / a text editor instead.", + ); + } + throw new Error(`Failed to write ${name}: ${(err as Error).message}`); + } + } + + // ── Byte-stream surface (unsupported for filesystem devices) ────────────── + + async send(): Promise<void> { + throw new Error("DirectoryTransport does not support streaming I/O"); + } + + async receive(): Promise<Uint8Array> { + throw new Error("DirectoryTransport does not support streaming I/O"); + } + + on<K extends keyof TransportEvents>( + event: K, + handler: TransportEvents[K], + ): void { + this.events[event] = handler; + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index bffe00f..d88b252 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,6 +1,12 @@ // Single import point for consumers: import type { ... } from "@/lib/types" -export type TransportType = "webusb" | "webhid" | "serial" | "nfc" | "http"; +export type TransportType = + | "webusb" + | "webhid" + | "serial" + | "nfc" + | "http" + | "directory"; export interface TransportEvents { onDisconnect?: () => void; @@ -237,6 +243,21 @@ export interface SystemHandler { */ content?: Uint8Array, ): VerificationResult | Promise<VerificationResult>; + /** + * Optional: the game's title parsed from the ROM's INTERNAL header — e.g. + * SNES title at $7FC0, N64 image name at 0x20, Game Boy title at 0x134, + * GBA title at 0xA0, Genesis domestic/overseas name field. Returns a value + * already trimmed and reduced to printable characters, safe to use directly + * as a filename stem (the file-save layer still strips reserved characters). + * Returns undefined when the system carries no title in its header + * (e.g. SMS / Game Gear) or when the bytes don't parse to a usable title. + * + * This is the authoritative, DEVICE-INDEPENDENT title source for naming an + * unverified dump: it is derived purely from the dumped bytes and must NOT + * be sourced from any driver- or device-supplied filename (e.g. the + * Retrode 2 firmware's synthesized volume filename). + */ + headerTitle?(content: Uint8Array): string | undefined; /** Optional: extract a human-readable summary of a completed dump's contents. */ summarizeDump?(rawData: Uint8Array): DumpSummary | null; /** @@ -274,6 +295,15 @@ export interface SystemHandler { * event log, not as a blocking error. */ analyzeDump?(content: Uint8Array, config: ReadConfig): string[]; + /** + * Optional: given a (possibly over-dumped) ROM, return a smaller real size + * the dump can be trimmed to — a best-effort heuristic (internal checksum / + * header). Offered to the user on the completion screen for UNVERIFIED dumps; + * null when the bytes don't look over-dumped. Authoritative, No-Intro-confirmed + * trimming happens in the dump pipeline ({@link DumpJob}); this is the + * unverified fallback the user opts into per dump. + */ + suggestTrim?(content: Uint8Array): { size: number; note: string } | null; } /** A single cell in a {@link DumpSummary} row — text or a small bitmap (e.g. PS1 save icon). */ @@ -403,6 +433,14 @@ export interface VerificationDB { export interface VerificationEntry { name: string; + /** + * The DAT's canonical ROM filename including its extension, e.g. + * "Some Game (USA, Europe).md". Lets a verified dump be named with + * No-Intro's own extension — which is authoritative for these exact bytes — + * rather than the (possibly mislabeled) detected system's. A cross-system + * match (e.g. another system's cart read as Genesis) thus gets the right extension. + */ + romName?: string; region?: string; languages?: string[]; status: "verified" | "alt" | "bad" | "unknown"; @@ -427,6 +465,14 @@ export interface VerificationResult { entry?: VerificationEntry; confidence: "exact" | "size_match" | "none"; suggestions?: string[]; + /** + * Whether a verification database was actually consulted for this dump. + * `false` means no DAT is loaded for this system, so the result is "not + * checked" rather than a genuine mismatch — the UI shows a neutral verdict + * instead of the unverified warning. Absent/true means a DB was present + * (a match, or a real no-match). Stamped centrally in the dump pipeline. + */ + databaseLoaded?: boolean; } // ─── DumpJob ──────────────────────────────────────────────────────────────── @@ -462,4 +508,17 @@ export interface DumpResult { verification: VerificationResult; cartInfo?: CartridgeInfo; durationMs: number; + /** + * Present when the dump is an unverified over-dump and the system suggested a + * smaller real size. Carries the pre-computed trimmed variant so the + * completion screen can offer it as an instant toggle — the user decides; it + * is never applied silently. + */ + trimSuggestion?: { + size: number; + note: string; + rom: OutputFile; + hashes: VerificationHashes; + verification: VerificationResult; + }; } diff --git a/src/web-apis.d.ts b/src/web-apis.d.ts index d44357e..d1fbfec 100644 --- a/src/web-apis.d.ts +++ b/src/web-apis.d.ts @@ -192,14 +192,34 @@ interface NDEFReader { ): void; } -interface FileSystemFileHandle { - createWritable(): Promise<FileSystemWritableFileStream>; - getFile(): Promise<File>; +// The File System Access handle types (FileSystemHandle / *FileHandle / +// *DirectoryHandle / *WritableFileStream) ship in lib.dom, so we don't redeclare +// them. We augment only the bits TS doesn't yet provide: the directory picker, +// and the non-standard permission query/request methods used to confirm +// read-write access before relying on writes. +interface FileSystemHandle { + queryPermission?(descriptor?: { + mode?: "read" | "readwrite"; + }): Promise<PermissionState>; + requestPermission?(descriptor?: { + mode?: "read" | "readwrite"; + }): Promise<PermissionState>; +} + +// lib.dom types write()'s chunk as ArrayBufferView<ArrayBuffer>, which a plain +// Uint8Array<ArrayBufferLike> doesn't satisfy. Widen it (as an extra overload) +// to the runtime-accepted shapes so callers can pass a Uint8Array directly +// without a per-call cast. `ArrayBufferView` here defaults to ArrayBufferLike. +interface FileSystemWritableFileStream { + write(data: ArrayBuffer | ArrayBufferView | Blob | string): Promise<void>; } -interface FileSystemWritableFileStream extends WritableStream { - write(data: ArrayBuffer | ArrayBufferView | Blob | string): Promise<void>; - close(): Promise<void>; +interface Window { + showDirectoryPicker(options?: { + id?: string; + mode?: "read" | "readwrite"; + startIn?: string | FileSystemHandle; + }): Promise<FileSystemDirectoryHandle>; } // ─── WebHID API ──────────────────────────────────────────────────────────────