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 and 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(); 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", ); }, ); });