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

+2 -1
View File
@@ -1,4 +1,5 @@
import type { PokemonData } from "../rom/pokemon";
import { TRAINER_NAME_PATTERN } from "./pokemon";
/** A mailmsg struct (macros/ram.asm): the message, the author's name and
* trainer ID, the species that held the MAIL when it was written, and the
@@ -61,7 +62,7 @@ export function validMail(data: PokemonData, value: unknown): value is Mail {
typeof mail === "object" &&
storedMailMessage(mail.message) &&
typeof mail.author === "string" &&
/^[A-Za-z0-9 -]{1,7}$/.test(mail.author) &&
TRAINER_NAME_PATTERN.test(mail.author) &&
integer(mail.authorId, 0, 0xffff) &&
integer(mail.species, 1, 251) &&
integer(mail.type, 1, 255) &&
+14 -5
View File
@@ -70,11 +70,20 @@ export function partyMonDetails(mon: PartyMon): string {
if (mon.egg) return "";
return `:L${mon.level} HP ${mon.hp}/${mon.stats[0]}${mon.hp ? statusName(mon.status) : " FNT"}`;
}
/** Characters a nickname can hold: the naming screen's letters and
* symbols, plus the digits and apostrophes of default species names. */
export const NICKNAME_PATTERN = /^[A-Za-z0-9 ()":;[\]?!♂♀/.,×'é-]{1,10}$/u;
/** The naming screen's <PK> and <MN> glyphs, each one character of a name,
* written as two small capitals. */
export const PK = "ᴾᴷ",
MN = "ᴹᴺ";
/** One character of a name: the naming screen's letters and symbols, plus
* the digits and apostrophes of default species names. */
const NAME_CHARACTER = `(?:[A-Za-z0-9 ()":;[\\]?!♂♀/.,×'é-]|${PK}|${MN})`;
/** Characters a nickname can hold (up to ten). */
export const NICKNAME_PATTERN = new RegExp(`^${NAME_CHARACTER}{1,10}$`, "u");
/** The same characters, for box names (up to eight). */
export const BOX_NAME_PATTERN = /^[A-Za-z0-9 ()":;[\]?!♂♀/.,×'é-]{1,8}$/u;
export const BOX_NAME_PATTERN = new RegExp(`^${NAME_CHARACTER}{1,8}$`, "u");
/** The same characters, for the player's and other trainers' names (up to
* seven, PLAYER_NAME_LENGTH less the terminator). */
export const TRAINER_NAME_PATTERN = new RegExp(`^${NAME_CHARACTER}{1,7}$`, "u");
/** BASE_HAPPINESS */
const BASE_HAPPINESS = 70;
const MAX_STAT_VALUE = 999;
@@ -245,7 +254,7 @@ export function validBoxFields(value: unknown): boolean {
tuple(mon.moves, 4, 251) &&
integer(mon.otId, 0, 0xffff) &&
typeof mon.ot === "string" &&
/^[A-Za-z0-9 -]{1,7}$/.test(mon.ot) &&
TRAINER_NAME_PATTERN.test(mon.ot) &&
typeof mon.nickname === "string" &&
NICKNAME_PATTERN.test(mon.nickname) &&
integer(mon.exp, 0, 0xffffff) &&
+4 -2
View File
@@ -16,7 +16,8 @@ export const MONS_PER_BOX = 20;
export const BOX_NAME_MAX = 8;
/** MAX_PC_ITEMS (constants/item_data_constants.asm). */
export const MAX_PC_ITEMS = 50;
/** The fields a stored BoxMon has, so no party fields slip into a box. */
/** The fields a stored BoxMon has, so no party fields slip into a box
* (an Egg also has its `egg` mark). */
const BOX_MON_FIELDS = 14;
/** box_struct: a party_struct without status, HP and stats. */
@@ -129,7 +130,8 @@ export function validStorage(value: unknown): value is PokemonStorage {
box.every(
(mon) =>
validBoxFields(mon) &&
Object.keys(mon as object).length === BOX_MON_FIELDS,
Object.keys(mon as object).length ===
BOX_MON_FIELDS + ((mon as BoxMon).egg ? 1 : 0),
),
)
);
+6 -2
View File
@@ -70,9 +70,13 @@ const CODES: Record<string, number> = {
"♀": 0xf5,
"…": ELLIPSIS_TILE,
};
/** Text as tile numbers, one character per tile. */
/** The naming screen's <PK> and <MN>, written as two small capitals in
* native names (see ../party/pokemon). */
const GLYPHS: Record<string, number> = { "ᴾᴷ": 0xe1, "ᴹᴺ": 0xe2 };
/** Text as tile numbers, one character (or <PK>/<MN> pair) per tile. */
export function textTiles(text: string): number[] {
return [...text].map((character) => {
return (text.match(/ᴾᴷ|ᴹᴺ|[\s\S]/gu) ?? []).map((character) => {
if (GLYPHS[character] !== undefined) return GLYPHS[character];
const code = character.charCodeAt(0);
if (character >= "A" && character <= "Z") return 0x80 + code - 65;
if (character >= "a" && character <= "z") return 0xa0 + code - 97;
+149
View File
@@ -1565,6 +1565,155 @@ export const WRAM = {
wBreedMon1Nickname: 0xdef6,
wBreedMon2Nickname: 0xdf2f,
} as const;
/** The work RAM of the saved game block (wPlayerData to wPokemonDataEnd,
* which sGameData copies byte for byte), plus wOptions and wCrystalData,
* keyed by PRET labels: what a cartridge save import reads. */
export const SAVE_BLOCK = {
wOptions: 0xcfcc,
wTextboxFrame: 0xcfce,
wGBPrinterBrightness: 0xcfd0,
wOptions2: 0xcfd1,
wCrystalData: 0xd472,
wPlayerGender: 0xd472,
wPlayerData: 0xd47b,
wPlayerID: 0xd47b,
wPlayerName: 0xd47d,
wRivalName: 0xd493,
wStartDay: 0xd4b6,
wDST: 0xd4c2,
wGameTimeCap: 0xd4c3,
wGameTimeHours: 0xd4c4,
wGameTimeMinutes: 0xd4c6,
wGameTimeSeconds: 0xd4c7,
wGameTimeFrames: 0xd4c8,
wCurDay: 0xd4cb,
wPlayerStruct: 0xd4d6,
wPlayerDirection: 0xd4de,
wPlayerMapX: 0xd4e6,
wObject1Struct: 0xd4fe,
wMapObjects: 0xd71e,
wObjectMasks: 0xd81e,
wVariableSprites: 0xd82e,
wStatusFlags: 0xd84c,
wMoney: 0xd84e,
wMomsMoney: 0xd851,
wCoins: 0xd855,
wTMsHMs: 0xd859,
wNumItems: 0xd892,
wNumKeyItems: 0xd8bc,
wNumBalls: 0xd8d7,
wNumPCItems: 0xd8f1,
wWhichRegisteredItem: 0xd95b,
wRegisteredItem: 0xd95c,
wPlayerState: 0xd95d,
wHallOfFameCount: 0xd95e,
wTradeFlags: 0xd960,
wEventFlags: 0xda72,
wCurBox: 0xdb72,
wBoxNames: 0xdb75,
wDecoBed: 0xdc0f,
wDecoCarpet: 0xdc10,
wDecoPlant: 0xdc11,
wDecoPoster: 0xdc12,
wDecoConsole: 0xdc13,
wDecoLeftOrnament: 0xdc14,
wDecoRightOrnament: 0xdc15,
wDecoBigDoll: 0xdc16,
wWhichMomItem: 0xdc17,
wMomItemTriggerBalance: 0xdc19,
wDailyResetTimer: 0xdc1c,
wFruitTreeFlags: 0xdc27,
wLuckyNumberDayTimer: 0xdc2d,
wSpecialPhoneCallID: 0xdc31,
wBlueCardBalance: 0xdc4b,
wStepCount: 0xdc73,
wPoisonStepCount: 0xdc74,
wHappinessStepCount: 0xdc77,
wPhoneList: 0xdc7c,
wRepelEffect: 0xdca1,
wBikeStep: 0xdca2,
wDigWarpNumber: 0xdca9,
wBackupWarpNumber: 0xdcac,
wLastSpawnMapGroup: 0xdcb2,
wMapGroup: 0xdcb5,
wYCoord: 0xdcb7,
wXCoord: 0xdcb8,
wPartyCount: 0xdcd7,
wPartySpecies: 0xdcd8,
wPartyMons: 0xdcdf,
wPartyMonOTs: 0xddff,
wPartyMonNicknames: 0xde41,
wPokedexCaught: 0xde99,
wPokedexSeen: 0xdeb9,
wUnownDex: 0xded9,
wFirstUnownSeen: 0xdef4,
wDayCareMan: 0xdef5,
wBreedMon1Nickname: 0xdef6,
wBreedMon1OT: 0xdf01,
wBreedMon1: 0xdf0c,
wDayCareLady: 0xdf2c,
wStepsToEgg: 0xdf2d,
wBreedMotherOrNonDitto: 0xdf2e,
wBreedMon2Nickname: 0xdf2f,
wBreedMon2OT: 0xdf3a,
wBreedMon2: 0xdf45,
wEggMonNickname: 0xdf65,
wEggMonOT: 0xdf70,
wEggMon: 0xdf7b,
wRoamMon1: 0xdfcf,
wRoamMons_CurMapNumber: 0xdfe4,
wBestMagikarpLengthFeet: 0xdfe8,
wMagikarpRecordHoldersName: 0xdfea,
wPokemonDataEnd: 0xdff5,
} as const;
/** Cartridge SRAM as offsets into a 32 KiB .sav file (bank * $2000 +
* address - $a000), keyed by PRET labels (ram/sram.asm). */
export const SRAM = {
sPartyMailBackup: 0x071a,
sMailboxCountBackup: 0x0a0b,
sMailboxesBackup: 0x0a0c,
sMysteryGiftItem: 0x0be2,
sBackupMysteryGiftItem: 0x0be4,
sRTCStatusFlags: 0x0c60,
sLuckyNumberDay: 0x0c68,
sLuckyIDNumber: 0x0c69,
sBackupOptions: 0x1200,
sBackupCheckValue1: 0x1208,
sBackupGameData: 0x1209,
sBackupChecksum: 0x1f0d,
sBackupCheckValue2: 0x1f0f,
sOptions: 0x2000,
sCheckValue1: 0x2008,
sGameData: 0x2009,
sGameDataEnd: 0x2b83,
sChecksum: 0x2d0d,
sCheckValue2: 0x2d0f,
sLinkBattleStats: 0x3260,
sLinkBattleStatsEnd: 0x32c0,
sHallOfFame: 0x32c0,
sCrystalData: 0x3e3d,
sBattleTowerChallengeState: 0x3e45,
sNrOfBeatenBattleTowerTrainers: 0x3e46,
sBTChoiceOfLevelGroup: 0x3e47,
sBTTrainers: 0x3e48,
sBattleTowerSaveFileFlags: 0x3e4f,
sBattleTowerReward: 0x3e50,
sBTMonOfTrainers: 0x3e51,
sBox1: 0x4000,
sBox2: 0x4450,
sBox3: 0x48a0,
sBox4: 0x4cf0,
sBox5: 0x5140,
sBox6: 0x5590,
sBox7: 0x59e0,
sBox8: 0x6000,
sBox9: 0x6450,
sBox10: 0x68a0,
sBox11: 0x6cf0,
sBox12: 0x7140,
sBox13: 0x7590,
sBox14: 0x79e0,
} as const;
export const MAP_GROUP_COUNT = 26;
export const NUM_POKEMON = 251;
+27 -2
View File
@@ -2,11 +2,16 @@ import type { GameRuntime } from "../app/runtime";
import type { Button } from "../input/types";
import { CrystalAssets } from "./rom/assets";
import { GameWorld, TICKS_PER_SECOND, realtimeChoice } from "./world/world";
import { CartridgeSaveError } from "./world/cartridge-save";
import type { BattleAction } from "./battle/battle";
import type { Direction } from "./world/types";
import { WorldRenderer } from "./rendering/renderer";
import { AudioOutput } from "./audio/output";
/** The largest native state accepted: a full PC (14 boxes of 20) with
* thirty Hall of Fame teams stays near 110 kB of JSON. */
const MAX_STATE = 256_000;
export class NativeRuntime implements GameRuntime {
readonly id = "crystal-native-world-v1";
world?: GameWorld;
@@ -58,7 +63,7 @@ export class NativeRuntime implements GameRuntime {
* throws when the game cannot load it. Returns its Savegames entry and
* the native schema version it was saved with. */
inspectState(bytes: Uint8Array) {
if (!this.world || bytes.length > 64_000)
if (!this.world || bytes.length > MAX_STATE)
throw new Error("Invalid native save size.");
const value: unknown = JSON.parse(
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
@@ -71,6 +76,26 @@ export class NativeRuntime implements GameRuntime {
schema: typeof schema === "number" ? schema : 0,
};
}
/** Converts a cartridge .sav into a native state without touching the
* running game. Throws with the reason shown to the player when the file
* cannot become a native save; inspectState then checks the result. */
convertCartridge(file: Uint8Array) {
if (!this.world) throw new Error("Load your ROM first.");
let conversion;
try {
conversion = this.world.importCartridge(file);
} catch (error) {
if (error instanceof CartridgeSaveError) throw error;
throw new Error(
`The cartridge save could not be read: ${error instanceof Error ? error.message : String(error)}`,
);
}
const { save, ...details } = conversion;
return {
state: new TextEncoder().encode(JSON.stringify(save)),
...details,
};
}
play() {
if (!this.world || this.raf) return;
// Starting play is a user gesture in the app; browsers allow sound then.
@@ -302,7 +327,7 @@ export class NativeRuntime implements GameRuntime {
return new TextEncoder().encode(JSON.stringify(this.world.save()));
}
restoreState(bytes: Uint8Array, continued = true) {
if (!this.world || bytes.length > 64_000)
if (!this.world || bytes.length > MAX_STATE)
throw new Error("Invalid native save size.");
this.world.restore(
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
+1 -1
View File
@@ -40,7 +40,7 @@ export function hallOfFameScreen(
put(
x,
y,
[...value].flatMap((character) =>
(value.match(/ᴾᴷ|ᴹᴺ|[\s\S]/gu) ?? []).flatMap((character) =>
character === '"' ? [0xe1] : textTiles(character),
),
);
+892
View File
@@ -0,0 +1,892 @@
import type { CrystalAssets } from "../rom/assets";
import { SAVED_WRAM, SAVE_BLOCK, SRAM, SYMBOLS, NUM_ENGINE_FLAGS, NUM_FRUIT_TREES, ENGINE_FLAGS } from "../rom/profile";
import { NUM_TMS_HMS } from "../rom/pokemon";
import { MN, PK, type PartyMon } from "../party/pokemon";
import type { BoxMon, PokemonStorage } from "../party/storage";
import type { Bag, ItemSlot } from "../party/bag";
import type { Mail } from "../party/mail";
import type { HallOfFameTeam } from "../party/hall-of-fame";
import type { DayCareState } from "../party/day-care";
import type { RoamMon } from "../battle/battle";
import { CHALLENGE, type BattleTowerState } from "../scripts/battle-tower";
import { RTC_DAYS, rtcDay, type DayTimer } from "./day-timers";
import { DECORATION_SLOTS, type Decorations } from "./decorations";
import { DIRECTION_ORDER } from "./types";
import { OW } from "./constants";
import type { NativeSave } from "./world";
/** PRET constants a cartridge save's layout depends on, keyed by their
* names so that `npm run check:reference` verifies each value. */
export const SAV = {
SAVE_CHECK_VALUE_1: 99,
SAVE_CHECK_VALUE_2: 127,
NUM_EVENTS: 0x800,
NAME_LENGTH: 11,
PLAYER_NAME_LENGTH: 8,
BOX_NAME_LENGTH: 9,
MON_NAME_LENGTH: 11,
MAIL_STRUCT_LENGTH: 0x2f,
MAIL_LINE_LENGTH: 0x10,
MAILBOX_CAPACITY: 10,
MONS_PER_BOX: 20,
MAX_ITEMS: 20,
MAX_BALLS: 12,
MAX_KEY_ITEMS: 25,
MAX_PC_ITEMS: 50,
CONTACT_LIST_SIZE: 10,
NUM_OBJECTS: 16,
NUM_OBJECT_STRUCTS: 13,
DST_F: 7,
PLAYERGENDER_FEMALE_F: 0,
GAME_TIME_CAPPED: 0,
STATUSFLAGS_FLASH_F: 2,
STATUSFLAGS_NO_WILD_ENCOUNTERS_F: 5,
STATUSFLAGS_HALL_OF_FAME_F: 6,
DAYCAREMAN_MONS_COMPATIBLE_F: 5,
DAYCAREMAN_ACTIVE_F: 7,
DAYCARELADY_ACTIVE_F: 7,
RTC_RESET: 0x80,
SPRITEMOVEDATA_WANDER: 0x02,
SPRITEMOVEDATA_WALK_UP_DOWN: 0x04,
SPRITEMOVEDATA_WALK_LEFT_RIGHT: 0x05,
SPRITEMOVEDATA_SWIM_WANDER: 0x24,
} as const;
/** Struct layouts from macros/ram.asm and constants/map_object_constants.asm
* (rb fields the reference check cannot read; tests compare them with the
* symbols of the pinned build). */
export const LAYOUT = {
/** box_struct (BOXMON_STRUCT_LENGTH) and party_struct. */
boxMon: 0x20,
partyMon: 0x30,
/** box: count, species list, the mons, their OTs, their nicknames. */
box: 0x450,
/** hall_of_fame (HOF_LENGTH) and hof_mon (HOF_MON_LENGTH). */
hallOfFame: 0x62,
hallOfFameMon: 0x10,
/** NUM_HOF_TEAMS */
hallOfFameTeams: 30,
/** map_object (MAPOBJECT_LENGTH) and its fields. */
mapObject: 0x10,
mapObjectStruct: 0,
mapObjectY: 2,
mapObjectX: 3,
/** object_struct (OBJECT_LENGTH) and its fields. */
object: 0x28,
objectDirection: 0x08,
objectMapX: 0x10,
objectMapY: 0x11,
/** roam_struct */
roamMon: 7,
} as const;
/** The emulator footers that append the MBC3 clock to a 32 KiB .sav:
* VBA-M, mGBA, BGB and SameBoy write the five clock registers, their
* latched copies (each a 32-bit little-endian word) and a 64-bit Unix
* timestamp of the write; older VBA builds a 32-bit one. The 3DS Virtual
* Console's sav.dat has 16 bytes after the RAM, whose clock format is not
* documented, so they are not read. */
const SRAM_SIZE = 0x8000;
const FOOTERS = { rtc48: 48, rtc44: 44, virtualConsole: 16 } as const;
/** The two-character strings for the naming screen's <PK> and <MN>. */
const NAME_CHARACTERS: Readonly<Record<number, string>> = {
0x72: "“",
0x73: "”",
0x75: "…",
0x7f: " ",
0x9a: "(",
0x9b: ")",
0x9c: ":",
0x9d: ";",
0x9e: "[",
0x9f: "]",
0xe0: "'",
0xe1: PK,
0xe2: MN,
0xe3: "-",
0xe6: "?",
0xe7: "!",
0xe8: ".",
0xe9: "&",
0xea: "é",
0xef: "♂",
0xf0: "¥",
0xf1: "×",
0xf2: ".",
0xf3: "/",
0xf4: ",",
0xf5: "♀",
};
/** A cartridge save the port refuses, with the reason shown to the player. */
export class CartridgeSaveError extends Error {}
/** What a cartridge save becomes, and how. */
export interface CartridgeConversion {
save: NativeSave;
/** The copy the cartridge itself would load: the main game data, or its
* backup when the main copy fails its checksum (TryLoadSaveFile). */
copy: "main" | "backup";
/** Where the game clock came from: the emulator's clock footer, or this
* device (the file holds no usable clock). */
clock: "rtc" | "device";
/** What could not be carried over, one sentence each. */
notes: string[];
}
/** A file whose size a cartridge save can have. */
export function isCartridgeSaveSize(size: number): boolean {
return (
size === SRAM_SIZE ||
Object.values(FOOTERS).some((footer) => size === SRAM_SIZE + footer)
);
}
/** Reads an English Crystal (revision 1.1 layout) cartridge save and
* converts it to a native save, as Continue would load it: the checked
* copy of the game data, the current box from its own bank, the mail
* backups and the clock. Throws CartridgeSaveError when the file cannot
* become a native save; the result still has to pass GameWorld.restore. */
export function convertCartridgeSave(
assets: CrystalAssets,
file: Uint8Array,
now: Date,
): CartridgeConversion {
if (!isCartridgeSaveSize(file.length))
throw new CartridgeSaveError(
"This file is not a Game Boy Color cartridge save: Crystal's are 32 KiB, sometimes followed by an emulator's clock.",
);
const sram = file.subarray(0, SRAM_SIZE);
const notes: string[] = [];
const copy = pickCopy(sram);
const base = copy === "main" ? SRAM.sGameData : SRAM.sBackupGameData;
if (copy === "backup")
notes.push(
"The main copy of the game failed its checksum, so its backup was imported, as the cartridge would load it.",
);
/** A byte of the saved block at a work RAM address. */
const at = (address: number) => {
if (address < SAVE_BLOCK.wPlayerData || address >= SAVE_BLOCK.wPokemonDataEnd)
throw new Error(`$${address.toString(16)} is not in the saved game.`);
return base + address - SAVE_BLOCK.wPlayerData;
};
const byte = (address: number) => sram[at(address)];
const big = (offset: number, length: number) => {
let value = 0;
for (let i = 0; i < length; i++) value = value * 256 + sram[offset + i];
return value;
};
const word = (address: number) => big(at(address), 2);
const bit = (address: number, index: number) =>
Boolean(byte(address + (index >> 3)) & (1 << (index & 7)));
const name = (offset: number, length: number, what: string) =>
decodeName(sram, offset, length, what);
const party = readParty(assets, sram, at);
const flags: number[] = [];
for (let index = 0; index < NUM_ENGINE_FLAGS; index++) {
const entry = SYMBOLS.engineFlags + index * 3;
const address = assets.reader.word(entry),
mask = assets.reader.byte(entry + 2);
// Three flags live outside the saved block: ENGINE_PLAYER_IS_FEMALE
// (the port's `avatar`) and two that are not saved and start clear.
if (address < SAVE_BLOCK.wPlayerData || address >= SAVE_BLOCK.wPokemonDataEnd)
continue;
if (sram[at(address)] & mask) flags.push(index);
}
if (flags.includes(ENGINE_FLAGS.bugContestTimer))
throw new CartridgeSaveError(
"This save was made during the Bug-Catching Contest. Finish the contest in the original game, save again, then import.",
);
const events: number[] = [];
for (let index = 0; index < SAV.NUM_EVENTS; index++)
if (bit(SAVE_BLOCK.wEventFlags, index)) events.push(index);
const options = SRAM.sOptions + (copy === "backup" ? SRAM.sBackupOptions - SRAM.sOptions : 0);
const clock = convertClock(sram, file.subarray(SRAM_SIZE), byte, now, notes);
const scenes: Record<string, number> = {};
for (let p = SYMBOLS.mapScenes; assets.reader.byte(p) !== 0xff; p += 4) {
const map = `${assets.reader.byte(p)}:${assets.reader.byte(p + 1)}`;
const value = sram[at(assets.reader.word(p + 2))];
const owner = assets.sceneOwner(map);
if (owner && value) scenes[owner] = value;
}
const variables: Record<number, number> = {};
for (let i = 0; i < 0x100 - OW.SPRITE_VARS; i++) {
const sprite = byte(SAVE_BLOCK.wVariableSprites + i);
if (sprite) variables[i] = sprite;
}
const phone: number[] = [];
for (let i = 0; i < SAV.CONTACT_LIST_SIZE; i++) {
const contact = byte(SAVE_BLOCK.wPhoneList + i);
if (!contact) break;
phone.push(contact);
}
const memory: NativeSave["memory"] = {};
for (const [label, address] of Object.entries(SAVED_WRAM) as [keyof typeof SAVED_WRAM, number][]) {
const value = byte(address);
if (value) memory[label] = value;
}
const map = (group: number, number: number) =>
group && group !== 0xff ? `${group}:${number}` : null;
const warp = (address: number) => {
const number = byte(address),
target = map(byte(address + 1), byte(address + 2));
return number && target ? { map: target, warp: number } : null;
};
const status = byte(SAVE_BLOCK.wStatusFlags);
const mapId = map(byte(SAVE_BLOCK.wMapGroup), byte(SAVE_BLOCK.wMapGroup + 1));
if (!mapId) throw new CartridgeSaveError("The saved position is damaged.");
if (!assets.isPorted(mapId)) {
let place = "a map";
try {
place = assets.map(mapId).location;
} catch {
throw new CartridgeSaveError("The saved position is damaged.");
}
throw new CartridgeSaveError(
`This save is in ${place}, which the port cannot open yet. Save elsewhere in Johto in the original game, then import.`,
);
}
const unownDex: number[] = [];
for (let i = 0; i < 26; i++) {
const letter = byte(SAVE_BLOCK.wUnownDex + i);
if (!letter) break;
unownDex.push(letter);
}
const caught: number[] = [],
seen: number[] = [];
for (let species = 1; species <= 251; species++) {
if (bit(SAVE_BLOCK.wPokedexCaught, species - 1)) caught.push(species);
if (bit(SAVE_BLOCK.wPokedexSeen, species - 1)) seen.push(species);
}
const fruitTrees: number[] = [];
for (let tree = 1; tree <= NUM_FRUIT_TREES; tree++)
if (bit(SAVE_BLOCK.wFruitTreeFlags, tree - 1)) fruitTrees.push(tree);
const decorations = Object.fromEntries(
DECORATION_SLOTS.map((slot) => [slot, byte(DECORATION_ADDRESSES[slot])]),
) as Decorations;
// Timers count days of the cartridge's wCurDay; the port counts them on
// its own day number, so each stored day moves by the same amount.
const shift = (day: number) => rtcDay(day + clock.dayShift);
const timer = (address: number): DayTimer => ({
remaining: byte(address),
day: byte(address + 1),
});
const lucky = timer(SAVE_BLOCK.wLuckyNumberDayTimer);
const luckyDay = sram[SRAM.sLuckyNumberDay];
// CheckDailyResetTimer: a countdown of one day. Once it has run out (or
// was never started), the next check clears the daily flags.
const daily = timer(SAVE_BLOCK.wDailyResetTimer);
const dailyDue = daily.remaining <= rtcDay(clock.today - daily.day);
const challenge = sram[SRAM.sBattleTowerChallengeState];
if (challenge === CHALLENGE.savedAndLeft || challenge === CHALLENGE.inProgress)
throw new CartridgeSaveError(
"This save was made during a Battle Tower challenge. Finish or leave the challenge in the original game, save again, then import.",
);
const battleTower: BattleTowerState = {
// LoadPlayerData turns a received reward back into a won challenge.
challenge: challenge === CHALLENGE.receivedReward ? CHALLENGE.won : challenge,
beaten: sram[SRAM.sNrOfBeatenBattleTowerTrainers],
trainers: [...sram.subarray(SRAM.sBTTrainers, SRAM.sBattleTowerSaveFileFlags)],
prevMons: [...sram.subarray(SRAM.sBTMonOfTrainers, SRAM.sBTMonOfTrainers + 3)],
prevPrevMons: [...sram.subarray(SRAM.sBTMonOfTrainers + 3, SRAM.sBTMonOfTrainers + 6)],
savedLevelGroup: sram[SRAM.sBTChoiceOfLevelGroup],
reward: sram[SRAM.sBattleTowerReward],
fileFlags: sram[SRAM.sBattleTowerSaveFileFlags],
// s5_aa8d is in an SRAM bank 32 KiB cartridges do not have.
aa8d: 0,
levelGroup: 0,
beatenNow: 0,
textIndex: 0,
};
const roamMons: RoamMon[] = [0, 1, 2].map((index) => {
const p = SAVE_BLOCK.wRoamMon1 + index * LAYOUT.roamMon;
return {
species: byte(p),
level: byte(p + 1),
mapId: map(byte(p + 2), byte(p + 3)),
hp: byte(p + 4),
dvs: word(p + 5),
};
});
if (sram[SRAM.sMysteryGiftItem] || sram[SRAM.sBackupMysteryGiftItem])
notes.push(
"A Mystery Gift item waiting at the Goldenrod department store is not carried over.",
);
if (sram.subarray(SRAM.sLinkBattleStats, SRAM.sLinkBattleStatsEnd).some(Boolean))
notes.push("The link battle record is not carried over: the port has no Link yet.");
notes.push("The tiles walked count starts from zero: cartridges do not record it.");
const save: NativeSave = {
format: "crystal-native",
version: 3,
map: mapId,
x: byte(SAVE_BLOCK.wXCoord),
y: byte(SAVE_BLOCK.wYCoord),
direction: direction(byte(SAVE_BLOCK.wPlayerDirection)),
name: name(at(SAVE_BLOCK.wPlayerName), SAV.NAME_LENGTH, "The player's name"),
avatar:
sram[SRAM.sCrystalData + SAVE_BLOCK.wPlayerGender - SAVE_BLOCK.wCrystalData] &
(1 << SAV.PLAYERGENDER_FEMALE_F)
? "kris"
: "chris",
tilesWalked: 0,
gameTime: {
hours: word(SAVE_BLOCK.wGameTimeHours),
minutes: byte(SAVE_BLOCK.wGameTimeMinutes),
seconds: byte(SAVE_BLOCK.wGameTimeSeconds),
frames: byte(SAVE_BLOCK.wGameTimeFrames),
capped: Boolean(byte(SAVE_BLOCK.wGameTimeCap) & (1 << SAV.GAME_TIME_CAPPED)),
},
events,
flags,
variables,
scenes,
phone,
specialPhoneCall: byte(SAVE_BLOCK.wSpecialPhoneCallID),
rivalName: name(at(SAVE_BLOCK.wRivalName), SAV.NAME_LENGTH, "The rival's name"),
clock: {
minuteOffset: clock.minuteOffset,
weekdayOffset: clock.weekdayOffset,
dst: Boolean(byte(SAVE_BLOCK.wDST) & (1 << SAV.DST_F)),
},
intro: false,
objects: readObjects(assets, mapId, byte, events),
playerId: word(SAVE_BLOCK.wPlayerID),
party,
bag: readBag(sram, at),
storage: readStorage(sram, at, byte, name),
pcItems: readItems(sram, at(SAVE_BLOCK.wNumPCItems), SAV.MAX_PC_ITEMS, "The PC's items"),
pokedex: { seen, caught },
money: big(at(SAVE_BLOCK.wMoney), 3),
momsMoney: big(at(SAVE_BLOCK.wMomsMoney), 3),
momItem: byte(SAVE_BLOCK.wWhichMomItem),
momItemTriggerBalance: big(at(SAVE_BLOCK.wMomItemTriggerBalance), 3),
decorations,
fruitTrees,
dailyReset: dailyDue ? null : clock.nativeDay,
lastSpawnMap: map(byte(SAVE_BLOCK.wLastSpawnMapGroup), byte(SAVE_BLOCK.wLastSpawnMapGroup + 1)),
playerState: byte(SAVE_BLOCK.wPlayerState),
backupWarp: warp(SAVE_BLOCK.wBackupWarpNumber),
digWarp: warp(SAVE_BLOCK.wDigWarpNumber),
flash: Boolean(status & (1 << SAV.STATUSFLAGS_FLASH_F)),
noWildEncounters: Boolean(status & (1 << SAV.STATUSFLAGS_NO_WILD_ENCOUNTERS_F)),
hallOfFame: Boolean(status & (1 << SAV.STATUSFLAGS_HALL_OF_FAME_F)),
hallOfFameCount: byte(SAVE_BLOCK.wHallOfFameCount),
hallOfFameTeams: readHallOfFame(sram, name),
registeredItem: {
which: byte(SAVE_BLOCK.wWhichRegisteredItem),
item: byte(SAVE_BLOCK.wRegisteredItem),
},
mailbox: readMailbox(sram),
repel: byte(SAVE_BLOCK.wRepelEffect),
stepCount: byte(SAVE_BLOCK.wStepCount),
poisonStepCount: byte(SAVE_BLOCK.wPoisonStepCount),
happinessStepCount: byte(SAVE_BLOCK.wHappinessStepCount),
bikeStep: word(SAVE_BLOCK.wBikeStep),
coins: word(SAVE_BLOCK.wCoins),
blueCardBalance: byte(SAVE_BLOCK.wBlueCardBalance),
tradeFlags: byte(SAVE_BLOCK.wTradeFlags),
memory,
unownDex,
firstUnownSeen: byte(SAVE_BLOCK.wFirstUnownSeen),
// A countdown never started is all zeros, which the port leaves out.
timers:
lucky.remaining || lucky.day
? { luckyNumber: { remaining: lucky.remaining, day: shift(lucky.day) } }
: {},
luckyNumber: {
id: big(SRAM.sLuckyIDNumber, 2),
day: luckyDay ? shift(luckyDay - 1) + 1 : 0,
},
magikarpRecord: {
feet: byte(SAVE_BLOCK.wBestMagikarpLengthFeet),
inches: byte(SAVE_BLOCK.wBestMagikarpLengthFeet + 1),
holder: name(at(SAVE_BLOCK.wMagikarpRecordHoldersName), SAV.NAME_LENGTH, "The Magikarp record holder's name"),
},
battleTower,
roamMons,
roamMonsMap: map(byte(SAVE_BLOCK.wRoamMons_CurMapNumber + 1), byte(SAVE_BLOCK.wRoamMons_CurMapNumber)),
options: {
options: sram[options],
textboxFrame: sram[options + SAVE_BLOCK.wTextboxFrame - SAVE_BLOCK.wOptions],
printerBrightness: sram[options + SAVE_BLOCK.wGBPrinterBrightness - SAVE_BLOCK.wOptions],
options2: sram[options + SAVE_BLOCK.wOptions2 - SAVE_BLOCK.wOptions],
},
dayCare: readDayCare(sram, at, byte, flags, name),
};
return { save, copy, clock: clock.source, notes };
}
const DECORATION_ADDRESSES: Record<(typeof DECORATION_SLOTS)[number], number> = {
bed: SAVE_BLOCK.wDecoBed,
carpet: SAVE_BLOCK.wDecoCarpet,
plant: SAVE_BLOCK.wDecoPlant,
poster: SAVE_BLOCK.wDecoPoster,
console: SAVE_BLOCK.wDecoConsole,
bigDoll: SAVE_BLOCK.wDecoBigDoll,
left: SAVE_BLOCK.wDecoLeftOrnament,
right: SAVE_BLOCK.wDecoRightOrnament,
};
/** OBJECT_DIRECTION's facing bits (DOWN, UP, LEFT, RIGHT). */
const direction = (value: number) => DIRECTION_ORDER[(value >> 2) & 3];
/** The checksum Checksum computes: the 16-bit sum of the block's bytes. */
function checksum(sram: Uint8Array, start: number, end: number) {
let sum = 0;
for (let p = start; p < end; p++) sum = (sum + sram[p]) & 0xffff;
return sum;
}
/** TryLoadSaveFile's choice: the main copy if it is marked and its
* checksum (little-endian, as SaveChecksum stores e then d) matches, else
* the backup under the same test. */
function pickCopy(sram: Uint8Array): "main" | "backup" {
const length = SRAM.sGameDataEnd - SRAM.sGameData;
const intact = (data: number, sum: number, first: number, second: number) =>
sram[first] === SAV.SAVE_CHECK_VALUE_1 &&
sram[second] === SAV.SAVE_CHECK_VALUE_2 &&
checksum(sram, data, data + length) === (sram[sum] | (sram[sum + 1] << 8));
if (intact(SRAM.sGameData, SRAM.sChecksum, SRAM.sCheckValue1, SRAM.sCheckValue2))
return "main";
if (
intact(
SRAM.sBackupGameData,
SRAM.sBackupChecksum,
SRAM.sBackupCheckValue1,
SRAM.sBackupCheckValue2,
)
)
return "backup";
const marked =
(sram[SRAM.sCheckValue1] === SAV.SAVE_CHECK_VALUE_1 &&
sram[SRAM.sCheckValue2] === SAV.SAVE_CHECK_VALUE_2) ||
(sram[SRAM.sBackupCheckValue1] === SAV.SAVE_CHECK_VALUE_1 &&
sram[SRAM.sBackupCheckValue2] === SAV.SAVE_CHECK_VALUE_2);
throw new CartridgeSaveError(
marked
? "Both copies of this save fail their checksums: the file is damaged."
: "This file holds no saved game of the English Pokémon Crystal.",
);
}
/** A name or nickname: charmap codes up to "@" or `length` bytes. */
function decodeName(
sram: Uint8Array,
offset: number,
length: number,
what: string,
): string {
let text = "";
for (let p = offset; p < offset + length; p++) {
const code = sram[p];
if (code === 0x50) return text;
text += character(code, what);
}
return text;
}
function character(code: number, what: string): string {
if (code >= 0x80 && code <= 0x99) return String.fromCharCode(65 + code - 0x80);
if (code >= 0xa0 && code <= 0xb9) return String.fromCharCode(97 + code - 0xa0);
if (code >= 0xf6) return String(code - 0xf6);
const value = NAME_CHARACTERS[code];
if (value === undefined)
throw new CartridgeSaveError(
`${what} uses a character the port cannot show yet (code $${code.toString(16).padStart(2, "0")}).`,
);
return value;
}
/** A box_struct, without the egg mark or the names. */
function boxFields(sram: Uint8Array, p: number) {
const word = (q: number) => (sram[q] << 8) | sram[q + 1];
return {
species: sram[p],
item: sram[p + 1],
moves: [sram[p + 2], sram[p + 3], sram[p + 4], sram[p + 5]] as PartyMon["moves"],
otId: word(p + 6),
exp: (sram[p + 8] << 16) | (sram[p + 9] << 8) | sram[p + 10],
statExp: [0, 1, 2, 3, 4].map((i) => word(p + 11 + i * 2)) as PartyMon["statExp"],
dvs: word(p + 21),
pp: [sram[p + 23], sram[p + 24], sram[p + 25], sram[p + 26]] as PartyMon["pp"],
happiness: sram[p + 27],
pokerus: sram[p + 28],
caughtData: word(p + 29),
level: sram[p + 31],
};
}
/** The stored Pokémon of a species list, structs, OTs and nicknames, as
* the party and the boxes keep them. */
function storedMons(
sram: Uint8Array,
count: number,
capacity: number,
species: number,
mons: number,
structLength: number,
ots: number,
nicknames: number,
what: string,
): (BoxMon & { slot: number })[] {
if (count > capacity) throw new CartridgeSaveError(`${what} is damaged.`);
return Array.from({ length: count }, (_, slot) => {
const listed = sram[species + slot],
p = mons + slot * structLength;
const mon: BoxMon = {
...boxFields(sram, p),
ot: decodeName(sram, ots + slot * SAV.NAME_LENGTH, SAV.NAME_LENGTH, `${what}'s Pokémon ${slot + 1}`),
nickname: decodeName(sram, nicknames + slot * SAV.MON_NAME_LENGTH, SAV.MON_NAME_LENGTH, `${what}'s Pokémon ${slot + 1}`),
};
if (listed === OW.EGG) mon.egg = true;
else if (listed !== mon.species) throw new CartridgeSaveError(`${what} is damaged.`);
return { ...mon, slot };
});
}
function readParty(
assets: CrystalAssets,
sram: Uint8Array,
at: (address: number) => number,
): PartyMon[] {
return storedMons(
sram,
sram[at(SAVE_BLOCK.wPartyCount)],
6,
at(SAVE_BLOCK.wPartySpecies),
at(SAVE_BLOCK.wPartyMons),
LAYOUT.partyMon,
at(SAVE_BLOCK.wPartyMonOTs),
at(SAVE_BLOCK.wPartyMonNicknames),
"The party",
).map(({ slot, ...box }) => {
const p = at(SAVE_BLOCK.wPartyMons) + slot * LAYOUT.partyMon;
const word = (q: number) => (sram[q] << 8) | sram[q + 1];
const mon: PartyMon = {
...box,
status: sram[p + 32],
hp: word(p + 34),
stats: [0, 1, 2, 3, 4, 5].map((i) => word(p + 36 + i * 2)) as PartyMon["stats"],
};
// sPartyMail, restored from its backup as RestorePartyMonMail does.
if (assets.pokemon.itemIsMail(mon.item))
mon.mail = readMail(sram, SRAM.sPartyMailBackup + slot * SAV.MAIL_STRUCT_LENGTH);
return mon;
});
}
function readStorage(
sram: Uint8Array,
at: (address: number) => number,
byte: (address: number) => number,
name: (offset: number, length: number, what: string) => string,
): PokemonStorage {
const boxes = Array.from({ length: 14 }, (_, index) => {
// LoadBox copies wCurBox's bank into sBox, so each numbered box holds
// its own Pokémon; sBox itself may be ahead of the last save.
const p = (SRAM as Record<string, number>)[`sBox${index + 1}`];
return storedMons(
sram,
sram[p],
SAV.MONS_PER_BOX,
p + 1,
p + 1 + SAV.MONS_PER_BOX + 1,
LAYOUT.boxMon,
p + 1 + SAV.MONS_PER_BOX + 1 + SAV.MONS_PER_BOX * LAYOUT.boxMon,
p + 1 + SAV.MONS_PER_BOX + 1 + SAV.MONS_PER_BOX * (LAYOUT.boxMon + SAV.NAME_LENGTH),
`Box ${index + 1}`,
).map(({ slot: _slot, ...mon }) => mon);
});
const names = boxes.map((_, index) => {
const boxName = name(
at(SAVE_BLOCK.wBoxNames) + index * SAV.BOX_NAME_LENGTH,
SAV.BOX_NAME_LENGTH,
`Box ${index + 1}'s name`,
);
// SetDefaultBoxNames' "BOX1" to "BOX14" are the port's null names.
return boxName === `BOX${index + 1}` ? null : boxName;
});
const current = byte(SAVE_BLOCK.wCurBox);
// GetBoxAddress resets an out-of-range wCurBox to the first box.
return { current: current < 14 ? current : 0, names, boxes };
}
/** An item list: a count, item and quantity pairs, then -1. */
function readItems(
sram: Uint8Array,
p: number,
capacity: number,
what: string,
): ItemSlot[] {
const count = sram[p];
if (count > capacity || sram[p + 1 + count * 2] !== 0xff)
throw new CartridgeSaveError(`${what} are damaged.`);
return Array.from({ length: count }, (_, i) => ({
item: sram[p + 1 + i * 2],
quantity: sram[p + 2 + i * 2],
}));
}
function readBag(sram: Uint8Array, at: (address: number) => number): Bag {
const keys = at(SAVE_BLOCK.wNumKeyItems),
count = sram[keys];
if (count > SAV.MAX_KEY_ITEMS || sram[keys + 1 + count] !== 0xff)
throw new CartridgeSaveError("The key items are damaged.");
return {
items: readItems(sram, at(SAVE_BLOCK.wNumItems), SAV.MAX_ITEMS, "The items"),
balls: readItems(sram, at(SAVE_BLOCK.wNumBalls), SAV.MAX_BALLS, "The balls"),
keyItems: [...sram.subarray(keys + 1, keys + 1 + count)],
tmsHms: [...sram.subarray(at(SAVE_BLOCK.wTMsHMs), at(SAVE_BLOCK.wTMsHMs) + NUM_TMS_HMS)],
};
}
/** A mailmsg: two lines split by <NEXT>, the author, the author's ID, the
* species that held it and the MAIL item. */
function readMail(sram: Uint8Array, p: number): Mail {
let message = "";
for (let q = p; q < p + 2 * SAV.MAIL_LINE_LENGTH + 1; q++) {
const code = sram[q];
if (code === 0x50) break;
message += code === 0x4e ? "\n" : character(code, "A MAIL's message");
}
const author = p + 2 * SAV.MAIL_LINE_LENGTH + 1;
const id = author + SAV.PLAYER_NAME_LENGTH + 2;
return {
message,
author: decodeName(sram, author, SAV.PLAYER_NAME_LENGTH, "A MAIL's author"),
authorId: (sram[id] << 8) | sram[id + 1],
species: sram[id + 2],
type: sram[id + 3],
};
}
/** sMailboxes, from their backup as RestorePartyMonMail loads them. */
function readMailbox(sram: Uint8Array): Mail[] {
const count = sram[SRAM.sMailboxCountBackup];
if (count > SAV.MAILBOX_CAPACITY)
throw new CartridgeSaveError("The PC's MAIL is damaged.");
return Array.from({ length: count }, (_, i) =>
readMail(sram, SRAM.sMailboxesBackup + i * SAV.MAIL_STRUCT_LENGTH),
);
}
/** sHallOfFame, newest first; an entry without a win count ends it. */
function readHallOfFame(
sram: Uint8Array,
name: (offset: number, length: number, what: string) => string,
): HallOfFameTeam[] {
const teams: HallOfFameTeam[] = [];
for (let index = 0; index < LAYOUT.hallOfFameTeams; index++) {
const p = SRAM.sHallOfFame + index * LAYOUT.hallOfFame,
win = sram[p];
if (!win) break;
const mons = [];
for (let slot = 0; slot < 6; slot++) {
const q = p + 1 + slot * LAYOUT.hallOfFameMon;
if (sram[q] === 0xff || !sram[q]) break;
mons.push({
species: sram[q],
otId: (sram[q + 1] << 8) | sram[q + 2],
dvs: (sram[q + 3] << 8) | sram[q + 4],
level: sram[q + 5],
nickname: name(q + 6, SAV.MON_NAME_LENGTH - 1, "A Hall of Fame Pokémon"),
});
}
teams.push({ win, mons });
}
return teams;
}
function readDayCare(
sram: Uint8Array,
at: (address: number) => number,
byte: (address: number) => number,
flags: number[],
name: (offset: number, length: number, what: string) => string,
): DayCareState {
const mon = (struct: number, nickname: number, ot: number, what: string): BoxMon => ({
...boxFields(sram, at(struct)),
nickname: name(at(nickname), SAV.MON_NAME_LENGTH, what),
ot: name(at(ot), SAV.NAME_LENGTH, what),
});
const man = byte(SAVE_BLOCK.wDayCareMan),
lady = byte(SAVE_BLOCK.wDayCareLady);
return {
manActive: Boolean(man & (1 << SAV.DAYCAREMAN_ACTIVE_F)),
ladyActive: Boolean(lady & (1 << SAV.DAYCARELADY_ACTIVE_F)),
compatible: Boolean(man & (1 << SAV.DAYCAREMAN_MONS_COMPATIBLE_F)),
stepsToEgg: byte(SAVE_BLOCK.wStepsToEgg),
motherOrNonDitto: byte(SAVE_BLOCK.wBreedMotherOrNonDitto),
mon1: flags.includes(ENGINE_FLAGS.dayCareManHasMon)
? mon(SAVE_BLOCK.wBreedMon1, SAVE_BLOCK.wBreedMon1Nickname, SAVE_BLOCK.wBreedMon1OT, "The Day-Care man's Pokémon")
: null,
mon2: flags.includes(ENGINE_FLAGS.dayCareLadyHasMon)
? mon(SAVE_BLOCK.wBreedMon2, SAVE_BLOCK.wBreedMon2Nickname, SAVE_BLOCK.wBreedMon2OT, "The Day-Care lady's Pokémon")
: null,
egg: byte(SAVE_BLOCK.wEggMon)
? {
...mon(SAVE_BLOCK.wEggMon, SAVE_BLOCK.wEggMonNickname, SAVE_BLOCK.wEggMonOT, "The Day-Care's Egg"),
egg: true,
}
: null,
};
}
/** The current map's objects as Continue finds them (MapSetupScript_Continue
* keeps wMapObjects, wObjectMasks and the object structs): objects a script
* moved or placed elsewhere, and objects whose mask differs from what
* their event flag says. Wandering objects stray from home on their own
* and keep the map's position, as the port saves them. The masks of
* objects with time windows follow the clock again on import. */
function readObjects(
assets: CrystalAssets,
mapId: string,
byte: (address: number) => number,
events: number[],
): NativeSave["objects"] {
const set = new Set(events);
const wandering = new Set<number>([
SAV.SPRITEMOVEDATA_WANDER,
SAV.SPRITEMOVEDATA_WALK_UP_DOWN,
SAV.SPRITEMOVEDATA_WALK_LEFT_RIGHT,
SAV.SPRITEMOVEDATA_SWIM_WANDER,
]);
const objects: NativeSave["objects"] = [];
for (const object of assets.map(mapId).objects) {
// Script object IDs count from 2; wMapObjects[1] is the first object.
const index = object.id - 1;
if (index >= SAV.NUM_OBJECTS) break;
const p = SAVE_BLOCK.wMapObjects + index * LAYOUT.mapObject;
let x = byte(p + LAYOUT.mapObjectX) - 4,
y = byte(p + LAYOUT.mapObjectY) - 4;
let facing: (typeof DIRECTION_ORDER)[number] | undefined;
const struct = byte(p + LAYOUT.mapObjectStruct);
if (struct > 0 && struct < SAV.NUM_OBJECT_STRUCTS) {
const q = SAVE_BLOCK.wPlayerStruct + struct * LAYOUT.object;
if (!wandering.has(object.movement) || x !== object.x || y !== object.y) {
x = byte(q + LAYOUT.objectMapX) - 4;
y = byte(q + LAYOUT.objectMapY) - 4;
}
facing = direction(byte(q + LAYOUT.objectDirection));
}
const moved = x !== object.x || y !== object.y;
const always =
(object.hour1 === 0xff && object.hour2 === 0xff) ||
(object.hour1 !== 0xff && object.hour1 === object.hour2);
const masked = byte(SAVE_BLOCK.wObjectMasks + index) === 0xff,
flagged = object.eventFlag !== 0xffff && set.has(object.eventFlag);
const hidden = always && masked && !flagged,
shown = always && !masked && flagged;
if (!moved && !hidden && !shown) continue;
objects.push({
id: object.id,
x,
y,
// An object without a struct faces as LoadMapObjects spawns it.
direction: facing ?? DIRECTION_ORDER[object.movement - 6] ?? "down",
...(hidden && { hidden: true as const }),
...(shown && { shown: true as const }),
});
}
return objects;
}
/** The game clock now, from the emulator's clock footer when the file has
* one, and how the port's clock offsets reproduce it. */
function convertClock(
sram: Uint8Array,
footer: Uint8Array,
byte: (address: number) => number,
now: Date,
notes: string[],
) {
const start = [0, 1, 2, 3].map((i) => byte(SAVE_BLOCK.wStartDay + i));
let today = byte(SAVE_BLOCK.wCurDay),
minutes: number | undefined;
let source: "rtc" | "device" = "device";
const rtc = readFooter(footer);
if (rtc && !(sram[SRAM.sRTCStatusFlags] & SAV.RTC_RESET)) {
// The registers when the file was written, plus the time since,
// unless the clock was halted.
let seconds =
rtc.seconds + rtc.minutes * 60 + rtc.hours * 3600 + rtc.days * 86400;
if (!rtc.halted)
seconds += Math.max(0, Math.floor(now.getTime() / 1000) - rtc.timestamp);
// The day counter has nine bits (with a carry FixDays ignores), which
// FixDays then takes modulo 140.
const days = Math.floor(seconds / 86400) % 512;
const clock = seconds % 86400;
// FixTime adds wStartDay, wStartHour, wStartMinute and wStartSecond.
const second = (clock % 60) + start[3],
minute = (Math.floor(clock / 60) % 60) + start[2] + Math.floor(second / 60),
hour = Math.floor(clock / 3600) + start[1] + Math.floor(minute / 60);
today = (days % RTC_DAYS) + start[0] + Math.floor(hour / 24);
minutes = (hour % 24) * 60 + (minute % 60);
source = "rtc";
} else if (rtc) {
notes.push(
"The cartridge's clock had been reset, so the game clock follows this device and its day continues from the save.",
);
} else {
notes.push(
"The file has no clock data, so the game clock follows this device and its day continues from the save.",
);
}
// The port's clock is this device's plus a minute offset (within 12
// hours) and a weekday offset; GetWeekday is wCurDay modulo 7.
const device = now.getHours() * 60 + now.getMinutes();
let minuteOffset = minutes === undefined ? 0 : (minutes - device + 1440) % 1440;
if (minuteOffset >= 720) minuteOffset -= 1440;
const game = new Date(now.getTime() + minuteOffset * 60_000);
const weekdayOffset = (((today % 7) - game.getDay()) % 7 + 7) % 7;
const nativeDay =
Math.floor(Date.UTC(game.getFullYear(), game.getMonth(), game.getDate()) / 86_400_000) +
weekdayOffset;
return {
source,
minuteOffset,
weekdayOffset,
nativeDay,
today: rtcDay(today),
/** Adds to a wCurDay to give the port's RTC day (see GameWorld). */
dayShift: rtcDay(nativeDay) - rtcDay(today),
};
}
/** The MBC3 registers of an emulator footer, or undefined without one.
* Throws when the footer's values are impossible. */
function readFooter(footer: Uint8Array) {
if (footer.length !== FOOTERS.rtc48 && footer.length !== FOOTERS.rtc44) return undefined;
const view = new DataView(footer.buffer, footer.byteOffset, footer.byteLength);
const [seconds, minutes, hours, low, high] = [0, 1, 2, 3, 4].map((i) =>
view.getUint32(i * 4, true),
);
const timestamp =
footer.length === FOOTERS.rtc48
? Number(view.getBigUint64(40, true))
: view.getUint32(40, true);
if (
seconds > 59 ||
minutes > 59 ||
hours > 23 ||
low > 0xff ||
high > 0xff ||
!Number.isSafeInteger(timestamp)
)
throw new CartridgeSaveError("The file's clock data is damaged.");
return {
seconds,
minutes,
hours,
days: low | ((high & 1) << 8),
halted: Boolean(high & 0x40),
timestamp,
};
}
+9 -1
View File
@@ -23,6 +23,7 @@ import {
PHONE,
} from "../rom/profile";
import { OW } from "./constants";
import { convertCartridgeSave, type CartridgeConversion } from "./cartridge-save";
import {
POKEGEAR_BUTTONS,
pokegearClock,
@@ -122,6 +123,7 @@ import {
import {
NICKNAME_PATTERN,
PARTY_LENGTH,
TRAINER_NAME_PATTERN,
caughtData,
validPartyMon,
type PartyMon,
@@ -5055,6 +5057,12 @@ export class GameWorld {
// ---------------------------------------------------------------- saves
/** A cartridge save converted to a native save on this world's clock
* (see ./cartridge-save); restore() still decides whether it loads. */
importCartridge(file: Uint8Array): CartridgeConversion {
return convertCartridgeSave(this.assets, file, this.now());
}
/** The Savegames list entry for a save: the trainer, the landmark (the
* backup map's in LANDMARK_SPECIAL rooms), play time, Johto badges and
* caught count. */
@@ -5220,7 +5228,7 @@ export class GameWorld {
!integer(save.y, 0, 127) ||
!DIRECTION_ORDER.includes(save.direction) ||
typeof save.name !== "string" ||
!/^[A-Za-z0-9 -]{1,7}$/.test(save.name) ||
!TRAINER_NAME_PATTERN.test(save.name) ||
!["chris", "kris"].includes(save.avatar) ||
!integer(save.tilesWalked, 0, Number.MAX_SAFE_INTEGER) ||
!validGameTime(save.gameTime) ||