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:
1 parent
87b05b45f4
commit
592ca43a40
20 files changed
+1821
-40
No files matched your search
@@ -49,7 +49,7 @@ Open the printed local URL and select your uncompressed **English US/Europe revi
|
|||||||
|
|
||||||
Use onscreen controls, or focus the game screen and use arrows to move, **X** to interact and **X / Z** to continue dialogue. **Settings** changes these keys, the text size and the sound volume for this browser. In choices, **Up / Down** change the answer, **X** confirms and **Z** answers No. Games save every 10 seconds and when paused, each into its own save. Switching apps pauses play. Download a backup before clearing browser data.
|
Use onscreen controls, or focus the game screen and use arrows to move, **X** to interact and **X / Z** to continue dialogue. **Settings** changes these keys, the text size and the sound volume for this browser. In choices, **Up / Down** change the answer, **X** confirms and **Z** answers No. Games save every 10 seconds and when paused, each into its own save. Switching apps pauses play. Download a backup before clearing browser data.
|
||||||
|
|
||||||
Native saves are separate from emulator snapshots and cartridge `.sav` files. Each save keeps its newest and previous write; if the newest was interrupted or damaged, the previous one loads and nothing is overwritten. The single save of earlier versions becomes "Save 1" the first time this version opens it. Saves and backups from a newer version are kept as they are and refused, never rewritten. Saving during a scene keeps the progress from before it, so the scene replays after loading. The time you give Oak is kept as an offset from the device clock (the steppers start at the device's time), and the weekday you give Mom as an offset from that clock's weekday. The tile counter measures in-game movement, not real-world steps.
|
Native saves are separate from emulator snapshots and cartridge `.sav` files, but Savegames → Import backup also reads a `.sav` of the English Crystal (from an emulator, a cartridge dump or the 3DS Virtual Console's `sav.dat`) and converts it into a new save after a preview. The cartridge's checksums decide which of its two copies is read, as the game would. With an emulator clock footer (VBA-M, mGBA, BGB, SameBoy), the game clock carries on from the cartridge's; otherwise it follows the device and the day continues from the save. Saves made in an area the port has not opened yet, during the Bug-Catching Contest or in a Battle Tower challenge are refused with the reason. Each save keeps its newest and previous write; if the newest was interrupted or damaged, the previous one loads and nothing is overwritten. The single save of earlier versions becomes "Save 1" the first time this version opens it. Saves and backups from a newer version are kept as they are and refused, never rewritten. Saving during a scene keeps the progress from before it, so the scene replays after loading. The time you give Oak is kept as an offset from the device clock (the steppers start at the device's time), and the weekday you give Mom as an offset from that clock's weekday. The tile counter measures in-game movement, not real-world steps.
|
||||||
|
|
||||||
## Reference emulator
|
## Reference emulator
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ flowchart LR
|
|||||||
|
|
||||||
Saves made during an unfinished walk or jump retain its source tile and completed step count, so loading cannot skip a landing event or map transition. Directional mats wait for exit input; doors, stairs and caves force an animated downward step after arrival.
|
Saves made during an unfinished walk or jump retain its source tile and completed step count, so loading cannot skip a landing event or map transition. Directional mats wait for exit input; doors, stairs and caves force an animated downward step after arrival.
|
||||||
|
|
||||||
Native and reference sessions use different IndexedDB databases and Web Locks. The native player keeps save slots (`storage/slots.ts`, `app/slot-session.ts`): each slot keeps its newest two records with checksums, written in one transaction, and loading falls back to the previous record when the newest is damaged. The singleton save of database version 1 becomes the first slot inside the version-2 upgrade. Imports are previewed in a scratch world and reject incompatible schemas and invalid positions before changing the world or any slot. A failed import, switch or new game restores the prior state. Original cartridge saves and emulator snapshots cannot be used as native saves.
|
Native and reference sessions use different IndexedDB databases and Web Locks. The native player keeps save slots (`storage/slots.ts`, `app/slot-session.ts`): each slot keeps its newest two records with checksums, written in one transaction, and loading falls back to the previous record when the newest is damaged. The singleton save of database version 1 becomes the first slot inside the version-2 upgrade. Imports are previewed in a scratch world and reject incompatible schemas and invalid positions before changing the world or any slot. A failed import, switch or new game restores the prior state. Emulator snapshots cannot be used as native saves. Cartridge saves are converted, not loaded: `world/cartridge-save.ts` checks the size, the check values and checksums of both copies (taking the backup when the main copy fails, as `TryLoadSaveFile` does), reads the saved WRAM block, the numbered PC boxes, the mail backups, the Hall of Fame and the Battle Tower by PRET addresses (`SAVE_BLOCK`, `SRAM` in the profile), translates engine flags through the ROM's `EngineFlags` and scenes through `MapScenes`, and turns the cartridge clock (an emulator's MBC3 footer plus `wStart*`, as `FixDays`/`FixTime` do) into the port's minute and weekday offsets, moving stored day counters by the same amount. The result goes through the same preview and `restore` checks as a native backup.
|
||||||
|
|
||||||
`GameWorld` advances in overworld ticks of two video frames, as the original loop does. The runtime converts elapsed time to ticks. Tile events follow `CheckTileEvent`: a completed step checks connections, then warps, then coordinate events for the current scene. Idle ticks run the current scene script. Map entry runs the NEWMAP, TILES and OBJECTS callbacks on a copy first, so an unsupported callback refuses the step instead of entering a half-initialized map. Objects spawn from event flags at map load, and later flag changes leave them in place until `appear`/`disappear` or the next load.
|
`GameWorld` advances in overworld ticks of two video frames, as the original loop does. The runtime converts elapsed time to ticks. Tile events follow `CheckTileEvent`: a completed step checks connections, then warps, then coordinate events for the current scene. Idle ticks run the current scene script. Map entry runs the NEWMAP, TILES and OBJECTS callbacks on a copy first, so an unsupported callback refuses the step instead of entering a half-initialized map. Objects spawn from event flags at map load, and later flag changes leave them in place until `appear`/`disappear` or the next load.
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ Saves live in slots (see [PLAN.md](PLAN.md), save slots): the slot model is vers
|
|||||||
|
|
||||||
Native saves are version 3. Version 2 added scene values by owning map, phone contacts, the weekday offset and DST flag, and positions of objects that scripts moved on the current map. Version 3 adds the player ID, party, bag, Pokédex seen/caught sets and money, and later Mom's savings (`momsMoney`, migrated by absence to 0). Early version-3 backups without money receive the original starting balance; explicitly invalid balances are rejected. Restores recompute each Pokémon's stats from its DVs and reject any mismatch, and check every item against its ROM pocket. Older saves migrate with new-game defaults; a missing player ID is drawn as a new game draws it. Objects are restored from the save on continue, and only the TILES callback runs, as in `MapSetupScript_Continue`.
|
Native saves are version 3. Version 2 added scene values by owning map, phone contacts, the weekday offset and DST flag, and positions of objects that scripts moved on the current map. Version 3 adds the player ID, party, bag, Pokédex seen/caught sets and money, and later Mom's savings (`momsMoney`, migrated by absence to 0). Early version-3 backups without money receive the original starting balance; explicitly invalid balances are rejected. Restores recompute each Pokémon's stats from its DVs and reject any mismatch, and check every item against its ROM pocket. Older saves migrate with new-game defaults; a missing player ID is drawn as a new game draws it. Objects are restored from the save on continue, and only the TILES callback runs, as in `MapSetupScript_Continue`.
|
||||||
|
|
||||||
|
Cartridge `.sav` files are converted into this schema rather than loaded (`world/cartridge-save.ts`; see [PLAN.md](PLAN.md), cartridge save import). Player and OT names accept the naming screen's alphabet, with <PK> and <MN> stored as `ᴾᴷ` and `ᴹᴺ`, each one character.
|
||||||
|
|
||||||
Later fields keep, by absence-migrated defaults: `memory`, the saved WRAM bytes that scripts read and write directly, by PRET label (`SAVED_WRAM`); and `unownDex` with `firstUnownSeen` (`wUnownDex`, `wFirstUnownSeen`).
|
Later fields keep, by absence-migrated defaults: `memory`, the saved WRAM bytes that scripts read and write directly, by PRET label (`SAVED_WRAM`); and `unownDex` with `firstUnownSeen` (`wUnownDex`, `wFirstUnownSeen`).
|
||||||
|
|
||||||
## Scripted trainer battle foundation
|
## Scripted trainer battle foundation
|
||||||
|
|||||||
+11
-1
@@ -519,6 +519,15 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
|||||||
- `PokemonCenterPC.ChooseWhichPCListToUse` picks `PCPC_POSTGAME` from the count, as the original does, so HALL OF FAME appears once a team is recorded. `_HallOfFamePC`, `LoadHOFTeam` and `DisplayHOFMon` show each Pokémon's page with its cry: A shows the next Pokémon, then the next team; Start skips to the next team; B returns to the PC. The original's unreachable "HOF Master!" title (it needs a count of 201) is kept.
|
- `PokemonCenterPC.ChooseWhichPCListToUse` picks `PCPC_POSTGAME` from the count, as the original does, so HALL OF FAME appears once a team is recorded. `_HallOfFamePC`, `LoadHOFTeam` and `DisplayHOFMon` show each Pokémon's page with its cry: A shows the next Pokémon, then the next team; Start skips to the next team; B returns to the PC. The original's unreachable "HOF Master!" title (it needs a count of 201) is kept.
|
||||||
- `tests/reference-hall-of-fame.spec.ts` writes 255 teams (every species, Unown, level 100, the Nidorans, win 200, all eight frames) into the cartridge's SRAM, runs its `_HallOfFamePC`, and compares the tilemap, attributes, graphics and palette with the port's page. `HOF_AnimateFrontpic` is the one routine stubbed: the port shows the picture at rest, without the `ANIM_MON_HOF` animation. `tests/hall-of-fame.test.ts` covers the records, the PC menu with and without them, browsing, and save, reload and migration.
|
- `tests/reference-hall-of-fame.spec.ts` writes 255 teams (every species, Unown, level 100, the Nidorans, win 200, all eight frames) into the cartridge's SRAM, runs its `_HallOfFamePC`, and compares the tilemap, attributes, graphics and palette with the port's page. `HOF_AnimateFrontpic` is the one routine stubbed: the port shows the picture at rest, without the `ANIM_MON_HOF` animation. `tests/hall-of-fame.test.ts` covers the records, the PC menu with and without them, browsing, and save, reload and migration.
|
||||||
|
|
||||||
|
## Implemented: cartridge save import (board #44)
|
||||||
|
|
||||||
|
- `world/cartridge-save.ts` converts a 32 KiB English Crystal `.sav` (optionally followed by a 44- or 48-byte emulator clock footer, or the Virtual Console's 16 bytes, which are not read) into a version-3 native save. Every SRAM and WRAM address it reads is keyed by its PRET label in `profile.ts` (`SRAM`, `SAVE_BLOCK`) and checked by `npm run check:reference`, like its PRET constants (`SAV`); `tests/cartridge-save.test.ts` compares its struct offsets (`LAYOUT`) with the pinned build's symbols.
|
||||||
|
- Copy selection follows `TryLoadSaveFile`: check values and the 16-bit checksum of the main copy, else the backup's; both damaged, or never saved, is refused. Boxes come from their own banks (`LoadBox` copies the current one into `sBox`), party mail and the mailbox from their backups (`RestorePartyMonMail`), the Battle Tower's received reward becomes a won challenge (`LoadPlayerData`).
|
||||||
|
- The clock: with an emulator footer, the MBC3 registers plus the time since the file was written (unless halted), nine-bit days modulo 140 and `wStart*` added with carries; without one, the device's time and the saved `wCurDay`. The minute and weekday offsets reproduce that time and weekday; the Lucky Number countdown, its drawn day and the daily reset move with the difference between `wCurDay` and the port's day number.
|
||||||
|
- Names keep the naming screen's alphabet, with <PK> and <MN> as `ᴾᴷ`/`ᴹᴺ` (one character, one tile); player and OT names now accept that alphabet. Boxed Eggs validate (they keep their `egg` mark). Native states up to 256 kB load, enough for a full PC.
|
||||||
|
- Refused with the reason: unported maps, the Bug-Catching Contest, a Battle Tower challenge in progress, characters outside the naming screen (traded Japanese names, ligatures). Not carried over, with a note in the preview: the tiles walked count, Mystery Gift items, link battle records; time-windowed objects follow the clock again.
|
||||||
|
- Savegames → Import backup reads `.sav`/`.srm`/`.dat` files (or any 32 KiB non-JSON file), previews trainer, place, progress, which copy and clock were used and the notes, then adds or replaces a save like a native backup (`SlotSession.previewCartridge`). Tests: `tests/cartridge-save.test.ts` (the committed real `.sav` and Virtual Console `sav.dat`, backup fallback, clock footers, day counters, refusals), `tests/save-slots.test.ts` and `tests/save-slots-browser.spec.ts`.
|
||||||
|
|
||||||
## Native battles: current state
|
## Native battles: current state
|
||||||
|
|
||||||
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Every ball can be thrown; only the contest's Park Ball handling remains.
|
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Every ball can be thrown; only the contest's Park Ball handling remains.
|
||||||
@@ -643,6 +652,7 @@ Acceptance: main-game/postgame playthroughs, representative optional content, ba
|
|||||||
- [x] Scalable text and configurable keyboard controls
|
- [x] Scalable text and configurable keyboard controls
|
||||||
- [ ] Fuller accessibility: announcements for every native screen and touch control remapping
|
- [ ] Fuller accessibility: announcements for every native screen and touch control remapping
|
||||||
- [x] Save slots with migration, atomic writes and import previews
|
- [x] Save slots with migration, atomic writes and import previews
|
||||||
|
- [x] Cartridge `.sav` import with verified structure, checksums and clock conversion (board #44)
|
||||||
- [x] Installable web app with offline app shell, safe updates and quota handling
|
- [x] Installable web app with offline app shell, safe updates and quota handling
|
||||||
- [ ] Acceptance on physical iPhone and Android devices
|
- [ ] Acceptance on physical iPhone and Android devices
|
||||||
|
|
||||||
@@ -656,7 +666,7 @@ Dependencies: stable serialization for every system stored in a slot, migration
|
|||||||
|
|
||||||
Acceptance: migrate existing browser data without loss, keep several independent playthroughs, recover from interrupted writes and reject incompatible or corrupt imports without changing a valid slot. Test create/load/delete/rename/duplicate, autosave isolation, export/import conflicts and upgrades from every released schema.
|
Acceptance: migrate existing browser data without loss, keep several independent playthroughs, recover from interrupted writes and reject incompatible or corrupt imports without changing a valid slot. Test create/load/delete/rename/duplicate, autosave isolation, export/import conflicts and upgrades from every released schema.
|
||||||
|
|
||||||
Add PWA installation/offline caching of the app shell only. ROM/data retention remains a separate explicit local preference; otherwise reselect the ROM after reopening. Add safe application updates, schema migrations, storage persistence/quota handling and portable backups. Investigate original `.sav` import only with verified structure, checksums and RTC conversion.
|
Add PWA installation/offline caching of the app shell only. ROM/data retention remains a separate explicit local preference; otherwise reselect the ROM after reopening. Add safe application updates, schema migrations, storage persistence/quota handling and portable backups. Original `.sav` files import through a converter with verified structure, checksums and RTC conversion (board #44, below).
|
||||||
|
|
||||||
Acceptance: installation, offline startup, portrait/landscape, touch/audio, background/resume and updates on physical iPhone/Safari and Android/Chrome. Browser automation does not establish physical-device compatibility.
|
Acceptance: installation, offline startup, portrait/landscape, touch/audio, background/resume and updates on physical iPhone/Safari and Android/Chrome. Browser automation does not establish physical-device compatibility.
|
||||||
|
|
||||||
|
|||||||
@@ -218,8 +218,8 @@ for (const [key, symbol] of Object.entries(ramNames)) {
|
|||||||
if (!match || Number(match[1]) !== ram.get(symbol))
|
if (!match || Number(match[1]) !== ram.get(symbol))
|
||||||
throw new Error(`Profile RAM mismatch: ${key} / ${symbol}`);
|
throw new Error(`Profile RAM mismatch: ${key} / ${symbol}`);
|
||||||
}
|
}
|
||||||
// SAVED_WRAM and WRAM entries are work RAM labels.
|
// SAVED_WRAM, WRAM and SAVE_BLOCK entries are work RAM labels.
|
||||||
for (const table of ["SAVED_WRAM", "WRAM"]) {
|
for (const table of ["SAVED_WRAM", "WRAM", "SAVE_BLOCK"]) {
|
||||||
const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1];
|
const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1];
|
||||||
if (block === undefined) throw new Error(`${table} symbol table is missing.`);
|
if (block === undefined) throw new Error(`${table} symbol table is missing.`);
|
||||||
for (const [, quoted, bare, literal] of block.matchAll(/^\s+(?:"([^"]+)"|(\w+)):\s*(0x[0-9a-f]+),/gm)) {
|
for (const [, quoted, bare, literal] of block.matchAll(/^\s+(?:"([^"]+)"|(\w+)):\s*(0x[0-9a-f]+),/gm)) {
|
||||||
@@ -228,6 +228,25 @@ for (const table of ["SAVED_WRAM", "WRAM"]) {
|
|||||||
throw new Error(`${table} RAM mismatch: ${label}`);
|
throw new Error(`${table} RAM mismatch: ${label}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// SRAM entries are offsets into a 32 KiB .sav file: bank * $2000 plus the
|
||||||
|
// address within $a000-$bfff.
|
||||||
|
{
|
||||||
|
const sram = new Map(
|
||||||
|
readFileSync(`${root}/pokecrystal11.sym`, "utf8")
|
||||||
|
.split("\n")
|
||||||
|
.flatMap((line) => {
|
||||||
|
const match = /^0([0-3]):([ab][0-9a-f]{3}) (\S+)$/.exec(line);
|
||||||
|
return match
|
||||||
|
? [[match[3], Number(match[1]) * 0x2000 + parseInt(match[2], 16) - 0xa000]]
|
||||||
|
: [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const block = /export const SRAM = \{([\s\S]*?)\} as const;/.exec(profile)?.[1];
|
||||||
|
if (block === undefined) throw new Error("SRAM symbol table is missing.");
|
||||||
|
for (const [, label, literal] of block.matchAll(/^\s+(\w+):\s*(0x[0-9a-f]+),/gm))
|
||||||
|
if (sram.get(label) !== Number(literal))
|
||||||
|
throw new Error(`SRAM mismatch: ${label}`);
|
||||||
|
}
|
||||||
// BATTLE, OVERWORLD, PC, INTRO, STATS, MENU, POKEGEAR, MOM, TRAINER_CARD, POKEDEX, OPTIONS, JOHTO and AUDIO entries are keyed by their PRET labels.
|
// BATTLE, OVERWORLD, PC, INTRO, STATS, MENU, POKEGEAR, MOM, TRAINER_CARD, POKEDEX, OPTIONS, JOHTO and AUDIO entries are keyed by their PRET labels.
|
||||||
for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS", "MENU", "POKEGEAR", "MOM", "TRAINER_CARD", "POKEDEX", "OPTIONS", "JOHTO", "AUDIO", "PHONE", "RADIO", "MAIL", "EGGS"]) {
|
for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS", "MENU", "POKEGEAR", "MOM", "TRAINER_CARD", "POKEDEX", "OPTIONS", "JOHTO", "AUDIO", "PHONE", "RADIO", "MAIL", "EGGS"]) {
|
||||||
const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1];
|
const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1];
|
||||||
@@ -372,6 +391,17 @@ for (const [, block] of battleConstants.matchAll(/export const \w+ = \{([\s\S]*?
|
|||||||
throw new Error(`Battle constant mismatch: ${name} is ${literal}, PRET has ${expected}`);
|
throw new Error(`Battle constant mismatch: ${name} is ${literal}, PRET has ${expected}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The cartridge save converter's PRET constants (its LAYOUT holds struct
|
||||||
|
// offsets, compared with the symbols by tests/cartridge-save.test.ts).
|
||||||
|
{
|
||||||
|
const block = /export const SAV = \{([\s\S]*?)\} as const;/.exec(
|
||||||
|
readFileSync("src/game/world/cartridge-save.ts", "utf8"),
|
||||||
|
)?.[1];
|
||||||
|
if (block === undefined) throw new Error("SAV constant table is missing.");
|
||||||
|
for (const [, name, literal] of block.matchAll(/^\s+(\w+):\s*(0x[0-9a-f]+|\d+),/gim))
|
||||||
|
if (pretConstants.get(name) !== Number(literal))
|
||||||
|
throw new Error(`Cartridge save constant mismatch: ${name} is ${literal}, PRET has ${pretConstants.get(name)}`);
|
||||||
|
}
|
||||||
if (!profile.includes(`pokegear: ${pretConstants.get("ENGINE_POKEGEAR")},`))
|
if (!profile.includes(`pokegear: ${pretConstants.get("ENGINE_POKEGEAR")},`))
|
||||||
throw new Error("ENGINE_POKEGEAR does not match the reference.");
|
throw new Error("ENGINE_POKEGEAR does not match the reference.");
|
||||||
if (!profile.includes(`pokedex: ${pretConstants.get("ENGINE_POKEDEX")},`))
|
if (!profile.includes(`pokedex: ${pretConstants.get("ENGINE_POKEDEX")},`))
|
||||||
|
|||||||
+80
-4
@@ -15,6 +15,21 @@ export interface SlotRuntime extends GameRuntime {
|
|||||||
reset(): void;
|
reset(): void;
|
||||||
summary(): SlotSummary;
|
summary(): SlotSummary;
|
||||||
inspectState(state: Uint8Array): { summary: SlotSummary; schema: number };
|
inspectState(state: Uint8Array): { summary: SlotSummary; schema: number };
|
||||||
|
/** A cartridge .sav as a native state; throws with the player-facing
|
||||||
|
* reason when it cannot be imported. */
|
||||||
|
convertCartridge(file: Uint8Array): {
|
||||||
|
state: Uint8Array;
|
||||||
|
copy: "main" | "backup";
|
||||||
|
clock: "rtc" | "device";
|
||||||
|
notes: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a cartridge save was converted (see game/world/cartridge-save). */
|
||||||
|
export interface CartridgeImport {
|
||||||
|
copy: "main" | "backup";
|
||||||
|
clock: "rtc" | "device";
|
||||||
|
notes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SlotEntry {
|
export interface SlotEntry {
|
||||||
@@ -37,6 +52,8 @@ export type ImportPreview =
|
|||||||
/** Slots of the same trainer (name and ID), newest first;
|
/** Slots of the same trainer (name and ID), newest first;
|
||||||
* `identical` when the slot's newest record holds the same game. */
|
* `identical` when the slot's newest record holds the same game. */
|
||||||
conflicts: { slot: SaveSlot; identical: boolean }[];
|
conflicts: { slot: SaveSlot; identical: boolean }[];
|
||||||
|
/** Present when the file was a cartridge save. */
|
||||||
|
cartridge?: CartridgeImport;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
ok: false;
|
ok: false;
|
||||||
@@ -326,12 +343,71 @@ export class SlotSession {
|
|||||||
identity,
|
identity,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
record,
|
||||||
|
...inspected,
|
||||||
|
conflicts: await this.conflicts(record, inspected.summary),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks a cartridge save (.sav) the same way: converted to a native
|
||||||
|
* save on this game's clock, then loaded into a separate world. The
|
||||||
|
* file's modification time stands for when it was saved. */
|
||||||
|
async previewCartridge(
|
||||||
|
file: Uint8Array,
|
||||||
|
modified: Date,
|
||||||
|
): Promise<ImportPreview> {
|
||||||
|
if (!this.romHash)
|
||||||
|
throw new Error("Load your ROM before importing a backup.");
|
||||||
|
const savedAt = Number.isFinite(modified.getTime())
|
||||||
|
? modified.toISOString()
|
||||||
|
: new Date().toISOString();
|
||||||
|
let converted: ReturnType<SlotRuntime["convertCartridge"]>;
|
||||||
|
try {
|
||||||
|
converted = this.runtime.convertCartridge(file);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: error instanceof Error ? error.message : String(error),
|
||||||
|
identity: { savedAt },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { state, ...cartridge } = converted;
|
||||||
|
const record: SaveRecord = {
|
||||||
|
version: 1,
|
||||||
|
romHash: this.romHash,
|
||||||
|
coreId: this.runtime.id,
|
||||||
|
savedAt,
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
let inspected: ReturnType<SlotRuntime["inspectState"]>;
|
||||||
|
try {
|
||||||
|
inspected = this.runtime.inspectState(record.state);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: `The converted game did not pass the port's checks: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
identity: { savedAt },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
record,
|
||||||
|
...inspected,
|
||||||
|
conflicts: await this.conflicts(record, inspected.summary),
|
||||||
|
cartridge,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slots of the same trainer as an import, newest first. */
|
||||||
|
private async conflicts(record: SaveRecord, summary: SlotSummary) {
|
||||||
const conflicts = [];
|
const conflicts = [];
|
||||||
for (const listed of await this.store.list(this.romHash)) {
|
for (const listed of await this.store.list(this.romHash!)) {
|
||||||
const slot = await this.withSummary(listed);
|
const slot = await this.withSummary(listed);
|
||||||
if (
|
if (
|
||||||
slot.summary?.player !== inspected.summary.player ||
|
slot.summary?.player !== summary.player ||
|
||||||
slot.summary.playerId !== inspected.summary.playerId
|
slot.summary.playerId !== summary.playerId
|
||||||
)
|
)
|
||||||
continue;
|
continue;
|
||||||
const newest = (await this.store.records(slot.id))[0]?.record.state;
|
const newest = (await this.store.records(slot.id))[0]?.record.state;
|
||||||
@@ -343,7 +419,7 @@ export class SlotSession {
|
|||||||
newest.every((byte, index) => byte === record.state[index]),
|
newest.every((byte, index) => byte === record.state[index]),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { ok: true, record, ...inspected, conflicts };
|
return conflicts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Imports a previewed backup into a new slot, or over a slot (whose
|
/** Imports a previewed backup into a new slot, or over a slot (whose
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PokemonData } from "../rom/pokemon";
|
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
|
/** 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
|
* 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" &&
|
typeof mail === "object" &&
|
||||||
storedMailMessage(mail.message) &&
|
storedMailMessage(mail.message) &&
|
||||||
typeof mail.author === "string" &&
|
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.authorId, 0, 0xffff) &&
|
||||||
integer(mail.species, 1, 251) &&
|
integer(mail.species, 1, 251) &&
|
||||||
integer(mail.type, 1, 255) &&
|
integer(mail.type, 1, 255) &&
|
||||||
|
|||||||
@@ -70,11 +70,20 @@ export function partyMonDetails(mon: PartyMon): string {
|
|||||||
if (mon.egg) return "";
|
if (mon.egg) return "";
|
||||||
return `:L${mon.level} HP ${mon.hp}/${mon.stats[0]}${mon.hp ? statusName(mon.status) : " FNT"}`;
|
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
|
/** The naming screen's <PK> and <MN> glyphs, each one character of a name,
|
||||||
* symbols, plus the digits and apostrophes of default species names. */
|
* written as two small capitals. */
|
||||||
export const NICKNAME_PATTERN = /^[A-Za-z0-9 ()":;[\]?!♂♀/.,×'é-]{1,10}$/u;
|
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). */
|
/** 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 */
|
/** BASE_HAPPINESS */
|
||||||
const BASE_HAPPINESS = 70;
|
const BASE_HAPPINESS = 70;
|
||||||
const MAX_STAT_VALUE = 999;
|
const MAX_STAT_VALUE = 999;
|
||||||
@@ -245,7 +254,7 @@ export function validBoxFields(value: unknown): boolean {
|
|||||||
tuple(mon.moves, 4, 251) &&
|
tuple(mon.moves, 4, 251) &&
|
||||||
integer(mon.otId, 0, 0xffff) &&
|
integer(mon.otId, 0, 0xffff) &&
|
||||||
typeof mon.ot === "string" &&
|
typeof mon.ot === "string" &&
|
||||||
/^[A-Za-z0-9 -]{1,7}$/.test(mon.ot) &&
|
TRAINER_NAME_PATTERN.test(mon.ot) &&
|
||||||
typeof mon.nickname === "string" &&
|
typeof mon.nickname === "string" &&
|
||||||
NICKNAME_PATTERN.test(mon.nickname) &&
|
NICKNAME_PATTERN.test(mon.nickname) &&
|
||||||
integer(mon.exp, 0, 0xffffff) &&
|
integer(mon.exp, 0, 0xffffff) &&
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ export const MONS_PER_BOX = 20;
|
|||||||
export const BOX_NAME_MAX = 8;
|
export const BOX_NAME_MAX = 8;
|
||||||
/** MAX_PC_ITEMS (constants/item_data_constants.asm). */
|
/** MAX_PC_ITEMS (constants/item_data_constants.asm). */
|
||||||
export const MAX_PC_ITEMS = 50;
|
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;
|
const BOX_MON_FIELDS = 14;
|
||||||
|
|
||||||
/** box_struct: a party_struct without status, HP and stats. */
|
/** box_struct: a party_struct without status, HP and stats. */
|
||||||
@@ -129,7 +130,8 @@ export function validStorage(value: unknown): value is PokemonStorage {
|
|||||||
box.every(
|
box.every(
|
||||||
(mon) =>
|
(mon) =>
|
||||||
validBoxFields(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),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,9 +70,13 @@ const CODES: Record<string, number> = {
|
|||||||
"♀": 0xf5,
|
"♀": 0xf5,
|
||||||
"…": ELLIPSIS_TILE,
|
"…": 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[] {
|
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);
|
const code = character.charCodeAt(0);
|
||||||
if (character >= "A" && character <= "Z") return 0x80 + code - 65;
|
if (character >= "A" && character <= "Z") return 0x80 + code - 65;
|
||||||
if (character >= "a" && character <= "z") return 0xa0 + code - 97;
|
if (character >= "a" && character <= "z") return 0xa0 + code - 97;
|
||||||
|
|||||||
@@ -1565,6 +1565,155 @@ export const WRAM = {
|
|||||||
wBreedMon1Nickname: 0xdef6,
|
wBreedMon1Nickname: 0xdef6,
|
||||||
wBreedMon2Nickname: 0xdf2f,
|
wBreedMon2Nickname: 0xdf2f,
|
||||||
} as const;
|
} 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 MAP_GROUP_COUNT = 26;
|
||||||
export const NUM_POKEMON = 251;
|
export const NUM_POKEMON = 251;
|
||||||
|
|||||||
+27
-2
@@ -2,11 +2,16 @@ import type { GameRuntime } from "../app/runtime";
|
|||||||
import type { Button } from "../input/types";
|
import type { Button } from "../input/types";
|
||||||
import { CrystalAssets } from "./rom/assets";
|
import { CrystalAssets } from "./rom/assets";
|
||||||
import { GameWorld, TICKS_PER_SECOND, realtimeChoice } from "./world/world";
|
import { GameWorld, TICKS_PER_SECOND, realtimeChoice } from "./world/world";
|
||||||
|
import { CartridgeSaveError } from "./world/cartridge-save";
|
||||||
import type { BattleAction } from "./battle/battle";
|
import type { BattleAction } from "./battle/battle";
|
||||||
import type { Direction } from "./world/types";
|
import type { Direction } from "./world/types";
|
||||||
import { WorldRenderer } from "./rendering/renderer";
|
import { WorldRenderer } from "./rendering/renderer";
|
||||||
import { AudioOutput } from "./audio/output";
|
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 {
|
export class NativeRuntime implements GameRuntime {
|
||||||
readonly id = "crystal-native-world-v1";
|
readonly id = "crystal-native-world-v1";
|
||||||
world?: GameWorld;
|
world?: GameWorld;
|
||||||
@@ -58,7 +63,7 @@ export class NativeRuntime implements GameRuntime {
|
|||||||
* throws when the game cannot load it. Returns its Savegames entry and
|
* throws when the game cannot load it. Returns its Savegames entry and
|
||||||
* the native schema version it was saved with. */
|
* the native schema version it was saved with. */
|
||||||
inspectState(bytes: Uint8Array) {
|
inspectState(bytes: Uint8Array) {
|
||||||
if (!this.world || bytes.length > 64_000)
|
if (!this.world || bytes.length > MAX_STATE)
|
||||||
throw new Error("Invalid native save size.");
|
throw new Error("Invalid native save size.");
|
||||||
const value: unknown = JSON.parse(
|
const value: unknown = JSON.parse(
|
||||||
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
||||||
@@ -71,6 +76,26 @@ export class NativeRuntime implements GameRuntime {
|
|||||||
schema: typeof schema === "number" ? schema : 0,
|
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() {
|
play() {
|
||||||
if (!this.world || this.raf) return;
|
if (!this.world || this.raf) return;
|
||||||
// Starting play is a user gesture in the app; browsers allow sound then.
|
// 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()));
|
return new TextEncoder().encode(JSON.stringify(this.world.save()));
|
||||||
}
|
}
|
||||||
restoreState(bytes: Uint8Array, continued = true) {
|
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.");
|
throw new Error("Invalid native save size.");
|
||||||
this.world.restore(
|
this.world.restore(
|
||||||
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
|
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function hallOfFameScreen(
|
|||||||
put(
|
put(
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
[...value].flatMap((character) =>
|
(value.match(/ᴾᴷ|ᴹᴺ|[\s\S]/gu) ?? []).flatMap((character) =>
|
||||||
character === '"' ? [0xe1] : textTiles(character),
|
character === '"' ? [0xe1] : textTiles(character),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
PHONE,
|
PHONE,
|
||||||
} from "../rom/profile";
|
} from "../rom/profile";
|
||||||
import { OW } from "./constants";
|
import { OW } from "./constants";
|
||||||
|
import { convertCartridgeSave, type CartridgeConversion } from "./cartridge-save";
|
||||||
import {
|
import {
|
||||||
POKEGEAR_BUTTONS,
|
POKEGEAR_BUTTONS,
|
||||||
pokegearClock,
|
pokegearClock,
|
||||||
@@ -122,6 +123,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
NICKNAME_PATTERN,
|
NICKNAME_PATTERN,
|
||||||
PARTY_LENGTH,
|
PARTY_LENGTH,
|
||||||
|
TRAINER_NAME_PATTERN,
|
||||||
caughtData,
|
caughtData,
|
||||||
validPartyMon,
|
validPartyMon,
|
||||||
type PartyMon,
|
type PartyMon,
|
||||||
@@ -5055,6 +5057,12 @@ export class GameWorld {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- saves
|
// ---------------------------------------------------------------- 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
|
/** The Savegames list entry for a save: the trainer, the landmark (the
|
||||||
* backup map's in LANDMARK_SPECIAL rooms), play time, Johto badges and
|
* backup map's in LANDMARK_SPECIAL rooms), play time, Johto badges and
|
||||||
* caught count. */
|
* caught count. */
|
||||||
@@ -5220,7 +5228,7 @@ export class GameWorld {
|
|||||||
!integer(save.y, 0, 127) ||
|
!integer(save.y, 0, 127) ||
|
||||||
!DIRECTION_ORDER.includes(save.direction) ||
|
!DIRECTION_ORDER.includes(save.direction) ||
|
||||||
typeof save.name !== "string" ||
|
typeof save.name !== "string" ||
|
||||||
!/^[A-Za-z0-9 -]{1,7}$/.test(save.name) ||
|
!TRAINER_NAME_PATTERN.test(save.name) ||
|
||||||
!["chris", "kris"].includes(save.avatar) ||
|
!["chris", "kris"].includes(save.avatar) ||
|
||||||
!integer(save.tilesWalked, 0, Number.MAX_SAFE_INTEGER) ||
|
!integer(save.tilesWalked, 0, Number.MAX_SAFE_INTEGER) ||
|
||||||
!validGameTime(save.gameTime) ||
|
!validGameTime(save.gameTime) ||
|
||||||
|
|||||||
+45
-15
@@ -43,6 +43,7 @@ import {
|
|||||||
validMailMessage,
|
validMailMessage,
|
||||||
} from "./game/party/mail";
|
} from "./game/party/mail";
|
||||||
import { SlotSession, type ImportPreview, type SlotEntry } from "./app/slot-session";
|
import { SlotSession, type ImportPreview, type SlotEntry } from "./app/slot-session";
|
||||||
|
import { isCartridgeSaveSize } from "./game/world/cartridge-save";
|
||||||
import { InputController } from "./input/controller";
|
import { InputController } from "./input/controller";
|
||||||
import { bindControls } from "./input/bindings";
|
import { bindControls } from "./input/bindings";
|
||||||
import { validateRom } from "./rom/validate";
|
import { validateRom } from "./rom/validate";
|
||||||
@@ -2142,10 +2143,18 @@ get<HTMLInputElement>("save-file").addEventListener("change", async (event) => {
|
|||||||
if (!file || busy || !ready()) return;
|
if (!file || busy || !ready()) return;
|
||||||
let preview: ImportPreview;
|
let preview: ImportPreview;
|
||||||
try {
|
try {
|
||||||
preview =
|
if (file.size > 2_000_000)
|
||||||
file.size > 2_000_000
|
preview = { ok: false, reason: "This backup is too large." };
|
||||||
? { ok: false, reason: "This backup is too large." }
|
else {
|
||||||
: await session.previewImport(await file.text());
|
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) {
|
} catch (error) {
|
||||||
showError(error);
|
showError(error);
|
||||||
return;
|
return;
|
||||||
@@ -2184,19 +2193,20 @@ function previewImport(
|
|||||||
actions.append(node);
|
actions.append(node);
|
||||||
};
|
};
|
||||||
if (!preview.ok) {
|
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.`;
|
get("import-text").textContent = `${preview.reason} Your saves are unchanged.`;
|
||||||
row("File", file);
|
row("File", file);
|
||||||
if (typeof preview.identity?.savedAt === "string")
|
if (typeof preview.identity?.savedAt === "string")
|
||||||
row("Saved", savedTime(preview.identity.savedAt));
|
row("Saved", savedTime(preview.identity.savedAt));
|
||||||
button("cancel", "Close", true);
|
button("cancel", "Close", true);
|
||||||
} else {
|
} else {
|
||||||
const { summary } = preview;
|
const { summary, cartridge } = preview;
|
||||||
get("import-title").textContent = summary.newGame
|
get("import-title").textContent = summary.newGame
|
||||||
? "A new game"
|
? "A new game"
|
||||||
: `${summary.player}'s game`;
|
: `${summary.player}'s game`;
|
||||||
get("import-text").textContent =
|
get("import-text").textContent = cartridge
|
||||||
"Check the backup before importing it. Importing continues it in place of the running game, which is saved first.";
|
? "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("File", file);
|
||||||
row(
|
row(
|
||||||
"Trainer",
|
"Trainer",
|
||||||
@@ -2206,13 +2216,33 @@ function previewImport(
|
|||||||
);
|
);
|
||||||
row("Place", summary.newGame ? "Introduction" : summary.place);
|
row("Place", summary.newGame ? "Introduction" : summary.place);
|
||||||
for (const line of summaryLines(summary).slice(1)) row("Progress", line);
|
for (const line of summaryLines(summary).slice(1)) row("Progress", line);
|
||||||
row("Saved", savedTime(preview.record.savedAt));
|
row(cartridge ? "File date" : "Saved", savedTime(preview.record.savedAt));
|
||||||
row(
|
if (cartridge) {
|
||||||
"Format",
|
row(
|
||||||
preview.schema < 3
|
"Format",
|
||||||
? `Native save, game data version ${preview.schema}: upgraded to version 3 on import`
|
cartridge.copy === "main"
|
||||||
: `Native save, game data version ${preview.schema}`,
|
? "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) {
|
if (preview.conflicts.length) {
|
||||||
conflicts.append(element("h3", "", "Saves of the same trainer"));
|
conflicts.append(element("h3", "", "Saves of the same trainer"));
|
||||||
const list = element("ul", "");
|
const list = element("ul", "");
|
||||||
|
|||||||
+2
-2
@@ -61,10 +61,10 @@ export function renderShell(root: HTMLElement) {
|
|||||||
</section>
|
</section>
|
||||||
<section id="panel-saves" class="tab-panel menu-panel" role="tabpanel" aria-labelledby="tab-saves" data-panel="saves" hidden>
|
<section id="panel-saves" class="tab-panel menu-panel" role="tabpanel" aria-labelledby="tab-saves" data-panel="saves" hidden>
|
||||||
<header><p class="menu-title">SAVEGAMES</p><h2>Your saves</h2></header>
|
<header><p class="menu-title">SAVEGAMES</p><h2>Your saves</h2></header>
|
||||||
<div class="save-actions"><button id="save" disabled>Save now</button><button id="new-game" disabled>New game</button><button id="backup" disabled>Export current save</button><button id="import-save" disabled>Import backup</button><input id="save-file" class="file-input" type="file" accept=".json"></div>
|
<div class="save-actions"><button id="save" disabled>Save now</button><button id="new-game" disabled>New game</button><button id="backup" disabled>Export current save</button><button id="import-save" disabled>Import backup</button><input id="save-file" class="file-input" type="file" accept=".json,.sav,.srm,.dat,application/json,application/octet-stream"></div>
|
||||||
<p id="slots-empty" class="slots-empty">Choose your ROM to see the saves made with it.</p>
|
<p id="slots-empty" class="slots-empty">Choose your ROM to see the saves made with it.</p>
|
||||||
<ul id="slot-list" class="slot-list" aria-label="Saves on this device" hidden></ul>
|
<ul id="slot-list" class="slot-list" aria-label="Saves on this device" hidden></ul>
|
||||||
<div class="save-copy"><p>Each save keeps its own playthrough and saves itself every 10 seconds and when paused. Backups include your character, position and completed interactions. They are separate from cartridge .sav files.</p><p>Export before clearing browser data.</p></div>
|
<div class="save-copy"><p>Each save keeps its own playthrough and saves itself every 10 seconds and when paused. Backups include your character, position and completed interactions.</p><p>Import backup also reads a cartridge save (.sav) of the English Pokémon Crystal from an emulator or a cartridge dump, and shows what it holds before anything changes.</p><p>Export before clearing browser data.</p></div>
|
||||||
<div class="replace-rom"><h3>Cartridge</h3><p>Select a ROM again after reloading, or choose another compatible copy.</p><label class="menu-button file-button" for="rom-file"><span aria-hidden="true">▶</span> Choose or replace ROM</label></div><div class="app-settings"><h3>App</h3><p id="install-status">Install Crystal to open it from your home screen and play the app offline. Select your ROM again each time you open it.</p><button id="install-app" class="menu-button" hidden>Install Crystal</button><h3>Storage</h3><p id="storage-status">Checking this browser's storage…</p><button id="persist-storage" class="menu-button" hidden>Keep saves on this device</button></div>
|
<div class="replace-rom"><h3>Cartridge</h3><p>Select a ROM again after reloading, or choose another compatible copy.</p><label class="menu-button file-button" for="rom-file"><span aria-hidden="true">▶</span> Choose or replace ROM</label></div><div class="app-settings"><h3>App</h3><p id="install-status">Install Crystal to open it from your home screen and play the app offline. Select your ROM again each time you open it.</p><button id="install-app" class="menu-button" hidden>Install Crystal</button><h3>Storage</h3><p id="storage-status">Checking this browser's storage…</p><button id="persist-storage" class="menu-button" hidden>Keep saves on this device</button></div>
|
||||||
</section>
|
</section>
|
||||||
<section id="panel-map" class="tab-panel menu-panel map-panel" role="tabpanel" aria-labelledby="tab-map" data-panel="map" hidden>
|
<section id="panel-map" class="tab-panel menu-panel map-panel" role="tabpanel" aria-labelledby="tab-map" data-panel="map" hidden>
|
||||||
|
|||||||
@@ -0,0 +1,439 @@
|
|||||||
|
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",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -165,3 +165,53 @@ test("save slots: migration, new game, rename, continue, duplicate, delete and i
|
|||||||
).toContainText("Gold run");
|
).toContainText("Gold run");
|
||||||
expect(errors).toEqual([]);
|
expect(errors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("save slots: a cartridge .sav is previewed, refused when blank and imported as a new save", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const cartridge = resolve(
|
||||||
|
"debug-saves/Pokémon - Crystal Version/Pokémon - Crystal Version/Pokémon - Crystal Version.sav",
|
||||||
|
);
|
||||||
|
test.skip(
|
||||||
|
!existsSync(romPath) || !existsSync(cartridge),
|
||||||
|
"Requires a local user-supplied ROM.",
|
||||||
|
);
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
await page.goto("/");
|
||||||
|
await page.locator("#rom-file").setInputFiles(romPath);
|
||||||
|
await expect(page.locator("#intro-screen")).toBeVisible();
|
||||||
|
await page.locator("#tab-saves").click();
|
||||||
|
const slots = page.locator("#slot-list > li");
|
||||||
|
await expect(slots).toHaveCount(1);
|
||||||
|
|
||||||
|
// A cartridge save without a game is refused, and nothing changes.
|
||||||
|
const blank = testInfo.outputPath("blank.sav");
|
||||||
|
writeFileSync(blank, new Uint8Array(0x8000).fill(0xff));
|
||||||
|
await page.locator("#save-file").setInputFiles(blank);
|
||||||
|
const preview = page.locator("#import-dialog");
|
||||||
|
await expect(preview).toContainText("This file cannot be imported");
|
||||||
|
await expect(preview).toContainText("holds no saved game");
|
||||||
|
await expect(page.locator("#import-new")).toHaveCount(0);
|
||||||
|
await preview.getByRole("button", { name: "Close" }).click();
|
||||||
|
await expect(slots).toHaveCount(1);
|
||||||
|
|
||||||
|
await page.locator("#save-file").setInputFiles(cartridge);
|
||||||
|
await expect(preview).toContainText("Mattiaᴾᴷ's game");
|
||||||
|
await expect(preview).toContainText("Mattiaᴾᴷ · ID No. 08872");
|
||||||
|
await expect(preview).toContainText("Pokémon Crystal cartridge save");
|
||||||
|
await expect(preview).toContainText("This device's time");
|
||||||
|
await expect(preview).toContainText("Conversion notes");
|
||||||
|
await expect(preview).toContainText("tiles walked count starts from zero");
|
||||||
|
await page.locator("#import-new").click();
|
||||||
|
await expect(page.locator("#status")).toContainText("Backup restored");
|
||||||
|
await expect(slots).toHaveCount(2);
|
||||||
|
await expect(
|
||||||
|
page.locator('#slot-list > li[aria-current="true"]'),
|
||||||
|
).toContainText("Mattiaᴾᴷ");
|
||||||
|
await expect(page.locator("#game-screen")).toHaveAttribute(
|
||||||
|
"data-map",
|
||||||
|
"24:4",
|
||||||
|
);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
@@ -41,6 +41,17 @@ class FakeRuntime implements SlotRuntime {
|
|||||||
inspectState(state: Uint8Array) {
|
inspectState(state: Uint8Array) {
|
||||||
return { summary: summarize(this.parse(state)), schema: 3 };
|
return { summary: summarize(this.parse(state)), schema: 3 };
|
||||||
}
|
}
|
||||||
|
/** A "cartridge save" here is the game's JSON prefixed with "SAV". */
|
||||||
|
convertCartridge(file: Uint8Array) {
|
||||||
|
const text = new TextDecoder().decode(file);
|
||||||
|
if (!text.startsWith("SAV")) throw new Error("Not a cartridge save.");
|
||||||
|
return {
|
||||||
|
state: new TextEncoder().encode(text.slice(3)),
|
||||||
|
copy: "backup" as const,
|
||||||
|
clock: "device" as const,
|
||||||
|
notes: ["A note."],
|
||||||
|
};
|
||||||
|
}
|
||||||
private parse(state: Uint8Array) {
|
private parse(state: Uint8Array) {
|
||||||
const game = JSON.parse(new TextDecoder().decode(state));
|
const game = JSON.parse(new TextDecoder().decode(state));
|
||||||
if (typeof game.player !== "string" || !Number.isInteger(game.steps))
|
if (typeof game.player !== "string" || !Number.isInteger(game.steps))
|
||||||
@@ -436,6 +447,49 @@ describe("save slot import", () => {
|
|||||||
).toEqual([2, 8]);
|
).toEqual([2, 8]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("previews a cartridge save and imports it as a new slot", async () => {
|
||||||
|
const { store, runtime, session } = await loaded();
|
||||||
|
const first = session.active!;
|
||||||
|
const file = new TextEncoder().encode(
|
||||||
|
`SAV${JSON.stringify({ player: "GOLD", id: 9, steps: 4 })}`,
|
||||||
|
);
|
||||||
|
const modified = new Date("2026-10-02T08:00:00.000Z");
|
||||||
|
const preview = await session.previewCartridge(file, modified);
|
||||||
|
if (!preview.ok) throw new Error(preview.reason);
|
||||||
|
expect(preview.summary).toMatchObject({ player: "GOLD", minutes: 4 });
|
||||||
|
expect(preview.record.savedAt).toBe(modified.toISOString());
|
||||||
|
expect(preview.cartridge).toEqual({
|
||||||
|
copy: "backup",
|
||||||
|
clock: "device",
|
||||||
|
notes: ["A note."],
|
||||||
|
});
|
||||||
|
expect(preview.conflicts).toEqual([]);
|
||||||
|
const slot = await session.importBackup(preview, "new");
|
||||||
|
expect(slot.name).toBe("GOLD");
|
||||||
|
expect(runtime.game).toEqual({ player: "GOLD", id: 9, steps: 4 });
|
||||||
|
expect((await store.list(ROM.sha1)).map((s) => s.id)).toContain(first.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a cartridge save that cannot be converted or loaded", async () => {
|
||||||
|
const { store, runtime, session } = await loaded();
|
||||||
|
const slot = session.active!;
|
||||||
|
const before = await store.records(slot.id);
|
||||||
|
const refused = await session.previewCartridge(
|
||||||
|
new TextEncoder().encode("{}"),
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
expect(!refused.ok && refused.reason).toBe("Not a cartridge save.");
|
||||||
|
const unreadable = await session.previewCartridge(
|
||||||
|
new TextEncoder().encode(`SAV${JSON.stringify({ player: 1 })}`),
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
expect(!unreadable.ok && unreadable.reason).toContain(
|
||||||
|
"did not pass the port's checks",
|
||||||
|
);
|
||||||
|
expect(await store.records(slot.id)).toEqual(before);
|
||||||
|
expect(runtime.game.steps).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("tells a conflicting slot that moved on from an identical one", async () => {
|
it("tells a conflicting slot that moved on from an identical one", async () => {
|
||||||
const { session, store, runtime } = await loaded();
|
const { session, store, runtime } = await loaded();
|
||||||
const first = session.active!;
|
const first = session.active!;
|
||||||
|
|||||||
Reference in new issue
Block a user