Files
Crystal/tests/cartridge-save.test.ts
T
kawaandClaude Opus 5.5 592ca43a40 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>
2026-10-17 12:03:01 +02:00

440 lines
16 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { textTiles } from "../src/game/rom/font";
import {
ENGINE_FLAGS,
SAVE_BLOCK,
SRAM,
SYMBOLS,
} from "../src/game/rom/profile";
import { TRAINER_NAME_PATTERN, NICKNAME_PATTERN } from "../src/game/party/pokemon";
import { validStorage, emptyStorage, type BoxMon } from "../src/game/party/storage";
import {
CartridgeSaveError,
LAYOUT,
SAV,
convertCartridgeSave,
isCartridgeSaveSize,
} from "../src/game/world/cartridge-save";
import { checkCountdown, rtcDay } from "../src/game/world/day-timers";
import { GameWorld, type NativeSave } from "../src/game/world/world";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const symbolsPath = ".reference/pokecrystal11.sym";
const saves = "debug-saves/Pokémon - Crystal Version";
const cartridgePath = `${saves}/Pokémon - Crystal Version/Pokémon - Crystal Version.sav`;
const virtualConsolePath = `${saves}/Pokémon - Crystal Version [VC]/sav.dat`;
describe("cartridge save names and sizes", () => {
it("accepts the sizes of a 32 KiB save and its emulator footers only", () => {
for (const size of [0x8000, 0x8000 + 16, 0x8000 + 44, 0x8000 + 48])
expect(isCartridgeSaveSize(size)).toBe(true);
for (const size of [0, 0x2000, 0x8000 - 1, 0x8000 + 1, 0x10000])
expect(isCartridgeSaveSize(size)).toBe(false);
});
it("keeps <PK> and <MN> as one character of a name and one tile", () => {
expect(TRAINER_NAME_PATTERN.test("Mattiaᴾᴷ")).toBe(true);
expect(TRAINER_NAME_PATTERN.test("MATTIAᴾᴷ")).toBe(true);
expect(TRAINER_NAME_PATTERN.test("MATTIAᴾᴷᴹᴺ")).toBe(false);
expect(TRAINER_NAME_PATTERN.test("ᴾ")).toBe(false);
expect(NICKNAME_PATTERN.test("ᴾᴷᴹᴺ×(♀)")).toBe(true);
expect(textTiles("Aᴾᴷᴹᴺ")).toEqual([0x80, 0xe1, 0xe2]);
});
it("stores an Egg in a box with its egg mark", () => {
const egg = {
species: 175,
item: 0,
moves: [1, 0, 0, 0],
otId: 1,
ot: "KRIS",
nickname: "EGG",
exp: 125,
statExp: [0, 0, 0, 0, 0],
dvs: 0,
pp: [35, 0, 0, 0],
happiness: 10,
pokerus: 0,
caughtData: 0,
level: 5,
egg: true,
} as BoxMon;
const storage = emptyStorage();
storage.boxes[0].push(egg);
expect(validStorage(storage)).toBe(true);
storage.boxes[0][0] = { ...egg, egg: false as never };
expect(validStorage(storage)).toBe(false);
});
});
describe.skipIf(!existsSync(symbolsPath))("cartridge save layout", () => {
const symbols = new Map<string, [number, number]>();
if (existsSync(symbolsPath))
for (const line of readFileSync(symbolsPath, "utf8").split("\n")) {
const match = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line);
if (match)
symbols.set(match[3], [parseInt(match[1], 16), parseInt(match[2], 16)]);
}
const at = (label: string) => {
const symbol = symbols.get(label);
if (!symbol) throw new Error(`No symbol ${label}`);
return symbol[1];
};
const offset = (field: string, struct: string) => at(field) - at(struct);
it("matches the struct layouts of the pinned build", () => {
expect(offset("wPartyMon1BoxEnd", "wPartyMon1")).toBe(LAYOUT.boxMon);
expect(offset("wPartyMon2", "wPartyMon1")).toBe(LAYOUT.partyMon);
for (const [field, value] of [
["Item", 1],
["Moves", 2],
["ID", 6],
["Exp", 8],
["HPExp", 11],
["DVs", 21],
["PP", 23],
["Happiness", 27],
["PokerusStatus", 28],
["CaughtData", 29],
["Level", 31],
["Status", 32],
["HP", 34],
["MaxHP", 36],
["SpclDef", 46],
] as const)
expect(offset(`wPartyMon1${field}`, "wPartyMon1"), field).toBe(value);
expect(offset("sBox1Mons", "sBox1")).toBe(1 + SAV.MONS_PER_BOX + 1);
expect(offset("sBox1MonOTs", "sBox1")).toBe(
1 + SAV.MONS_PER_BOX + 1 + SAV.MONS_PER_BOX * LAYOUT.boxMon,
);
expect(offset("sBox1MonNicknames", "sBox1MonOTs")).toBe(
SAV.MONS_PER_BOX * SAV.NAME_LENGTH,
);
expect(offset("sBox2", "sBox1")).toBe(LAYOUT.box);
expect(offset("sHallOfFame2", "sHallOfFame1")).toBe(LAYOUT.hallOfFame);
expect(offset("sHallOfFame1Mon1", "sHallOfFame1")).toBe(1);
expect(offset("sHallOfFame1Mon2", "sHallOfFame1Mon1")).toBe(
LAYOUT.hallOfFameMon,
);
expect(offset("sHallOfFame1Mon1ID", "sHallOfFame1Mon1")).toBe(1);
expect(offset("sHallOfFame1Mon1DVs", "sHallOfFame1Mon1")).toBe(3);
expect(offset("sHallOfFame1Mon1Level", "sHallOfFame1Mon1")).toBe(5);
expect(offset("sHallOfFame1Mon1Nickname", "sHallOfFame1Mon1")).toBe(6);
expect(offset("sHallOfFameEnd", "sHallOfFame")).toBe(
LAYOUT.hallOfFame * LAYOUT.hallOfFameTeams,
);
expect(offset("wMap2Object", "wMap1Object")).toBe(LAYOUT.mapObject);
expect(offset("wMap1ObjectStructID", "wMap1Object")).toBe(
LAYOUT.mapObjectStruct,
);
expect(offset("wMap1ObjectYCoord", "wMap1Object")).toBe(LAYOUT.mapObjectY);
expect(offset("wMap1ObjectXCoord", "wMap1Object")).toBe(LAYOUT.mapObjectX);
expect(offset("wMap1Object", "wMapObjects")).toBe(LAYOUT.mapObject);
expect(offset("wObject1Struct", "wPlayerStruct")).toBe(LAYOUT.object);
expect(offset("wObject1Direction", "wObject1Struct")).toBe(
LAYOUT.objectDirection,
);
expect(offset("wObject1MapX", "wObject1Struct")).toBe(LAYOUT.objectMapX);
expect(offset("wObject1MapY", "wObject1Struct")).toBe(LAYOUT.objectMapY);
expect(offset("wRoamMon2", "wRoamMon1")).toBe(LAYOUT.roamMon);
expect(offset("sPartyMon2Mail", "sPartyMon1Mail")).toBe(
SAV.MAIL_STRUCT_LENGTH,
);
expect(offset("sPartyMon1MailLineBreak", "sPartyMon1Mail")).toBe(
SAV.MAIL_LINE_LENGTH,
);
expect(offset("sPartyMon1MailAuthor", "sPartyMon1Mail")).toBe(
2 * SAV.MAIL_LINE_LENGTH + 1,
);
expect(offset("sPartyMon1MailAuthorID", "sPartyMon1MailAuthor")).toBe(
SAV.PLAYER_NAME_LENGTH + 2,
);
expect(offset("sPartyMon1MailSpecies", "sPartyMon1MailAuthorID")).toBe(2);
expect(offset("sPartyMon1MailType", "sPartyMon1MailAuthorID")).toBe(3);
// The saved block is one copy of three adjacent WRAM blocks.
expect(at("wPlayerDataEnd")).toBe(at("wCurMapData"));
expect(at("wCurMapDataEnd")).toBe(at("wPokemonData"));
expect(SRAM.sGameDataEnd - SRAM.sGameData).toBe(
SAVE_BLOCK.wPokemonDataEnd - SAVE_BLOCK.wPlayerData,
);
expect(offset("wTextboxFrame", "wOptions")).toBe(2);
expect(at("wRoamMons_CurMapGroup")).toBe(SAVE_BLOCK.wRoamMons_CurMapNumber + 1);
expect(at("wBestMagikarpLengthInches")).toBe(
SAVE_BLOCK.wBestMagikarpLengthFeet + 1,
);
});
});
describe.skipIf(!existsSync(romPath))("cartridge saves with the local ROM", () => {
const assets = existsSync(romPath)
? new CrystalAssets(new Uint8Array(readFileSync(romPath)))
: undefined!;
const cartridge = () => new Uint8Array(readFileSync(cartridgePath));
const now = new Date(2026, 9, 17, 12, 30, 0);
const world = (save: NativeSave) => {
const game = new GameWorld(assets, () => now);
game.restore(save);
return game;
};
/** A byte of the main copy of the saved block. */
const main = (address: number) =>
SRAM.sGameData + address - SAVE_BLOCK.wPlayerData;
/** SaveChecksum over the main copy. */
const sign = (file: Uint8Array) => {
let sum = 0;
for (let p = SRAM.sGameData; p < SRAM.sGameDataEnd; p++)
sum = (sum + file[p]) & 0xffff;
file[SRAM.sChecksum] = sum & 0xff;
file[SRAM.sChecksum + 1] = sum >> 8;
return file;
};
/** The port's day number, as GameWorld computes it. */
const nativeDay = (save: NativeSave) => {
const game = new Date(now.getTime() + save.clock.minuteOffset * 60_000);
return (
Math.floor(
Date.UTC(game.getFullYear(), game.getMonth(), game.getDate()) /
86_400_000,
) + save.clock.weekdayOffset
);
};
/** An MBC3 footer as VBA-M, mGBA and BGB write it. */
const footer = (
registers: [number, number, number, number, number],
timestamp: number,
length = 48,
) => {
const bytes = new Uint8Array(length);
const view = new DataView(bytes.buffer);
registers.forEach((value, i) => {
view.setUint32(i * 4, value, true);
view.setUint32(20 + i * 4, value, true);
});
if (length === 48) view.setBigUint64(40, BigInt(timestamp), true);
else view.setUint32(40, timestamp, true);
return bytes;
};
const withFooter = (file: Uint8Array, extra: Uint8Array) => {
const joined = new Uint8Array(file.length + extra.length);
joined.set(file);
joined.set(extra, file.length);
return joined;
};
it.skipIf(!existsSync(cartridgePath))(
"converts the committed cartridge save into a game that loads",
() => {
const result = convertCartridgeSave(assets, cartridge(), now);
expect(result.copy).toBe("main");
expect(result.clock).toBe("device");
const { save } = result;
expect(save).toMatchObject({
map: "24:4",
x: 13,
y: 6,
name: "Mattiaᴾᴷ",
avatar: "chris",
playerId: 8872,
rivalName: "ARGENTO",
gameTime: { hours: 79, minutes: 32, seconds: 56, frames: 20 },
hallOfFame: true,
});
expect(save.party.map((mon) => [mon.species, mon.level])).toEqual([
[157, 100],
[164, 100],
[226, 100],
[248, 100],
[225, 100],
[210, 100],
]);
expect(save.party.every((mon) => mon.ot === "Mattiaᴾᴷ")).toBe(true);
expect(save.pokedex.caught).toHaveLength(251);
for (let bit = 0; bit < 16; bit++)
expect(save.flags).toContain(27 + bit);
expect(world(save).describe()).toEqual({
player: "Mattiaᴾᴷ",
playerId: 8872,
place: "NEW BARK TOWN",
hours: 79,
minutes: 32,
badges: 8,
caught: 251,
newGame: false,
});
// Its saved game is exactly what the port keeps after loading it,
// but for the roaming Pokémon, which Continue's JumpRoamMons moves
// as the cartridge's does.
const loaded = world(save).save();
expect(loaded).toEqual({
...save,
roamMons: loaded.roamMons,
roamMonsMap: loaded.roamMonsMap,
});
},
);
it.skipIf(!existsSync(virtualConsolePath))(
"reads the 3DS Virtual Console's sav.dat like the plain save",
() => {
const vc = convertCartridgeSave(
assets,
new Uint8Array(readFileSync(virtualConsolePath)),
now,
);
expect(vc.save).toEqual(convertCartridgeSave(assets, cartridge(), now).save);
expect(vc.clock).toBe("device");
},
);
it.skipIf(!existsSync(cartridgePath))(
"falls back to the backup copy and refuses two damaged copies",
() => {
const expected = convertCartridgeSave(assets, cartridge(), now).save;
const damaged = cartridge();
damaged[main(SAVE_BLOCK.wMoney)] ^= 1;
const backup = convertCartridgeSave(assets, damaged, now);
expect(backup.copy).toBe("backup");
expect(backup.save).toEqual(expected);
expect(backup.notes[0]).toContain("backup");
damaged[SRAM.sBackupGameData + 5] ^= 1;
expect(() => convertCartridgeSave(assets, damaged, now)).toThrow(
"Both copies of this save fail their checksums",
);
expect(() =>
convertCartridgeSave(assets, new Uint8Array(0x8000).fill(0xff), now),
).toThrow("holds no saved game");
expect(() =>
convertCartridgeSave(assets, new Uint8Array(0x4000), now),
).toThrow(CartridgeSaveError);
},
);
it.skipIf(!existsSync(cartridgePath))(
"carries the cartridge clock forward from an emulator footer",
() => {
const file = cartridge();
// wStartDay, wStartHour, wStartMinute and wStartSecond of this save.
expect([
...file.subarray(main(SAVE_BLOCK.wStartDay), main(SAVE_BLOCK.wStartDay) + 4),
]).toEqual([6, 23, 18, 10]);
const written = Math.floor(now.getTime() / 1000) - 3600;
// 3 days 1:41:50 an hour ago is 3 days 2:41:50 now; FixTime adds
// 6 days 23:18:10 with carries: day 10 (a Wednesday), 2:00:00.
for (const length of [48, 44]) {
const result = convertCartridgeSave(
assets,
withFooter(file, footer([50, 41, 1, 3, 0], written, length)),
now,
);
expect(result.clock).toBe("rtc");
expect(world(result.save).clock()).toEqual({
weekday: 3,
hours: 2,
minutes: 0,
});
}
// A halted clock does not move; FixDays takes 300 days modulo 140:
// day 20 + 6 = 26 (a Friday), 0:00:00 + 23:18:10.
const halted = convertCartridgeSave(
assets,
withFooter(file, footer([0, 0, 0, 44, 0x41], 0)),
now,
);
expect(world(halted.save).clock()).toEqual({
weekday: 5,
hours: 23,
minutes: 18,
});
// Without a footer, this device's time and the saved day (17, a
// Wednesday) are used.
const plain = convertCartridgeSave(assets, file, now);
expect(world(plain.save).clock()).toEqual({
weekday: 3,
hours: 12,
minutes: 30,
});
expect(() =>
convertCartridgeSave(
assets,
withFooter(file, footer([60, 0, 0, 0, 0], written)),
now,
),
).toThrow("clock data is damaged");
},
);
it.skipIf(!existsSync(cartridgePath))(
"moves day countdowns onto the port's day number",
() => {
const file = cartridge();
const today = file[main(SAVE_BLOCK.wCurDay)];
expect(today).toBe(17);
// The Lucky Number countdown started two days ago with 5 days.
file[main(SAVE_BLOCK.wLuckyNumberDayTimer)] = 5;
file[main(SAVE_BLOCK.wLuckyNumberDayTimer) + 1] = today - 2;
// The daily reset ran today.
file[main(SAVE_BLOCK.wDailyResetTimer)] = 1;
file[main(SAVE_BLOCK.wDailyResetTimer) + 1] = today;
// The lucky number was drawn today (wCurDay + 1).
file[SRAM.sLuckyNumberDay] = today + 1;
const { save } = convertCartridgeSave(assets, sign(file), now);
const day = nativeDay(save);
expect(
checkCountdown(save.timers.luckyNumber, day).timer.remaining,
).toBe(3);
expect(save.dailyReset).toBe(day);
expect(save.luckyNumber.day).toBe(rtcDay(day) + 1);
// A reset that last ran yesterday is due at the next check.
file[main(SAVE_BLOCK.wDailyResetTimer) + 1] = today - 1;
expect(convertCartridgeSave(assets, sign(file), now).save.dailyReset).toBe(
null,
);
},
);
it.skipIf(!existsSync(cartridgePath))(
"refuses saves the port cannot continue, with the reason",
() => {
const contest = cartridge();
const entry = SYMBOLS.engineFlags + ENGINE_FLAGS.bugContestTimer * 3;
contest[main(assets.reader.word(entry))] |= assets.reader.byte(entry + 2);
expect(() => convertCartridgeSave(assets, sign(contest), now)).toThrow(
"Bug-Catching Contest",
);
const tower = cartridge();
tower[SRAM.sBattleTowerChallengeState] = 2;
expect(() => convertCartridgeSave(assets, tower, now)).toThrow(
"Battle Tower challenge",
);
// Any decoded map the port has not opened.
const closed = cartridge();
let unported = "";
for (let group = 1; group <= 26 && !unported; group++)
for (let number = 1; number <= 3 && !unported; number++)
if (!assets.isPorted(`${group}:${number}`)) {
try {
assets.map(`${group}:${number}`);
unported = `${group}:${number}`;
} catch {
// Not a map.
}
}
const [group, number] = unported.split(":").map(Number);
closed[main(SAVE_BLOCK.wMapGroup)] = group;
closed[main(SAVE_BLOCK.wMapGroup) + 1] = number;
expect(() => convertCartridgeSave(assets, sign(closed), now)).toThrow(
"cannot open yet",
);
const glyph = cartridge();
glyph[main(SAVE_BLOCK.wBoxNames)] = 0xd0; // 'd, not on the naming screen
expect(() => convertCartridgeSave(assets, sign(glyph), now)).toThrow(
"Box 1's name uses a character the port cannot show yet",
);
},
);
});