Import cartridge .sav files with verified structure, checksums and clock (Closes #44)

Savegames → Import backup now reads an English Crystal .sav (from an
emulator, a cartridge dump or the 3DS Virtual Console's sav.dat) and
converts it into a native save after a preview.

- world/cartridge-save.ts: picks the copy TryLoadSaveFile would load
  (check values and checksums of the main copy, else the backup), reads
  the saved WRAM block, the numbered boxes, mail backups, Hall of Fame and
  Battle Tower by PRET labels (SRAM, SAVE_BLOCK in the profile, checked by
  check:reference with the SAV constants), engine flags via EngineFlags,
  scenes via MapScenes, and the map's objects and masks.
- Clock: an emulator MBC3 footer plus the time since it was written,
  FixDays/FixTime with wStart*, mapped to the minute and weekday offsets;
  without one, the device's time and the saved wCurDay. Day countdowns
  move with the day difference.
- Refused with the reason: unported maps, the Bug-Catching Contest, a
  Battle Tower challenge, characters outside the naming screen.
- Names keep <PK>/<MN> as ᴾᴷ/ᴹᴺ (one character, one tile); trainer names
  accept the naming screen's alphabet; boxed Eggs validate; native states
  up to 256 kB load (a full PC exceeded 64 kB).
- Tests: the committed debug saves, layout offsets against the symbols,
  backup fallback, clock footers, day counters, refusals, slot session
  and the Savegames flow in Chromium and WebKit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-17 12:03:01 +02:00
1 parent 87b05b45f4
commit 592ca43a40
20 files changed
+1821 -40

No files matched your search

+45 -15
View File
@@ -43,6 +43,7 @@ import {
validMailMessage,
} from "./game/party/mail";
import { SlotSession, type ImportPreview, type SlotEntry } from "./app/slot-session";
import { isCartridgeSaveSize } from "./game/world/cartridge-save";
import { InputController } from "./input/controller";
import { bindControls } from "./input/bindings";
import { validateRom } from "./rom/validate";
@@ -2142,10 +2143,18 @@ get<HTMLInputElement>("save-file").addEventListener("change", async (event) => {
if (!file || busy || !ready()) return;
let preview: ImportPreview;
try {
preview =
file.size > 2_000_000
? { ok: false, reason: "This backup is too large." }
: await session.previewImport(await file.text());
if (file.size > 2_000_000)
preview = { ok: false, reason: "This backup is too large." };
else {
const bytes = new Uint8Array(await file.arrayBuffer());
// Native backups are JSON; anything else the size of a cartridge
// save, or named like one, is read as a cartridge save.
preview =
bytes[0] !== 0x7b &&
(isCartridgeSaveSize(bytes.length) || /\.(?:sav|srm|dat)$/i.test(file.name))
? await session.previewCartridge(bytes, new Date(file.lastModified))
: await session.previewImport(new TextDecoder().decode(bytes));
}
} catch (error) {
showError(error);
return;
@@ -2184,19 +2193,20 @@ function previewImport(
actions.append(node);
};
if (!preview.ok) {
get("import-title").textContent = "This backup cannot be imported";
get("import-title").textContent = "This file cannot be imported";
get("import-text").textContent = `${preview.reason} Your saves are unchanged.`;
row("File", file);
if (typeof preview.identity?.savedAt === "string")
row("Saved", savedTime(preview.identity.savedAt));
button("cancel", "Close", true);
} else {
const { summary } = preview;
const { summary, cartridge } = preview;
get("import-title").textContent = summary.newGame
? "A new game"
: `${summary.player}'s game`;
get("import-text").textContent =
"Check the backup before importing it. Importing continues it in place of the running game, which is saved first.";
get("import-text").textContent = cartridge
? "Check the cartridge save before importing it. Importing converts it into a save of this port and continues it in place of the running game, which is saved first. The file itself is not changed."
: "Check the backup before importing it. Importing continues it in place of the running game, which is saved first.";
row("File", file);
row(
"Trainer",
@@ -2206,13 +2216,33 @@ function previewImport(
);
row("Place", summary.newGame ? "Introduction" : summary.place);
for (const line of summaryLines(summary).slice(1)) row("Progress", line);
row("Saved", savedTime(preview.record.savedAt));
row(
"Format",
preview.schema < 3
? `Native save, game data version ${preview.schema}: upgraded to version 3 on import`
: `Native save, game data version ${preview.schema}`,
);
row(cartridge ? "File date" : "Saved", savedTime(preview.record.savedAt));
if (cartridge) {
row(
"Format",
cartridge.copy === "main"
? "Pokémon Crystal cartridge save"
: "Pokémon Crystal cartridge save, from its backup copy (the main copy is damaged)",
);
row(
"Clock",
cartridge.clock === "rtc"
? "The cartridge clock, carried forward to now"
: "This device's time; the day continues from the save",
);
} else
row(
"Format",
preview.schema < 3
? `Native save, game data version ${preview.schema}: upgraded to version 3 on import`
: `Native save, game data version ${preview.schema}`,
);
if (cartridge?.notes.length) {
conflicts.append(element("h3", "", "Conversion notes"));
const list = element("ul", "");
for (const note of cartridge.notes) list.append(element("li", "", note));
conflicts.append(list);
}
if (preview.conflicts.length) {
conflicts.append(element("h3", "", "Saves of the same trainer"));
const list = element("ul", "");