Read the English 1.0 and Australian cartridges besides 1.1 (#84)
Checks / web (push) Failing after 19s
Private ROM checks / private-rom (push) Failing after 25m36s

PRET's crystal and crystal_au builds reproduce both retail cartridges,
and every label the profile pins keeps its address in all three;
check:reference now verifies that when the rebuilds are present. The
revision comes from the header. Revision 1.0's Battle Tower draws only
the first 21 trainers and picks its texts' gender from the name's sixth
character, checked against each cartridge. A native backup imports
across the three, which share every map, event and object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-17 16:12:00 +02:00
1 parent acdaf62eaa
commit 3c480a28f0
17 files changed
+316 -25

No files matched your search

+2 -2
View File
@@ -49,7 +49,7 @@ npm ci
npm run dev npm run dev
``` ```
Open the printed local URL and select your uncompressed **English US/Europe revision 1.1** ROM (`.gbc`, 2 MiB). Its checksum is verified locally. ROMs are never uploaded or stored in browser storage; select yours again after reloading to resume. A new game starts with Professor Oak's introduction, where you choose your character, set the clock and pick a name. Open the printed local URL and select your uncompressed **English** Pokémon Crystal ROM (`.gbc`, 2 MiB): the US/Europe revision 1.1 or 1.0, or the Australian release. Its checksum is verified locally. Each cartridge keeps its own saves, and a backup exported from one imports into another. Other languages are not supported yet. ROMs are never uploaded or stored in browser storage; select yours again after reloading to resume. A new game starts with Professor Oak's introduction, where you choose your character, set the clock and pick a name.
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.
@@ -76,7 +76,7 @@ npm run test:e2e
npm run check:vendor npm run check:vendor
``` ```
Local-ROM tests use `Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc` at the root, or `CRYSTAL_ROM_PATH`. They skip when absent; no test downloads a ROM. Game files, private reference builds and gameplay screenshots must remain ignored. Local-ROM tests use `Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc` at the root, or `CRYSTAL_ROM_PATH`, which may also point to PRET's 1.0 or Australian rebuild (`make -C .reference/pokecrystal crystal crystal_au`); `tests/revisions.test.ts` and `tests/reference-revisions.spec.ts` use those rebuilds when they are present. They skip when absent; no test downloads a ROM. Game files, private reference builds and gameplay screenshots must remain ignored.
[CI checks](https://git.azuze.fr/kawa/Crystal/actions?workflow=checks.yml) run on pushes and pull requests, or manually from Actions. The [workflow](.gitea/workflows/checks.yml) uses the shared `docker-build` runner with Node 22 in a Debian container. It installs from the lockfile, runs synthetic unit and Chromium/WebKit browser tests, builds both applications, and checks vendor integrity and native release isolation. `CRYSTAL_ROM_PATH` points to a verified absent file, so ROM-dependent tests skip; `check:reference` runs separately with the private ROM and reference build. [CI checks](https://git.azuze.fr/kawa/Crystal/actions?workflow=checks.yml) run on pushes and pull requests, or manually from Actions. The [workflow](.gitea/workflows/checks.yml) uses the shared `docker-build` runner with Node 22 in a Debian container. It installs from the lockfile, runs synthetic unit and Chromium/WebKit browser tests, builds both applications, and checks vendor integrity and native release isolation. `CRYSTAL_ROM_PATH` points to a verified absent file, so ROM-dependent tests skip; `check:reference` runs separately with the private ROM and reference build.
+4
View File
@@ -10,6 +10,9 @@ To recreate the private reference:
git clone https://github.com/pret/pokecrystal.git .reference/pokecrystal git clone https://github.com/pret/pokecrystal.git .reference/pokecrystal
git -C .reference/pokecrystal checkout 7a7881d0d62e0ddbd82dcf10e7116807487ac651 git -C .reference/pokecrystal checkout 7a7881d0d62e0ddbd82dcf10e7116807487ac651
make -C .reference/pokecrystal crystal11 -j4 make -C .reference/pokecrystal crystal11 -j4
# Optional: the US 1.0 and Australian cartridges, for tests/revisions.test.ts
# and tests/reference-revisions.spec.ts.
make -C .reference/pokecrystal crystal crystal_au -j4
npm run check:reference npm run check:reference
``` ```
@@ -62,6 +65,7 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
| `game/party/day-care.ts`, `game/scripts/day-care.ts`, Day-Care and Route 34 | `engine/events/daycare.asm`, `engine/pokemon/breeding.asm`, `engine/pokemon/move_mon.asm`, `engine/pokemon/breedmon_level_growth.asm`, `DayCareStep`, `_GiveOddEgg` | Deposit refusals, level growth/price, move learning on retrieval, compatibility and egg moves/DVs/species, the egg countdown/chance, collection and Odd Egg tables run from the user's ROM. Retrieval keeps the original experience truncation. The save holds both parents and the prepared egg, active/compatible bits, countdown and mother index, with flag consistency validation and absence migration. Day-Care map objects resolve their deposited species' ROM icons. Six controlled-RNG cartridge comparisons cover routine state and byte consumption; native tests cover map scripts and save/reload. Keeper UI/audio timing and fence OAM/palettes are not reference-validated. Hatching is the row above. | | `game/party/day-care.ts`, `game/scripts/day-care.ts`, Day-Care and Route 34 | `engine/events/daycare.asm`, `engine/pokemon/breeding.asm`, `engine/pokemon/move_mon.asm`, `engine/pokemon/breedmon_level_growth.asm`, `DayCareStep`, `_GiveOddEgg` | Deposit refusals, level growth/price, move learning on retrieval, compatibility and egg moves/DVs/species, the egg countdown/chance, collection and Odd Egg tables run from the user's ROM. Retrieval keeps the original experience truncation. The save holds both parents and the prepared egg, active/compatible bits, countdown and mother index, with flag consistency validation and absence migration. Day-Care map objects resolve their deposited species' ROM icons. Six controlled-RNG cartridge comparisons cover routine state and byte consumption; native tests cover map scripts and save/reload. Keeper UI/audio timing and fence OAM/palettes are not reference-validated. Hatching is the row above. |
| `game/party/hall-of-fame.ts`, `game/scripts/hall-of-fame.ts`, `game/scripts/pc.ts` | `engine/events/halloffame.asm` (`GetHallOfFameParty`, `_HallOfFamePC`, `LoadHOFTeam`, `DisplayHOFMon`), `engine/menus/save.asm` (`AddHallOfFameEntry`), `engine/events/pokecenter_pc.asm` (`.ChooseWhichPCListToUse`, `HallOfFamePC`) | Up to 30 recorded teams, newest first, with the capped win count; saved and migrated by absence, separate from the League flag. Nothing records a team until the League is ported. The PC lists HALL OF FAME from the count; pages browse with A, Start and B as the original does, and keep its unreachable "HOF Master!" title. A cartridge comparison of 255 teams checks the tilemap, attributes, graphics and palette; the frontpic animation (`HOF_AnimateFrontpic`) is not presented. | | `game/party/hall-of-fame.ts`, `game/scripts/hall-of-fame.ts`, `game/scripts/pc.ts` | `engine/events/halloffame.asm` (`GetHallOfFameParty`, `_HallOfFamePC`, `LoadHOFTeam`, `DisplayHOFMon`), `engine/menus/save.asm` (`AddHallOfFameEntry`), `engine/events/pokecenter_pc.asm` (`.ChooseWhichPCListToUse`, `HallOfFamePC`) | Up to 30 recorded teams, newest first, with the capped win count; saved and migrated by absence, separate from the League flag. Nothing records a team until the League is ported. The PC lists HALL OF FAME from the count; pages browse with A, Start and B as the original does, and keep its unreachable "HOF Master!" title. A cartridge comparison of 255 teams checks the tilemap, attributes, graphics and palette; the frontpic animation (`HOF_AnimateFrontpic`) is not presented. |
| `game/scripts/league.ts`, the League, the Hall of Fame and the credits | `engine/events/halloffame.asm` (`HallOfFame`, `RedCredits`, `AnimateHallOfFame`, `AnimateHOFMonEntrance`, `HOF_SlideBackpic`, `HOF_SlideFrontpic`, `HOF_AnimatePlayerPic`), `engine/movie/credits.asm` (`Credits`, `ParseCredits`, `Credits_LoadBorderGFX`, `Credits_LYOverride`, `ConstructCreditsTilemap`, `GetCreditsPalette`), `engine/movie/init_hof_credits.asm`, `engine/events/prof_oaks_pc.asm` (`ProfOaksPCBoot`, `ProfOaksPCRating`, `Rate`, `FindOakRating`), `engine/gfx/player_gfx.asm` (`GetPlayerBackpic`, `HOF_LoadTrainerFrontpic`), `home/vblank.asm` (`VBlank_Credits`), `engine/overworld/scripting.asm` (`Script_halloffame`, `Script_credits`, `Script_wait`), `home/text.asm` (`PlaceEnemysName`) | The ceremony records the team and win, pauses the play time and shows each Pokémon's slides and page, the player's card and Oak's rating; the credits run frame by frame (text by thirds, banner tiles by halves, palette VBlanks, border scroll), with each command group's measured spill frames; after the Hall of Fame the world continues at New Bark as Continue would, after Red at Mt. Silver. Checked against the cartridge by `tests/reference-credits.spec.ts` and `tests/reference-hall-of-fame-ceremony.spec.ts`. | | `game/scripts/league.ts`, the League, the Hall of Fame and the credits | `engine/events/halloffame.asm` (`HallOfFame`, `RedCredits`, `AnimateHallOfFame`, `AnimateHOFMonEntrance`, `HOF_SlideBackpic`, `HOF_SlideFrontpic`, `HOF_AnimatePlayerPic`), `engine/movie/credits.asm` (`Credits`, `ParseCredits`, `Credits_LoadBorderGFX`, `Credits_LYOverride`, `ConstructCreditsTilemap`, `GetCreditsPalette`), `engine/movie/init_hof_credits.asm`, `engine/events/prof_oaks_pc.asm` (`ProfOaksPCBoot`, `ProfOaksPCRating`, `Rate`, `FindOakRating`), `engine/gfx/player_gfx.asm` (`GetPlayerBackpic`, `HOF_LoadTrainerFrontpic`), `home/vblank.asm` (`VBlank_Credits`), `engine/overworld/scripting.asm` (`Script_halloffame`, `Script_credits`, `Script_wait`), `home/text.asm` (`PlaceEnemysName`) | The ceremony records the team and win, pauses the play time and shows each Pokémon's slides and page, the player's card and Oak's rating; the credits run frame by frame (text by thirds, banner tiles by halves, palette VBlanks, border scroll), with each command group's measured spill frames; after the Hall of Fame the world continues at New Bark as Continue would, after Red at Mt. Silver. Checked against the cartridge by `tests/reference-credits.spec.ts` and `tests/reference-hall-of-fame-ceremony.spec.ts`. |
| `rom/validate.ts`, `game/rom/revision.ts` | `Makefile` (`crystal`, `crystal11`, `crystal_au`: rgbfix's game code and version), `engine/events/battle_tower/load_trainer.asm` and `trainer_text.asm` (their `_CRYSTAL11` conditionals) | The three English cartridges by SHA-1, and their revision from the header. Revision 1.0 draws Battle Tower trainers from the first 21 and picks their texts' gender from the name's sixth character; the profile's addresses hold for all three (`check:reference`). |
| `game/scripts/kanto.ts` | `engine/events/specials.asm` (`SnorlaxAwake`, `TrainerHouse`), `engine/events/haircut.asm` (`BillsGrandfather`), `engine/events/diploma.asm` (`PlaceDiplomaOnScreen`, `Diploma`, `PrintDiploma`), `engine/gfx/cgb_layouts.asm` (`_CGB_Diploma`) | Snorlax wakes to the Poké Flute channel (wMapMusic) from `.ProximityCoords`; the Trainer House always fights CAL, as without a Mystery Gift partner; Bill's grandpa reads the party Pokémon shown. The Diploma is drawn from DiplomaGFX and its tilemap with the player's name in `PalPacket_Diploma`'s palette; printing it finds no printer. Checked by `tests/reference-kanto.spec.ts`. | | `game/scripts/kanto.ts` | `engine/events/specials.asm` (`SnorlaxAwake`, `TrainerHouse`), `engine/events/haircut.asm` (`BillsGrandfather`), `engine/events/diploma.asm` (`PlaceDiplomaOnScreen`, `Diploma`, `PrintDiploma`), `engine/gfx/cgb_layouts.asm` (`_CGB_Diploma`) | Snorlax wakes to the Poké Flute channel (wMapMusic) from `.ProximityCoords`; the Trainer House always fights CAL, as without a Mystery Gift partner; Bill's grandpa reads the party Pokémon shown. The Diploma is drawn from DiplomaGFX and its tilemap with the player's name in `PalPacket_Diploma`'s palette; printing it finds no printer. Checked by `tests/reference-kanto.spec.ts`. |
| `game/scripts/magnet-train.ts` | `engine/events/magnet_train.asm` (`MagnetTrain`, `MagnetTrain_Jumptable`, `MagnetTrain_UpdateLYOverrides`, `MagnetTrain_LoadGFX_PlayMusic`, `DrawMagnetTrain`, `MagnetTrainBGTiles`, `MagnetTrainTilemap`), `engine/overworld/scripting.asm` (`Script_newloadmap`'s `MAPSETUP_TRAIN`) | The ride frame by frame both ways: the player's sprite (Chris or Kris) running its animation with the last frame flipped, the bushes' and train's row scrolls, the station's palettes with an extra palette frame and eight frames of teardown. Checked against the cartridge by `tests/reference-magnet-train.spec.ts`. | | `game/scripts/magnet-train.ts` | `engine/events/magnet_train.asm` (`MagnetTrain`, `MagnetTrain_Jumptable`, `MagnetTrain_UpdateLYOverrides`, `MagnetTrain_LoadGFX_PlayMusic`, `DrawMagnetTrain`, `MagnetTrainBGTiles`, `MagnetTrainTilemap`), `engine/overworld/scripting.asm` (`Script_newloadmap`'s `MAPSETUP_TRAIN`) | The ride frame by frame both ways: the player's sprite (Chris or Kris) running its animation with the last frame flipped, the bushes' and train's row scrolls, the station's palettes with an extra palette frame and eight frames of teardown. Checked against the cartridge by `tests/reference-magnet-train.spec.ts`. |
| `game/scripts/tin-tower.ts`, Tin Tower and Whirl Islands | `engine/pokemon/search_owned.asm` (`BeastsCheck`, `MonCheck`, `CheckOwnMonAnywhere`, `CheckOwnMon`) | `CheckOwnMonAnywhere` returns nothing with an empty party, then reads the party structs' species (an Egg counts as its species), `sBox` and the thirteen other `sBoxN` copies, matching the player's ID and the OT's first five letters (`NAME_LENGTH_JAPANESE - 2` compares, stopping early at a shared terminator); the Day-Care is not searched, preserving the original's explicitly documented bug even now that deposits run. The original searches the current `sBox` and skips its numbered `sBoxN` copy; the port searches each logical box once. The sages, Suicune, Ho-Oh and Lugia run on ported primitives (`BATTLETYPE_SUICUNE`, `BATTLETYPE_FORCEITEM`). `tests/tin-tower.test.ts` and two playthrough stages cover them. | | `game/scripts/tin-tower.ts`, Tin Tower and Whirl Islands | `engine/pokemon/search_owned.asm` (`BeastsCheck`, `MonCheck`, `CheckOwnMonAnywhere`, `CheckOwnMon`) | `CheckOwnMonAnywhere` returns nothing with an empty party, then reads the party structs' species (an Egg counts as its species), `sBox` and the thirteen other `sBoxN` copies, matching the player's ID and the OT's first five letters (`NAME_LENGTH_JAPANESE - 2` compares, stopping early at a shared terminator); the Day-Care is not searched, preserving the original's explicitly documented bug even now that deposits run. The original searches the current `sBox` and skips its numbered `sBoxN` copy; the port searches each logical box once. The sages, Suicune, Ho-Oh and Lugia run on ported primitives (`BATTLETYPE_SUICUNE`, `BATTLETYPE_FORCEITEM`). `tests/tin-tower.test.ts` and two playthrough stages cover them. |
+10 -1
View File
@@ -4,7 +4,7 @@
Translate Crystal's Game Boy assembly systems into a native TypeScript game. The browser runs the translated engine; the user's original ROM supplies data and assets. The emulator is a separate development reference for checking behavior, never a dependency of the native release. Translate Crystal's Game Boy assembly systems into a native TypeScript game. The browser runs the translated engine; the user's original ROM supplies data and assets. The emulator is a separate development reference for checking behavior, never a dependency of the native release.
PRET's [disassembly](https://github.com/pret/pokecrystal) is pinned at `7a7881d0d62e0ddbd82dcf10e7116807487ac651`. Its `crystal11` build with RGBDS 1.0.3 was verified byte-for-byte against the supplied English US/Europe 1.1 ROM. Required SHA-1: `f2f52230b536214ef7c9924f483392993e226cfb`. Other revisions require separate verified profiles. PRET's [disassembly](https://github.com/pret/pokecrystal) is pinned at `7a7881d0d62e0ddbd82dcf10e7116807487ac651`. Its `crystal11` build with RGBDS 1.0.3 was verified byte-for-byte against the supplied English US/Europe 1.1 ROM (SHA-1 `f2f52230b536214ef7c9924f483392993e226cfb`). Its `crystal` and `crystal_au` builds reproduce the retail English 1.0 (`f4cd194bdee0d04ca4eac29e09b8e4e9d818c133`) and Australian (`a0fc810f1d4e124434f7be2c989ab5b5892ddf36`) cartridges, which the same profile reads (board #84). Other languages require their own verified profiles.
The emulator-backed prototype is now at `/reference.html`. The default application is the native port. The previous plan to progressively modify an emulated ROM has been superseded. The emulator-backed prototype is now at `/reference.html`. The default application is the native port. The previous plan to progressively modify an emulated ROM has been superseded.
@@ -549,6 +549,15 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- Tests: `tests/kanto.test.ts` (Snorlax's coordinates, 2×2 block and flute battle; Bill's grandpa's Everstone; the Trainer House's CAL; the Magnet Train both ways; the Diploma and its printer; Oak opening Mt. Silver; the move tutor; the rival in Mt. Moon and at Indigo Plateau on Mondays but not Tuesdays). The playthrough continues with fourteen stages: the S.S. Aqua, the eight Kanto badges in the order Thunder, Marsh, Cascade (with the Power Plant's manager, the Machine Part and Misty's date), Boulder (after the power returns, Rock Tunnel, the EXPN card and Snorlax), Volcano, Soul (Blue on Cinnabar), Rainbow (Cycling Road) and Earth, Oak opening Mt. Silver, training outside Silver Cave, TM26 from Victory Road, and Red with the credits, ending at `SPAWN_MT_SILVER`. Test saves: `vermilion-city`, `saffron-city`, `celadon-city`, `mt-silver`, `red-beaten`. - Tests: `tests/kanto.test.ts` (Snorlax's coordinates, 2×2 block and flute battle; Bill's grandpa's Everstone; the Trainer House's CAL; the Magnet Train both ways; the Diploma and its printer; Oak opening Mt. Silver; the move tutor; the rival in Mt. Moon and at Indigo Plateau on Mondays but not Tuesdays). The playthrough continues with fourteen stages: the S.S. Aqua, the eight Kanto badges in the order Thunder, Marsh, Cascade (with the Power Plant's manager, the Machine Part and Misty's date), Boulder (after the power returns, Rock Tunnel, the EXPN card and Snorlax), Volcano, Soul (Blue on Cinnabar), Rainbow (Cycling Road) and Earth, Oak opening Mt. Silver, training outside Silver Cave, TM26 from Victory Road, and Red with the credits, ending at `SPAWN_MT_SILVER`. Test saves: `vermilion-city`, `saffron-city`, `celadon-city`, `mt-silver`, `red-beaten`.
- Not presented: Mystery Gift's Trainer House opponents and printing; the Diploma's B prompt cursor (the page has no ▼). - Not presented: Mystery Gift's Trainer House opponents and printing; the Diploma's B prompt cursor (the page has no ▼).
## Implemented: the other English cartridges (board #84, increment A)
- Accepted: the English US/Europe revisions 1.1 and 1.0 and the Australian release (`src/rom/validate.ts`), the three cartridges PRET rebuilds byte for byte. Every label the profile pins has the same address in all three; `check:reference` now verifies that against `pokecrystal.sym` and `pokecrystal_au.sym` whenever those rebuilds are present, and checks their hashes.
- `romRevision` (`game/rom/revision.ts`) tells them apart from the header (game code BYTE or BYTU, mask ROM version 0 or 1). Revision 1.0's two Battle Tower bugs are reproduced: `LoadOpponentTrainerAndPokemon` draws only the first 21 trainers (BATTLETOWER_NUM_UNIQUE_MON), and `BattleTowerText` indexes `BTTrainerClassGenders` with the sixth character of the trainer's name. The Australian release differs only in the Game Corners' and Celadon's texts and one Buena call, which come from the ROM.
- Saves: each cartridge keeps its own list (slots are keyed by the ROM's SHA-1), and a native backup from one of the others imports, with a "Cartridge" line in the preview; the three share every map, event and object, so nothing is converted. Cartridge `.sav` files have the same layout in all three.
- Reference: `tests/reference-revisions.spec.ts` runs the cartridge's `BattleTowerText` on 1.1, 1.0 and the Australian rebuild for every trainer, six random values and the three texts. Tests: `tests/revisions.test.ts` (each cartridge is accepted under its name and recognised from its header, a changed byte is refused, the Battle Tower's trainer range), `tests/save-slots.test.ts` (a backup carried over between cartridges). The whole unit suite and the playthrough from the introduction to Red pass with `CRYSTAL_ROM_PATH` set to each rebuild (only the private CI downloader's 1.1 check is specific).
- Not affected: 1.0's other differences, the Pokédex's `wPokedexStatus` sharing a byte with `wPrevDexEntryBackup` (it misplaces the cursor after a search result's entry; the search mode is not ported) and the mobile Stadium 2 tilemap.
- Not supported: the French, German, Italian, Spanish and Japanese releases. PRET builds none of them, and no copy was available to verify a profile against; each needs its own addresses, character set and name lengths (the Japanese release most of all).
## 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.
+38 -3
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
const root = ".reference/pokecrystal"; const root = ".reference/pokecrystal";
@@ -208,13 +208,17 @@ const ramNames = {
moneyTemp: "hMoneyTemp", moneyTemp: "hMoneyTemp",
}; };
const profile = readFileSync("src/game/rom/profile.ts", "utf8"); const profile = readFileSync("src/game/rom/profile.ts", "utf8");
// Every label the profile pins, for the other revisions' check below.
const used = new Set(["Trainers", "TrainerGroups"]);
for (const [key, symbol] of Object.entries(names)) { for (const [key, symbol] of Object.entries(names)) {
const match = new RegExp(`\\b${key}:\\s*(0x[0-9a-f]+)`, "i").exec(profile); const match = new RegExp(`\\b${key}:\\s*(0x[0-9a-f]+)`, "i").exec(profile);
used.add(symbol);
if (!match || Number(match[1]) !== symbols.get(symbol)) if (!match || Number(match[1]) !== symbols.get(symbol))
throw new Error(`Profile address mismatch: ${key} / ${symbol}`); throw new Error(`Profile address mismatch: ${key} / ${symbol}`);
} }
for (const [key, symbol] of Object.entries(ramNames)) { for (const [key, symbol] of Object.entries(ramNames)) {
const match = new RegExp(`\\b${key}:\\s*(0x[0-9a-f]+)`, "i").exec(profile); const match = new RegExp(`\\b${key}:\\s*(0x[0-9a-f]+)`, "i").exec(profile);
used.add(symbol);
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}`);
} }
@@ -224,6 +228,7 @@ for (const table of ["SAVED_WRAM", "WRAM", "SAVE_BLOCK"]) {
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)) {
const label = quoted ?? bare; const label = quoted ?? bare;
used.add(label);
if (ram.get(label) !== Number(literal)) if (ram.get(label) !== Number(literal))
throw new Error(`${table} RAM mismatch: ${label}`); throw new Error(`${table} RAM mismatch: ${label}`);
} }
@@ -243,16 +248,19 @@ for (const table of ["SAVED_WRAM", "WRAM", "SAVE_BLOCK"]) {
); );
const block = /export const SRAM = \{([\s\S]*?)\} as const;/.exec(profile)?.[1]; const block = /export const SRAM = \{([\s\S]*?)\} as const;/.exec(profile)?.[1];
if (block === undefined) throw new Error("SRAM symbol table is missing."); 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)) for (const [, label, literal] of block.matchAll(/^\s+(\w+):\s*(0x[0-9a-f]+),/gm)) {
used.add(label);
if (sram.get(label) !== Number(literal)) if (sram.get(label) !== Number(literal))
throw new Error(`SRAM mismatch: ${label}`); throw new Error(`SRAM mismatch: ${label}`);
} }
}
// BATTLE, OVERWORLD, PC, INTRO, STATS, MENU, POKEGEAR, MOM, TRAINER_CARD, POKEDEX, OPTIONS, JOHTO, KANTO and AUDIO entries are keyed by their PRET labels. // BATTLE, OVERWORLD, PC, INTRO, STATS, MENU, POKEGEAR, MOM, TRAINER_CARD, POKEDEX, OPTIONS, JOHTO, KANTO 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", "KANTO"]) { for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS", "MENU", "POKEGEAR", "MOM", "TRAINER_CARD", "POKEDEX", "OPTIONS", "JOHTO", "AUDIO", "PHONE", "RADIO", "MAIL", "EGGS", "KANTO"]) {
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)) {
const label = quoted ?? bare; const label = quoted ?? bare;
used.add(label);
if (symbols.get(label) !== Number(literal)) if (symbols.get(label) !== Number(literal))
throw new Error(`${table} symbol mismatch: ${label}`); throw new Error(`${table} symbol mismatch: ${label}`);
} }
@@ -439,6 +447,33 @@ if (
!profile.includes(`LAST_MAP_GROUP_SIZE = ${groups.at(-1)};`) !profile.includes(`LAST_MAP_GROUP_SIZE = ${groups.at(-1)};`)
) )
throw new Error("Map group bounds do not match the reference."); throw new Error("Map group bounds do not match the reference.");
// The other English cartridges (src/rom/validate.ts) read through the same
// profile: when their PRET rebuilds are present (`make crystal crystal_au`),
// they must match the retail hashes and keep every pinned label in place.
const banked = (file) =>
new Map(
readFileSync(file, "utf8")
.split("\n")
.flatMap((line) => {
const match = /^([0-9a-f]{2}:[0-9a-f]{4}) (\S+)$/.exec(line);
return match ? [[match[2], match[1]]] : [];
}),
);
const pinned = banked(`${root}/pokecrystal11.sym`);
const revisions = [];
for (const [name, rom, sha1] of [
["US/Europe 1.0", "pokecrystal", "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133"],
["Australia", "pokecrystal_au", "a0fc810f1d4e124434f7be2c989ab5b5892ddf36"],
]) {
if (!existsSync(`${root}/${rom}.gbc`) || !existsSync(`${root}/${rom}.sym`)) continue;
if (createHash("sha1").update(readFileSync(`${root}/${rom}.gbc`)).digest("hex") !== sha1)
throw new Error(`The ${name} rebuild does not match the retail cartridge.`);
const other = banked(`${root}/${rom}.sym`);
const moved = [...used].filter((label) => other.get(label) !== pinned.get(label));
if (moved.length)
throw new Error(`Profile labels at other addresses in ${name}: ${moved.slice(0, 10).join(", ")}`);
revisions.push(name);
}
console.log( console.log(
"User ROM, pinned PRET rebuild and all native profile addresses match.", `User ROM, pinned PRET rebuild and all native profile addresses match${revisions.length ? `, also in ${revisions.join(" and ")}` : ""}.`,
); );
+8 -2
View File
@@ -1,6 +1,6 @@
import type { GameRuntime } from "./runtime"; import type { GameRuntime } from "./runtime";
import type { SessionState } from "./session"; import type { SessionState } from "./session";
import type { ValidatedRom } from "../rom/validate"; import { supportedRom, type ValidatedRom } from "../rom/validate";
import { encodeSave, type SaveRecord } from "../storage/saves"; import { encodeSave, type SaveRecord } from "../storage/saves";
import { import {
SLOT_VERSION, SLOT_VERSION,
@@ -54,6 +54,9 @@ export type ImportPreview =
conflicts: { slot: SaveSlot; identical: boolean }[]; conflicts: { slot: SaveSlot; identical: boolean }[];
/** Present when the file was a cartridge save. */ /** Present when the file was a cartridge save. */
cartridge?: CartridgeImport; cartridge?: CartridgeImport;
/** The other supported cartridge a native backup was made with; it
* continues with this one. */
madeWith?: string;
} }
| { | {
ok: false; ok: false;
@@ -306,7 +309,9 @@ export class SlotSession {
reason: "This file is not a Crystal backup.", reason: "This file is not a Crystal backup.",
identity, identity,
}; };
if (value.romHash !== this.romHash) // The English cartridges share their native saves.
const other = value.romHash === this.romHash ? undefined : supportedRom(value.romHash);
if (value.romHash !== this.romHash && !other)
return { return {
ok: false, ok: false,
reason: "This backup was made with a different cartridge.", reason: "This backup was made with a different cartridge.",
@@ -348,6 +353,7 @@ export class SlotSession {
record, record,
...inspected, ...inspected,
conflicts: await this.conflicts(record, inspected.summary), conflicts: await this.conflicts(record, inspected.summary),
...(other && { madeWith: other.name }),
}; };
} }
+17
View File
@@ -0,0 +1,17 @@
import type { RomReader } from "./reader";
/** The cartridges the port reads, as PRET builds them (`make crystal`,
* `crystal11` and `crystal_au`): the English US/Europe revisions 1.0 and
* 1.1, and the Australian release, which is 1.1 with the Game Corners'
* gambling texts rewritten. All three keep every label the port reads at the
* same address; only the routines `romRevision` callers check differ. */
export type Revision = "1.0" | "1.1" | "au";
/** The revision from the cartridge header (rgbfix's -i and -n): game code
* BYTU for the Australian release, else BYTE with mask ROM version 0 or 1. */
export function romRevision(r: RomReader): Revision {
const code = String.fromCharCode(r.byte(0x13f), r.byte(0x140), r.byte(0x141), r.byte(0x142));
if (code === "BYTU") return "au";
if (code !== "BYTE") throw new Error("Unknown cartridge game code.");
return r.byte(0x14c) === 0 ? "1.0" : "1.1";
}
+15 -2
View File
@@ -2,6 +2,7 @@ import { JOHTO, RAM, WRAM } from "../rom/profile";
import { maxPP, validPartyMon, type PartyMon } from "../party/pokemon"; import { maxPP, validPartyMon, type PartyMon } from "../party/pokemon";
import type { PokemonData } from "../rom/pokemon"; import type { PokemonData } from "../rom/pokemon";
import { DIRECTION_ORDER, type Direction } from "../world/types"; import { DIRECTION_ORDER, type Direction } from "../world/types";
import { romRevision } from "../rom/revision";
import { readFixedString, readString } from "./text"; import { readFixedString, readString } from "./text";
import type { Gen, SpecialContext } from "./special-context"; import type { Gen, SpecialContext } from "./special-context";
@@ -51,6 +52,8 @@ const MON_STRUCT = 48,
NICKNAMED_MON = MON_STRUCT + 11; NICKNAMED_MON = MON_STRUCT + 11;
/** BattleTowerTrainers' entries: a 10-character name and the class. */ /** BattleTowerTrainers' entries: a 10-character name and the class. */
const TRAINER_ENTRY = 11; const TRAINER_ENTRY = 11;
/** NAME_LENGTH_JAPANESE (constants/text_constants.asm). */
const NAME_LENGTH_JAPANESE = 6;
const HP_UP = 0x1a, const HP_UP = 0x1a,
CALCIUM = 0x1f, CALCIUM = 0x1f,
LUCKY_PUNCH = 0x1e, LUCKY_PUNCH = 0x1e,
@@ -206,7 +209,10 @@ function sample(
* others and none of the last two trainers' species. */ * others and none of the last two trainers' species. */
export function loadOpponent(c: SpecialContext, bt: BattleTowerState): BattleTowerOpponent { export function loadOpponent(c: SpecialContext, bt: BattleTowerState): BattleTowerOpponent {
const r = c.reader; const r = c.reader;
const trainer = sample(c, BATTLE_TOWER.uniqueTrainers, (index) => bt.trainers.includes(index)); // BUG (Crystal 1.0): masked and compared with BATTLETOWER_NUM_UNIQUE_MON,
// so only the first 21 trainers are drawn.
const trainers = romRevision(r) === "1.0" ? BATTLE_TOWER.uniqueMons : BATTLE_TOWER.uniqueTrainers;
const trainer = sample(c, trainers, (index) => bt.trainers.includes(index));
bt.trainers[bt.beaten] = trainer; bt.trainers[bt.beaten] = trainer;
const entry = JOHTO.BattleTowerTrainers + trainer * TRAINER_ENTRY; const entry = JOHTO.BattleTowerTrainers + trainer * TRAINER_ENTRY;
// Copied to wOTPlayerName with a "@" after its ten bytes: ZABOROWSKI // Copied to wOTPlayerName with a "@" after its ten bytes: ZABOROWSKI
@@ -251,7 +257,14 @@ export const battleTowerSprite = (c: SpecialContext, trainerClass: number) =>
* player lost) and 3 (the player won) reuse it. Returns the text. */ * player lost) and 3 (the player won) reuse it. Returns the text. */
export function battleTowerText(c: SpecialContext, bt: BattleTowerState, which: 1 | 2 | 3) { export function battleTowerText(c: SpecialContext, bt: BattleTowerState, which: 1 | 2 | 3) {
const r = c.reader; const r = c.reader;
const trainerClass = bt.opponent?.trainerClass ?? 0; // BUG (Crystal 1.0): the sixth character of wBT_OTName stands in for
// wBT_OTTrainerClass as the index into BTTrainerClassGenders.
const trainerClass =
romRevision(r) === "1.0"
? bt.opponent
? r.byte(JOHTO.BattleTowerTrainers + bt.opponent.trainer * TRAINER_ENTRY + NAME_LENGTH_JAPANESE - 1)
: 0
: (bt.opponent?.trainerClass ?? 0);
const female = r.byte(JOHTO.BTTrainerClassGenders + ((trainerClass - 1) & 0xff)) !== 0; const female = r.byte(JOHTO.BTTrainerClassGenders + ((trainerClass - 1) & 0xff)) !== 0;
const table = female ? JOHTO.BTFemaleTrainerTexts : JOHTO.BTMaleTrainerTexts; const table = female ? JOHTO.BTFemaleTrainerTexts : JOHTO.BTMaleTrainerTexts;
if (which === 1) { if (which === 1) {
+2
View File
@@ -2237,6 +2237,8 @@ function previewImport(
? `Native save, game data version ${preview.schema}: upgraded to version 3 on import` ? `Native save, game data version ${preview.schema}: upgraded to version 3 on import`
: `Native save, game data version ${preview.schema}`, : `Native save, game data version ${preview.schema}`,
); );
if (preview.madeWith)
row("Cartridge", `Made with ${preview.madeWith.replace("Pokémon Crystal · ", "")}; it continues with this one`);
if (cartridge?.notes.length) { if (cartridge?.notes.length) {
conflicts.append(element("h3", "", "Conversion notes")); conflicts.append(element("h3", "", "Conversion notes"));
const list = element("ul", ""); const list = element("ul", "");
+27 -5
View File
@@ -1,9 +1,30 @@
export const CRYSTAL_ROM = { /** The English cartridges the port reads (src/game/rom/revision.ts), as
* PRET rebuilds them byte for byte. Their native saves are interchangeable:
* the three share every map, event and object. */
export const CRYSTAL_ROMS = [
{
name: "Pokémon Crystal · English US/Europe 1.1", name: "Pokémon Crystal · English US/Europe 1.1",
sha1: "f2f52230b536214ef7c9924f483392993e226cfb", sha1: "f2f52230b536214ef7c9924f483392993e226cfb",
},
{
name: "Pokémon Crystal · English US/Europe 1.0",
sha1: "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133",
},
{
name: "Pokémon Crystal · English Australia",
sha1: "a0fc810f1d4e124434f7be2c989ab5b5892ddf36",
},
] as const;
export const CRYSTAL_ROM = {
...CRYSTAL_ROMS[0],
size: 2 * 1024 * 1024, size: 2 * 1024 * 1024,
} as const; } as const;
/** The supported cartridge with this SHA-1, if any. */
export const supportedRom = (sha1: unknown) =>
CRYSTAL_ROMS.find((rom) => rom.sha1 === sha1);
export interface ValidatedRom { export interface ValidatedRom {
bytes: Uint8Array; bytes: Uint8Array;
sha1: string; sha1: string;
@@ -13,7 +34,7 @@ export interface ValidatedRom {
export async function validateRom(file: File): Promise<ValidatedRom> { export async function validateRom(file: File): Promise<ValidatedRom> {
if (file.size !== CRYSTAL_ROM.size) { if (file.size !== CRYSTAL_ROM.size) {
throw new Error( throw new Error(
"Choose an uncompressed Pokémon Crystal English US/Europe 1.1 ROM (.gbc, 2 MiB).", "Choose an uncompressed English Pokémon Crystal ROM (.gbc, 2 MiB).",
); );
} }
if (!globalThis.crypto?.subtle) { if (!globalThis.crypto?.subtle) {
@@ -26,10 +47,11 @@ export async function validateRom(file: File): Promise<ValidatedRom> {
const sha1 = Array.from(new Uint8Array(digest), (byte) => const sha1 = Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, "0"), byte.toString(16).padStart(2, "0"),
).join(""); ).join("");
if (sha1 !== CRYSTAL_ROM.sha1) { const rom = supportedRom(sha1);
if (!rom) {
throw new Error( throw new Error(
"This ROM is not the supported English US/Europe revision 1.1. Other languages, revisions, and modified ROMs are not supported yet.", "This ROM is not a supported English Pokémon Crystal: US/Europe revision 1.0 or 1.1, or the Australian release. Other languages and modified ROMs are not supported yet.",
); );
} }
return { bytes: new Uint8Array(buffer), sha1, name: CRYSTAL_ROM.name }; return { bytes: new Uint8Array(buffer), sha1, name: rom.name };
} }
+1 -1
View File
@@ -22,7 +22,7 @@ export function renderShell(root: HTMLElement) {
<canvas id="game-screen" width="160" height="144" tabindex="0" aria-label="Game screen"></canvas> <canvas id="game-screen" width="160" height="144" tabindex="0" aria-label="Game screen"></canvas>
<div id="screen-empty" class="screen-empty"> <div id="screen-empty" class="screen-empty">
<form class="load-card" onsubmit="return false"> <form class="load-card" onsubmit="return false">
<p class="menu-title">NEW SESSION</p><h1>Load your Crystal ROM</h1><p>English US/Europe · Revision 1.1</p> <p class="menu-title">NEW SESSION</p><h1>Load your Crystal ROM</h1><p>English · US/Europe 1.0 or 1.1, or Australia</p>
<label class="primary-button file-button" id="rom-label" for="rom-file"><span aria-hidden="true">▶</span> Choose ROM</label> <label class="primary-button file-button" id="rom-label" for="rom-file"><span aria-hidden="true">▶</span> Choose ROM</label>
<p class="privacy-note">Opened locally. Never uploaded.</p> <p class="privacy-note">Opened locally. Never uploaded.</p>
</form> </form>
+1 -1
View File
@@ -30,7 +30,7 @@ describe("ROM validation", () => {
bytes[0x14c] = 1; bytes[0x14c] = 1;
await expect( await expect(
validateRom(new File([bytes], "Pokemon Crystal EU 1.1.gbc")), validateRom(new File([bytes], "Pokemon Crystal EU 1.1.gbc")),
).rejects.toThrow("not the supported"); ).rejects.toThrow("not a supported English Pokémon Crystal");
}); });
}); });
+3 -4
View File
@@ -17,7 +17,7 @@ import { readMovement } from "../src/game/scripts/movement";
import { defaultTextContext, readText } from "../src/game/scripts/text"; import { defaultTextContext, readText } from "../src/game/scripts/text";
import { settle, walkTo, withoutPlayTime } from "./world-driver"; import { settle, walkTo, withoutPlayTime } from "./world-driver";
import { drawTile } from "../src/game/rendering/pixels"; import { drawTile } from "../src/game/rendering/pixels";
import { CRYSTAL_ROM } from "../src/rom/validate"; import { supportedRom } from "../src/rom/validate";
import { VECTORS, type Direction } from "../src/game/world/types"; import { VECTORS, type Direction } from "../src/game/world/types";
describe("native ROM formats", () => { describe("native ROM formats", () => {
@@ -159,9 +159,8 @@ describe.skipIf(!existsSync(romPath))(
() => { () => {
const load = () => { const load = () => {
const bytes = readFileSync(romPath); const bytes = readFileSync(romPath);
expect(createHash("sha1").update(bytes).digest("hex")).toBe( // Any of the supported English cartridges (CRYSTAL_ROM_PATH).
CRYSTAL_ROM.sha1, expect(supportedRom(createHash("sha1").update(bytes).digest("hex"))).toBeDefined();
);
return new CrystalAssets(new Uint8Array(bytes)); return new CrystalAssets(new Uint8Array(bytes));
}; };
it("decodes every supported map, tileset, event table and sprite", () => { it("decodes every supported map, tileset, event table and sprite", () => {
+10 -4
View File
@@ -7,6 +7,12 @@ export const romPath = resolve(
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc", "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
); );
export const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym"; export const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym";
/** PRET's rebuilds of the other English cartridges (`make crystal
* crystal_au`), which keep 1.1's addresses for every label the port reads. */
export const revisionRoms = {
"1.0": resolve(".reference/pokecrystal/pokecrystal.gbc"),
au: resolve(".reference/pokecrystal/pokecrystal_au.gbc"),
} as const;
/** Banked addresses from the private PRET rebuild's symbol file. */ /** Banked addresses from the private PRET rebuild's symbol file. */
export function loadSymbols() { export function loadSymbols() {
@@ -34,9 +40,9 @@ export function loadSymbols() {
}; };
} }
/** Open the reference page and hand it the ROM through an isolated file /** Open the reference page and hand it the ROM (by default the user's 1.1)
* input, without an HTTP ROM endpoint. */ * through an isolated file input, without an HTTP ROM endpoint. */
export async function openReference(page: Page) { export async function openReference(page: Page, rom = romPath) {
await page.goto("/reference.html"); await page.goto("/reference.html");
await page.evaluate(() => { await page.evaluate(() => {
const input = document.createElement("input"); const input = document.createElement("input");
@@ -44,5 +50,5 @@ export async function openReference(page: Page) {
input.id = "reference-fixture"; input.id = "reference-fixture";
document.body.append(input); document.body.append(input);
}); });
await page.locator("#reference-fixture").setInputFiles(romPath); await page.locator("#reference-fixture").setInputFiles(rom);
} }
+92
View File
@@ -0,0 +1,92 @@
import { test, expect } from "@playwright/test";
import { existsSync } from "node:fs";
import { romPath, symbolsPath, loadSymbols, openReference, revisionRoms } from "./reference-fixture";
test.skip(!existsSync(romPath) || !existsSync(symbolsPath), "Needs private ROM/reference.");
/** BattleTowerText on each English cartridge, with PrintTextboxTextAt
* patched to keep the text pointer: for every BattleTowerTrainers entry,
* several hRandomAdd values and each of the three texts, the pointer matches
* battleTowerText's. Revision 1.0 indexes BTTrainerClassGenders with the
* name's sixth character instead of the class. */
for (const [revision, rom] of [["1.1", romPath], ["1.0", revisionRoms["1.0"]], ["au", revisionRoms.au]] as const)
test(`BattleTowerText matches the ${revision} cartridge`, async ({ page }) => {
test.skip(!existsSync(rom), `Needs PRET's ${revision} rebuild.`);
test.setTimeout(120_000);
const { at, routine } = loadSymbols();
const a = {
romBank: at("hROMBank"),
program: at("wOverworldMapBlocks"),
stackTop: at("wStackTop"),
randomAdd: at("hRandomAdd"),
name: routine("wBT_OTName"),
textIndex: routine("wBT_TrainerTextIndex"),
};
const routines = { text: routine("BattleTowerText"), print: routine("PrintTextboxTextAt") };
await openReference(page, rom);
const result = await page.evaluate(
async ({ a, routines, revision }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
const { romRevision } = await import("/src/game/rom/revision.ts");
const { JOHTO } = await import("/src/game/rom/profile.ts");
const { battleTowerText, emptyBattleTowerState } = await import("/src/game/scripts/battle-tower.ts");
const rom = new Uint8Array(
await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer(),
);
const assets = new CrystalAssets(rom);
const r = assets.reader;
// PrintTextboxTextAt: keep HL at program + $f0 and return.
const kept = a.program + 0xf0;
[0x7d, 0xea, kept & 255, kept >> 8, 0x7c, 0xea, (kept + 1) & 255, (kept + 1) >> 8, 0xc9].forEach(
(value, i) => (rom[routines.print.address + i] = value),
);
const reference = new BinjgbEmulator();
await reference.load(rom, document.createElement("canvas"));
reference.setMuted(true);
const read = (p: number) => reference.readMemory(p);
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
reference.advanceFrames(300);
const sentinel = a.program + 0xf8;
const failures: unknown[] = [];
let compared = 0;
for (let trainer = 0; trainer < 70; trainer++)
for (const roll of [0x00, 0x0e, 0x13, 0x1f, 0x9b, 0xff]) {
const bt = emptyBattleTowerState();
const entry = JOHTO.BattleTowerTrainers + trainer * 11;
bt.opponent = { trainer, name: "", trainerClass: r.byte(entry + 10), party: [] };
for (const which of [1, 2, 3] as const) {
write(0xff70, a.name.bank);
for (let i = 0; i < 11; i++) write(a.name.address + i, r.byte(entry + i));
write(0xff70, 1);
write(a.randomAdd, roll);
write(sentinel, 0);
const program = [
0xf3, 0x31, a.stackTop & 255, a.stackTop >> 8,
0x3e, routines.text.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20,
0x0e, which,
0xcd, routines.text.address & 255, routines.text.address >> 8,
0x3e, 0x42, 0xea, sentinel & 255, sentinel >> 8,
0x18, 0xfe,
];
program.forEach((v, i) => write(a.program + i, v));
reference.setProgramCounter(a.program);
for (let frame = 0; frame < 10 && read(sentinel) !== 0x42; frame++) reference.advanceFrames(1);
const original = read(kept) | (read(kept + 1) << 8);
// A flat offset in BattleTowerText's bank, as the CPU sees it.
const flat = battleTowerText({ reader: r, random: () => roll } as never, bt, which);
const native = r.bank(flat) === routines.text.bank ? (flat & 0x3fff) | 0x4000 : -flat;
compared++;
if (read(sentinel) !== 0x42 || original !== native)
failures.push({ trainer, roll, which, original: original.toString(16), native: native.toString(16) });
}
}
reference.dispose();
return { revision: romRevision(r), compared, failures: failures.slice(0, 8), failed: failures.length };
},
{ a, routines, revision },
);
expect(result.revision).toBe(revision);
expect(result.failures, JSON.stringify(result)).toEqual([]);
expect(result.compared).toBe(70 * 6 * 3);
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { romRevision } from "../src/game/rom/revision";
import { CRYSTAL_ROMS, validateRom } from "../src/rom/validate";
import { emptyBattleTowerState, loadOpponent } from "../src/game/scripts/battle-tower";
import type { SpecialContext } from "../src/game/scripts/special-context";
/** The user's 1.1 cartridge and PRET's rebuilds of the other English ones
* (`make crystal crystal_au` in .reference/pokecrystal), each skipped when
* absent. */
const cartridges = [
{ revision: "1.1", path: process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc", rom: CRYSTAL_ROMS[0] },
{ revision: "1.0", path: ".reference/pokecrystal/pokecrystal.gbc", rom: CRYSTAL_ROMS[1] },
{ revision: "au", path: ".reference/pokecrystal/pokecrystal_au.gbc", rom: CRYSTAL_ROMS[2] },
] as const;
for (const { revision, path, rom } of cartridges)
describe.skipIf(!existsSync(path))(`the ${revision} cartridge`, () => {
const bytes = existsSync(path) ? new Uint8Array(readFileSync(path)) : new Uint8Array();
it("is accepted under its name and recognised from its header", async () => {
// CRYSTAL_ROM_PATH may point the 1.1 entry at another cartridge.
const assets = new CrystalAssets(bytes);
const own = cartridges.find((c) => c.revision === romRevision(assets.reader))!;
expect(await validateRom(new File([bytes], "crystal.gbc"))).toMatchObject({ sha1: own.rom.sha1, name: own.rom.name });
if (revision !== "1.1" || !process.env.CRYSTAL_ROM_PATH) expect(own.revision).toBe(revision);
// One changed byte is a modified ROM.
const changed = bytes.slice();
changed[0x7fff] ^= 1;
await expect(validateRom(new File([changed], "crystal.gbc"))).rejects.toThrow("not a supported English");
});
it("draws Battle Tower trainers from all 70, or from the first 21 on 1.0", () => {
const assets = new CrystalAssets(bytes);
let seed = 84;
const context = {
reader: assets.reader,
data: assets.pokemon,
random: () => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
},
text: () => ({ player: "KAWA", rival: "SILVER", mom: "MOM", strings: {}, weekday: "SUNDAY" }),
} as unknown as SpecialContext;
const drawn = new Set<number>();
for (let draw = 0; draw < 2000; draw++) {
const bt = { ...emptyBattleTowerState(), levelGroup: 1 + (draw % 10) };
drawn.add(loadOpponent(context, bt).trainer);
}
const limit = romRevision(assets.reader) === "1.0" ? 21 : 70;
expect(Math.max(...drawn)).toBe(limit - 1);
expect(drawn.size).toBe(limit);
});
});
+15
View File
@@ -144,6 +144,21 @@ test("save slots: migration, new game, rename, continue, duplicate, delete and i
await preview.getByRole("button", { name: "Close" }).click(); await preview.getByRole("button", { name: "Close" }).click();
await expect(slots).toHaveCount(2); await expect(slots).toHaveCount(2);
// One from another supported English cartridge carries over.
const us10 = testInfo.outputPath("us10.crystal-save.json");
writeFileSync(
us10,
JSON.stringify({
...JSON.parse(readFileSync(backup, "utf8")),
romHash: "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133",
}),
);
await page.locator("#save-file").setInputFiles(us10);
await expect(preview).toContainText("Made with English US/Europe 1.0; it continues with this one");
await expect(page.locator("#import-new")).toHaveCount(1);
await preview.getByRole("button", { name: "Cancel" }).click();
await expect(slots).toHaveCount(2);
// Importing as a new save adds a slot and continues it. // Importing as a new save adds a slot and continues it.
await page.locator("#save-file").setInputFiles(backup); await page.locator("#save-file").setInputFiles(backup);
await page.locator("#import-new").click(); await page.locator("#import-new").click();
+17
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import "fake-indexeddb/auto"; import "fake-indexeddb/auto";
import { SlotSession, type SlotRuntime } from "../src/app/slot-session"; import { SlotSession, type SlotRuntime } from "../src/app/slot-session";
import { encodeSave, type SaveRecord } from "../src/storage/saves"; import { encodeSave, type SaveRecord } from "../src/storage/saves";
import { CRYSTAL_ROMS } from "../src/rom/validate";
import { import {
SlotRepository, SlotRepository,
recordChecksum, recordChecksum,
@@ -411,6 +412,22 @@ describe("save slot import", () => {
expect(runtime.game.steps).toBe(2); expect(runtime.game.steps).toBe(2);
}); });
it("carries a backup over from another supported English cartridge", async () => {
const [us11, us10] = CRYSTAL_ROMS;
const context = setup();
await context.session.load({ ...ROM, sha1: us11.sha1, name: us11.name }, canvas);
const backup = record({ player: "KRIS", id: 5, steps: 3 });
const preview = await context.session.previewImport(encodeSave({ ...backup, romHash: us10.sha1 }));
if (!preview.ok) throw new Error(preview.reason);
expect(preview.madeWith).toBe(us10.name);
expect(preview.record.romHash).toBe(us11.sha1);
const added = await context.session.importBackup(preview, "new");
expect(added.romHash).toBe(us11.sha1);
// The same file under its own cartridge has nothing to carry over.
const own = await context.session.previewImport(encodeSave({ ...backup, romHash: us11.sha1 }));
expect(own.ok && own.madeWith).toBeUndefined();
});
it("previews conflicts and imports as a new slot or over one", async () => { it("previews conflicts and imports as a new slot or over one", async () => {
const { store, runtime, session } = await loaded(); const { store, runtime, session } = await loaded();
const first = session.active!; const first = session.active!;