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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 154 additions & 28 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(),
];

Expand All @@ -60,6 +78,33 @@ const ACTIVE_STATES: ReadonlySet<DumpJobState> = new Set<DumpJobState>([
]);
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<DeviceUiKind> = new Set([
"amiibo",
"infinity",
"nds",
"retrode",
]);

/** Merge config field defaults with pre-filled values from auto-detection. */
function seedConfigDefaults(
system: SystemHandler,
Expand Down Expand Up @@ -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<ConfigValues>({});
// 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<CartridgeInfo | null>(null);
const [detecting, setDetecting] = useState(false);
const [unsupportedDetection, setUnsupportedDetection] = useState<{
Expand Down Expand Up @@ -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],
);
Expand All @@ -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,
Expand All @@ -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;
Expand All @@ -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 ||
Expand All @@ -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
Expand All @@ -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) <CRC32>" 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.
Expand All @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -436,6 +533,7 @@ function App() {
* natural. */
const handleScan = useCallback(() => {
dumpJob.reset();
setUseTrimmed(false);
setAutoDetected(null);
setUnsupportedDetection(null);
if (connection.driver) autoDetectSystem(connection.driver);
Expand Down Expand Up @@ -487,28 +585,39 @@ function App() {
error={connection.connectError}
availableDevices={connection.availableDevices}
/>
) : isAmiiboDevice ? (
) : uiKind === "amiibo" ? (
<AmiiboScanner
driver={connection.driver!}
deviceInfo={connection.deviceInfo}
onDisconnect={handleDisconnect}
log={log}
/>
) : isInfinityDevice ? (
) : uiKind === "infinity" ? (
<InfinityScanner
driver={connection.driver! as InfinityDriver}
deviceInfo={connection.deviceInfo}
onDisconnect={handleDisconnect}
log={log}
/>
) : isNDSSaveDevice ? (
) : uiKind === "nds" ? (
<NDSScanner
driver={connection.driver! as NDSDeviceDriver}
deviceInfo={connection.deviceInfo}
onDisconnect={handleDisconnect}
log={log}
nointroDb={nointro.getDb("nds_save")}
/>
) : uiKind === "retrode" ? (
<Retrode2Panel
driver={connection.driver! as Retrode2Driver}
deviceInfo={connection.deviceInfo}
dumps={retrode.dumps}
scanning={retrode.scanning}
hiddenCount={retrode.hiddenCount}
onReload={retrode.onReload}
onDisconnect={handleDisconnect}
log={log}
/>
) : (
<div className="flex flex-col gap-6">
{/* Persistent device header — Disconnect (and Scan, on
Expand Down Expand Up @@ -610,6 +719,10 @@ function App() {
// chain (verified name → title → "(Unverified) <CRC32>"),
// 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}
Expand All @@ -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}
/>
)}
Expand Down
Loading