Map the French, German, Italian and Spanish releases' addresses (#84)
Checks / web (push) Failing after 18s
Private ROM checks / private-rom (push) Failing after 25m51s

PRET builds none of these releases, so scripts/map-revision.mjs derives
where each keeps the profile's labels from the 1.1 build: a bank-by-bank
alignment, then the exact references RGBDS recorded while linking 1.1,
read at the matching place in the release, each result checked against
its bytes. All but 1-3 labels per release are found; the rest, in code the
release changed, are -1 so reading them refuses. check:reference verifies
the generated file when the ROMs are present.

CrystalAssets now points the profile at the cartridge's addresses, and
the releases' characters (from their own fonts) and two European text
codes decode. The introduction and Mom's scene play in each language and
every map sweeps as in English, but the releases stay refused until the
logic they localize (units, word order, layouts) is ported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-17 17:45:34 +02:00
1 parent 3c480a28f0
commit 627f87d8b5
17 files changed
+1202 -190

No files matched your search

+1 -1
View File
@@ -49,7 +49,7 @@ npm ci
npm run dev npm run dev
``` ```
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. 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. The French, German, Italian and Spanish releases are recognized and their support is in progress; the Japanese release is not supported. 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.
+2 -1
View File
@@ -16,7 +16,7 @@ make -C .reference/pokecrystal crystal crystal_au -j4
npm run check:reference npm run check:reference
``` ```
Install RGBDS 1.0.3 separately for local checks. `CRYSTAL_ROM_PATH` optionally selects another local location; checks otherwise use `Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc` at the root. The private CI workflow downloads the user's ROM from the `CRYSTAL_ROM_URL` repository secret, verifies its size and SHA-1, and builds this reference with the pinned RGBDS 1.0.3 source. It runs only for `main`, never pull requests. Install RGBDS 1.0.3 separately for local checks. With the French, German, Italian and Spanish ROMs at the root (`Pokemon - Version Cristal (France).gbc`, `Pokemon - Kristall-Edition (Germany).gbc`, `Pokemon - Versione Cristallo (Italy).gbc`, `Pokemon - Edicion Cristal (Spain).gbc`), `node scripts/map-revision.mjs` regenerates `src/game/rom/revision-addresses.ts` from the 1.1 build (its objects and map file) and `check:reference` verifies it. `CRYSTAL_ROM_PATH` optionally selects another local location; checks otherwise use `Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc` at the root. The private CI workflow downloads the user's ROM from the `CRYSTAL_ROM_URL` repository secret, verifies its size and SHA-1, and builds this reference with the pinned RGBDS 1.0.3 source. It runs only for `main`, never pull requests.
The reference checkout, rebuilt ROM, extracted graphics, saves and gameplay screenshots must stay in local or private CI workspaces and remain ignored. Private CI deletes them and does not upload artifacts or cache game data. The shipping profile contains addresses and format constants, not game content. The reference checkout, rebuilt ROM, extracted graphics, saves and gameplay screenshots must stay in local or private CI workspaces and remain ignored. Private CI deletes them and does not upload artifacts or cache game data. The shipping profile contains addresses and format constants, not game content.
@@ -66,6 +66,7 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
| `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`). | | `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/rom/revision-addresses.ts` (generated by `scripts/map-revision.mjs`), `game/rom/charset.ts` | `pokecrystal11.o` patches and `.map` (RGBDS object format, rgbds(5)), `home/text.asm` (`PlaceNextChar`'s European $1d and $1e), `gfx/font/font.1bpp` in each release | The French, German, Italian and Spanish releases' addresses for every profile label, derived from 1.1 and checked against each release's bytes; their characters and text codes. `useRevision` switches the profile and characters for the cartridge `CrystalAssets` reads. The releases are recognized but not yet accepted (board #84). |
| `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 -2
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 (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. 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). The French, German, Italian and Spanish releases (SHA-1 `c055992b…`, `accb5842…`, `6cee05e5…`, `889a06fc…`) get their addresses from `scripts/map-revision.mjs`; the Japanese release is not covered.
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.
@@ -556,7 +556,15 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- 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. - 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). - 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 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). - The French, German, Italian and Spanish releases: increment B below. The Japanese release is not covered (no copy, and it differs the most).
## In progress: the French, German, Italian and Spanish releases (board #84, increment B)
- Addresses: `scripts/map-revision.mjs` writes `game/rom/revision-addresses.ts`, where each release keeps the profile's labels. PRET builds none of these releases, so they are derived from its 1.1 build: each bank of the 1.1 ROM is aligned with the release's (unique byte runs, refined inside unequal gaps; equal-length gaps keep their offset), and everything else through the exact references RGBDS recorded while linking 1.1 (every patch in `main11.o` and the other objects, placed with `pokecrystal11.map`), read at the matching place in the release: inside an alignment run, a walk from a resolved label over equal bytes (patched bytes excepted), or the instruction around the pointer near a changed routine. Pointers vote with their bank (the bank byte beside them, the `BANK()` load, or the section's); references resolved while assembling are found by their pointer bytes in a pointer context; labels sharing an address share it; a short text that looks alike in two languages is repaired by the release's own pointers. Each result is checked against the release's bytes (equal data, its patched pointers naming the resolved targets, the same instructions, or a text boundary). Of the 1,358 ROM labels the profile pins, French, German, Italian and Spanish each leave 1-3 unfound, all in code the release changed (the Diploma's certification string; in French and German two Bill's PC strings); they are written as -1, so reading them refuses instead of using 1.1's address. Work and save RAM keep 1.1's layout except the Seer's buffers in French, Italian and Spanish. `check:reference` regenerates the file and compares it when the four ROMs are present.
- Runtime: `CrystalAssets` reads the cartridge's revision from its header (game codes BYTF, BYTD, BYTI, BYTS) and `useRevision` points the profile tables at its addresses (and back to 1.1's for another cartridge). Two tables that copied addresses at import now read them when used.
- Characters (`game/rom/charset.ts`): the releases' fonts, identified glyph by glyph: French and German share à è é ù ß ç ë ï â ô û ê î, the apostrophe ligatures c' d' j' l' m' n' p' s' 's t' u' y' and +; Italian and Spanish share à è é ù À Á È É Ì Í Ñ Ò Ó Ù Ú á ì í ñ ò ó ú º & ¿ ¡ and English's 'd 'l 'm 'r 's 't 'v moved to $d8. All four add two `PlaceNextChar` codes: $1d places "-" and breaks the line, $1e prints nothing (a word break). Names accept those letters and English's umlauts, which the tile encoder now knows.
- Checked: the introduction, Mom's scene and the first steps outside play in each language (`tests/languages.test.ts`), and the local sweep of every open map (each object, sign and warp) runs in all four with the same results as in English.
- Not yet accepted: the cartridge selector recognizes the four releases and refuses them, because parts of their own logic, which the port reimplements, differ from English's. Comparing every routine's code with 1.1's (pointers masked) lists them; besides strings and menus read from the ROM, they include the units of Magikarp's length (`CalcMagikarpLength`, `PrintMagikarpLength`, the Lake of Rage checks in `LoadEnemyMon`) and of the Pokédex (`DisplayDexEntry`), the enemy's name order in French, Italian and Spanish (`PlaceEnemysName`), the clock and calendar (`SetDayOfWeek`, `PrintHour`, `GetTimeOfDayString`, Spanish's "¿" before the hour), the start menu, pack, PC, Bill's PC, Mom's bank and mailbox layouts and texts, the naming screen, the Hall of Fame and trainer card layouts, German's Pokémon Talk and Buena texts, the Diploma, and some French-only scripts (`GiveDratini`, `CheckCaughtCelebi`). These are the next increments; each language is accepted once its playthrough passes.
## Native battles: current state ## Native battles: current state
+14 -159
View File
@@ -1,6 +1,8 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { PROFILE_RAM, PROFILE_SYMBOLS } from "./profile-symbols.mjs";
import { RELEASES, generate } from "./map-revision.mjs";
const root = ".reference/pokecrystal"; const root = ".reference/pokecrystal";
const expected = "f2f52230b536214ef7c9924f483392993e226cfb"; const expected = "f2f52230b536214ef7c9924f483392993e226cfb";
@@ -37,125 +39,7 @@ const symbols = new Map(
]; ];
}), }),
); );
const names = { const names = PROFILE_SYMBOLS;
trainerGroups: "TrainerGroups",
trainerClassNames: "TrainerClassNames",
trainerClassAttributes: "TrainerClassAttributes",
trainerClassDVs: "TrainerClassDVs",
statusOnlyEffects: "StatusOnlyEffects",
stallMoves: "StallMoves",
recklessMoves: "RecklessMoves",
residualMoves: "ResidualMoves",
riskyEffects: "RiskyEffects",
mapGroups: "MapGroupPointers",
tilesets: "Tilesets",
collisions: "CollisionPermissionTable",
environmentColors: "EnvironmentColorsPointers",
backgroundPalettes: "TilesetBGPalette",
objectPalettes: "MapObjectPals",
roofPalettes: "RoofPals",
roofTypes: "MapGroupRoofs",
roofs: "Roofs",
housePalettes: "HousePalette",
sprites: "OverworldSprites",
facings: "Facings",
standardScripts: "StdScripts",
mapScenes: "MapScenes",
specials: "SpecialsPointers",
emotes: "Emotes",
landmarks: "Landmarks",
weekdayNames: "SetDayOfWeek.WeekdayStrings",
whatDayText: "SetDayOfWeek.OakTimeWhatDayIsItText",
isItDayText: "SetDayOfWeek.OakTimeIsItText",
dstConfirmText: "InitialSetDSTFlag.DSTIsThatOKText",
timeConfirmText: "InitialClearDSTFlag.TimeAskOkayText",
baseData: "BaseData",
pokemonNames: "PokemonNames",
pokemonPics: "PokemonPicPointers",
picsBanks: "FixPicBank.PicsBanks",
pokemonPalettes: "PokemonPalettes",
growthRates: "GrowthRates",
evosAttacks: "EvosAttacksPointers",
moves: "Moves",
moveNames: "MoveNames",
itemNames: "ItemNames",
itemAttributes: "ItemAttributes",
itemPocketNames: "ItemPocketNames",
giveItemScript: "GiveItemScript",
putItemInPocketText: "PutItemInPocketText",
pocketIsFullText: "PocketIsFullText",
findItemInBallScript: "FindItemInBallScript",
caughtAskNicknameText: "CaughtAskNicknameText",
stringEgg: "String_Egg",
tmhmMoves: "TMHMMoves",
eggPic: "EggPic",
nicknameStrings: "NamingScreenJumptable.NicknameStrings",
rivalNameString: "NamingScreenJumptable.RivalNameString",
rivalDefaultName: "NameRival.DefaultName",
hiddenItemScript: "HiddenItemScript",
talkToTrainerScript: "TalkToTrainerScript",
seenByTrainerScript: "SeenByTrainerScript",
wildBattleScript: "WildBattleScript",
battleWhiteoutScript: "Script_BattleWhiteout",
spawnPoints: "SpawnPoints",
johtoGrassWildMons: "JohtoGrassWildMons",
grassMonProbTable: "GrassMonProbTable",
grassCollisions: "CheckGrassCollision.blocks",
fruitTreeScript: "FruitTreeScript",
fruitTreeItems: "FruitTreeItems",
engineFlags: "EngineFlags",
spriteMovementData: "SpriteMovementData",
getCurTreeFruit: "GetCurTreeFruit",
tryResetFruitTrees: "TryResetFruitTrees",
checkFruitTree: "CheckFruitTree",
pickedFruitTree: "PickedFruitTree",
trainerWalkToPlayer: "TrainerWalkToPlayer",
setMemEvent: "SetMemEvent",
marts: "Marts",
martWelcomeText: "MartWelcomeText",
martAskMoreText: "MartAskMoreText",
martComeAgainText: "MartComeAgainText",
martHowManyText: "MartHowManyText",
martFinalPriceText: "MartFinalPriceText",
martNoMoneyText: "MartNoMoneyText",
martPackFullText: "MartPackFullText",
martThanksText: "MartThanksText",
martCantBuyText: "MartCantBuyText",
martSellHowManyText: "MartSellHowManyText",
martSellPriceText: "MartSellPriceText",
martBoughtText: "MartBoughtText",
fadeOutToWhite: "FadeOutToWhite",
healParty: "HealParty",
healMachineAnim: "HealMachineAnim",
randomPhoneMon: "RandomPhoneMon",
checkPokerus: "CheckPokerus",
stubbedTrainerRankingsHealings: "StubbedTrainerRankings_Healings",
warpToSpawnPoint: "WarpToSpawnPoint",
battleBGMap: "BattleBGMap",
halveMoney: "HalveMoney",
getWhiteoutSpawn: "GetWhiteoutSpawn",
tryReceiveItem: "FindItemInBallScript.TryReceiveItem",
giveItemDummy: "GiveItemScript_DummyFunction",
restartMapMusic: "RestartMapMusic",
fadeOutMusic: "FadeOutMusic",
fadeOutToBlack: "FadeOutToBlack",
fadeInFromBlack: "FadeInFromBlack",
reloadSpritesNoPalettes: "ReloadSpritesNoPalettes",
receiveSpecialPhoneCall: "CheckSpecialPhoneCall.script",
specialPhoneCallList: "SpecialPhoneCallList",
specialCallOnlyWhenOutside: "SpecialCallOnlyWhenOutside",
specialCallWhereverYouAre: "SpecialCallWhereverYouAre",
phoneClickText: "PhoneClickText",
ringTwiceStartCall: "RingTwice_StartCall",
hangUp: "HangUp",
initCallReceiveDelay: "InitCallReceiveDelay",
nameRival: "NameRival",
setDayOfWeek: "SetDayOfWeek",
initialSetDst: "InitialSetDSTFlag",
initialClearDst: "InitialClearDSTFlag",
toggleDecorationsVisibility: "ToggleDecorationsVisibility",
toggleMaptileDecorations: "ToggleMaptileDecorations",
};
// Work and hardware RAM symbols are compared by their CPU address, not a // Work and hardware RAM symbols are compared by their CPU address, not a
// ROM offset. Hardware RAM (hMoneyTemp and similar) lives at $FF80-$FFFE. // ROM offset. Hardware RAM (hMoneyTemp and similar) lives at $FF80-$FFFE.
const ram = new Map( const ram = new Map(
@@ -168,45 +52,7 @@ const ram = new Map(
return match ? [[match[2], parseInt(match[1], 16)]] : []; return match ? [[match[2], parseInt(match[1], 16)]] : [];
}), }),
); );
const ramNames = { const ramNames = PROFILE_RAM;
playerDamageTaken: "wPlayerDamageTaken",
enemyDamageTaken: "wEnemyDamageTaken",
payDayMoney: "wPayDayMoney",
textDecimalByte: "wTextDecimalByte",
battleReward: "wBattleReward",
stringBuffer1: "wStringBuffer1",
stringBuffer2: "wStringBuffer2",
stringBuffer3: "wStringBuffer3",
stringBuffer4: "wStringBuffer4",
stringBuffer5: "wStringBuffer5",
playerName: "wPlayerName",
momsName: "wMomsName",
rivalName: "wRivalName",
battleMonNickname: "wBattleMonNickname",
enemyMonNickname: "wEnemyMonNickname",
curPartyLevel: "wCurPartyLevel",
monOrItemNameBuffer: "wMonOrItemNameBuffer",
curFruit: "wCurFruit",
dailyFlags1: "wDailyFlags1",
dailyRematchFlags: "wDailyRematchFlags",
dailyPhoneTimeOfDayFlags: "wDailyPhoneTimeOfDayFlags",
unusedDailyFlag: "wUnusedDailyFlag",
itemQuantityChange: "wItemQuantityChange",
movementBuffer: "wMovementBuffer",
hiddenItemID: "wHiddenItemID",
runningTrainerBattleScript: "wRunningTrainerBattleScript",
callerContact: "wCallerContact",
surfingPlayerState: "wSurfingPlayerState",
strengthSpecies: "wStrengthSpecies",
curHPAnimDeltaHP: "wCurHPAnimDeltaHP",
visitedSpawns: "wVisitedSpawns",
tempWildMonSpecies: "wTempWildMonSpecies",
parkBallsRemaining: "wParkBallsRemaining",
coins: "wCoins",
bgMapMode: "hBGMapMode",
blueCardBalance: "wBlueCardBalance",
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. // Every label the profile pins, for the other revisions' check below.
const used = new Set(["Trainers", "TrainerGroups"]); const used = new Set(["Trainers", "TrainerGroups"]);
@@ -474,6 +320,15 @@ for (const [name, rom, sha1] of [
throw new Error(`Profile labels at other addresses in ${name}: ${moved.slice(0, 10).join(", ")}`); throw new Error(`Profile labels at other addresses in ${name}: ${moved.slice(0, 10).join(", ")}`);
revisions.push(name); revisions.push(name);
} }
// The European releases' addresses (src/game/rom/revision-addresses.ts),
// regenerated from 1.1 and compared when their ROMs are present.
const releases = Object.values(RELEASES).filter(({ rom }) => existsSync(rom));
let european = false;
if (releases.length === Object.keys(RELEASES).length) {
if (generate().text !== readFileSync("src/game/rom/revision-addresses.ts", "utf8"))
throw new Error("src/game/rom/revision-addresses.ts is out of date: run node scripts/map-revision.mjs.");
european = true;
} else if (releases.length) throw new Error("Checking the European releases' addresses needs all four ROMs.");
console.log( console.log(
`User ROM, pinned PRET rebuild and all native profile addresses match${revisions.length ? `, also in ${revisions.join(" and ")}` : ""}.`, `User ROM, pinned PRET rebuild and all native profile addresses match${revisions.length ? `, also in ${revisions.join(" and ")}` : ""}${european ? "; the European releases' addresses are up to date" : ""}.`,
); );
+707
View File
@@ -0,0 +1,707 @@
// Where the French, German, Italian and Spanish releases keep the labels the
// profile pins (src/game/rom/profile.ts). PRET builds none of them, so the
// addresses are derived from its English 1.1 build: the 1.1 ROM is aligned
// with each release bank by bank, and the rest is found through the exact
// references RGBDS recorded while linking 1.1 (every patch in the object
// files), read at the matching place in the release. Every result is then
// checked against the release's bytes.
//
// node scripts/map-revision.mjs writes src/game/rom/revision-addresses.ts
// node scripts/map-revision.mjs --check fails if that file is out of date
//
// Needs the pinned PRET checkout built for 1.1 (its .o, .map and .sym) and
// the releases' ROMs at the repository root (or CRYSTAL_<FR|DE|IT|ES>_ROM_PATH).
// MAP_REVISION_DUMP=dir also writes every 1.1 label's place in each release
// (<dir>/<language>.json), for comparing routines.
import { createHash } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { PROFILE_RAM, PROFILE_SYMBOLS } from "./profile-symbols.mjs";
const root = ".reference/pokecrystal";
const output = "src/game/rom/revision-addresses.ts";
export const RELEASES = {
fr: { rom: "Pokemon - Version Cristal (France).gbc", sha1: "c055992b16b7399c687647725cdd1f4f13a2f75c" },
de: { rom: "Pokemon - Kristall-Edition (Germany).gbc", sha1: "accb584293ba056152f1fd908439b019017ff2fe" },
it: { rom: "Pokemon - Versione Cristallo (Italy).gbc", sha1: "6cee05e5b95beeae74b8365ad18ec4a07a8c4af8" },
es: { rom: "Pokemon - Edicion Cristal (Spain).gbc", sha1: "889a06fc0bb863666865aa69def0adf97945ac2a" },
};
const flat = (bank, address) => (address < 0x4000 ? address : bank * 0x4000 + address - 0x4000);
/** name -> [bank, address] from a .sym file. */
function loadSymbols(file) {
const symbols = new Map();
for (const line of readFileSync(file, "utf8").split("\n")) {
const match = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line.trim());
if (match) symbols.set(match[3], [parseInt(match[1], 16), parseInt(match[2], 16)]);
}
return symbols;
}
// ------------------------------------------------------------ RGBDS objects
/** An RGB9 object file (rgbds(5)): its symbols and its ROM sections' patches. */
function readObject(file) {
const b = readFileSync(file);
let i = 0;
const long = () => ((i += 4), b.readInt32LE(i - 4));
const byte = () => b[i++];
const string = () => {
const end = b.indexOf(0, i);
const value = b.toString("latin1", i, end);
i = end + 1;
return value;
};
if (b.toString("latin1", 0, 4) !== "RGB9") throw new Error(`${file} is not an RGB9 object.`);
i = 8;
const symbolCount = long(),
sectionCount = long(),
nodeCount = long();
for (let n = 0; n < nodeCount; n++) {
long();
long();
if (byte() & 0x7f) string();
else for (let depth = long(); depth > 0; depth--) long();
}
const symbols = [];
for (let n = 0; n < symbolCount; n++) {
const name = string();
if (byte() !== 1) (long(), long(), long(), long());
symbols.push(name);
}
const sections = [];
for (let n = 0; n < sectionCount; n++) {
const name = string();
long();
long();
const size = long(),
type = byte();
long();
long();
byte();
long();
const patches = [];
if ((type & 7) === 2 || (type & 7) === 3) {
i += size;
for (let count = long(); count > 0; count--) {
long();
long();
const offset = long();
long();
long();
const kind = byte(),
length = long();
patches.push({ offset, kind, rpn: parseRpn(b.subarray(i, i + length), symbols) });
i += length;
}
}
sections.push({ name, size, type, patches });
}
return sections;
}
/** An RPN expression as ["sym", name], ["bank", name], ["lit", n] or ["op", code]. */
function parseRpn(rpn, symbols) {
const ops = [];
for (let i = 0; i < rpn.length; ) {
const code = rpn[i++];
if (code === 0x50 || code === 0x81) {
ops.push([code === 0x50 ? "bank" : "sym", symbols[rpn.readInt32LE(i)]]);
i += 4;
} else if (code === 0x80) {
ops.push(["lit", rpn.readInt32LE(i)]);
i += 4;
} else if (code === 0x51 || code === 0x53 || code === 0x54) {
const end = rpn.indexOf(0, i);
ops.push(["section", code]);
i = end + 1;
} else if (code === 0x55 || code === 0x56 || code === 0x62) {
ops.push(["op", code, rpn[i++]]);
} else ops.push(["op", code]);
}
return ops;
}
/** Every ROM patch of the 1.1 link: { site, kind, rpn }, sites as ROM offsets. */
function references() {
const placed = new Map();
let bank = 0;
for (const line of readFileSync(`${root}/pokecrystal11.map`, "utf8").split("\n")) {
const header = /^(ROM0|ROMX|WRAM0|WRAMX|SRAM|HRAM|VRAM|OAM) bank #(\d+):/.exec(line);
if (header) bank = Number(header[2]);
const section = /^\s+SECTION: \$([0-9a-f]{4})(?:-\$[0-9a-f]{4})? \(\$[0-9a-f]{4} bytes?\) \["(.*)"\]/.exec(line);
if (section && !placed.has(section[2])) placed.set(section[2], flat(bank, parseInt(section[1], 16)));
}
const fragments = new Map(),
out = [];
for (const object of ["audio11.o", "home11.o", "main11.o", "ram11.o"])
for (const section of readObject(`${root}/${object}`)) {
if (!placed.has(section.name) || ((section.type & 7) !== 2 && (section.type & 7) !== 3)) continue;
let base = placed.get(section.name);
if (section.type & 0x40) {
const before = fragments.get(section.name) ?? 0;
fragments.set(section.name, before + section.size);
base += before;
}
for (const patch of section.patches) out.push({ site: base + patch.offset, kind: patch.kind, rpn: patch.rpn });
}
return out;
}
/** ["sym", name, addend] for symbol + constant, or ["bank", name]. */
function singleSymbol(rpn) {
const named = rpn.filter((op) => op[0] === "sym" || op[0] === "bank");
if (named.length !== 1) return undefined;
if (named[0][0] === "bank") return rpn.length === 1 ? ["bank", named[0][1]] : undefined;
const evaluate = (value) => {
const stack = [];
for (const op of rpn) {
if (op[0] === "sym") stack.push(value);
else if (op[0] === "lit") stack.push(op[1]);
else if (op[0] === "op" && (op[1] === 0 || op[1] === 1) && stack.length >= 2) {
const right = stack.pop(),
left = stack.pop();
stack.push(op[1] === 0 ? left + right : left - right);
} else return undefined;
}
return stack.length === 1 ? stack[0] : undefined;
};
const zero = evaluate(0),
shifted = evaluate(0x1000);
if (zero === undefined || shifted === undefined || shifted - zero !== 0x1000) return undefined;
return ["sym", named[0][1], zero];
}
const PATCH_SIZE = [1, 2, 4, 1];
function indexReferences(refs) {
const words = new Map(),
banks = new Map(),
halves = new Map(),
cover = new Set();
const push = (map, key, value) => (map.get(key) ?? map.set(key, []).get(key)).push(value);
for (const { site, kind, rpn } of refs) {
for (let k = 0; k < PATCH_SIZE[kind]; k++) cover.add(site + k);
if (kind === 0 && rpn.length === 2 && rpn[0][0] === "sym" && rpn[1][0] === "op" && (rpn[1][1] === 0x70 || rpn[1][1] === 0x71))
push(halves, rpn[0][1], [site, rpn[1][1] === 0x70 ? "hi" : "lo"]);
const single = singleSymbol(rpn);
if (!single) continue;
if (single[0] === "bank" && kind === 0) push(banks, single[1], site);
else if (single[0] === "sym" && kind === 1) push(words, single[1], [site, single[2]]);
}
return { words, banks: new Map([...banks].map(([k, v]) => [k, new Set(v)])), halves, cover };
}
// ------------------------------------------------------------ GB-Z80 code
const LENGTH = new Array(256).fill(1);
for (const op of [0x06, 0x0e, 0x16, 0x1e, 0x26, 0x2e, 0x36, 0x3e, 0xc6, 0xce, 0xd6, 0xde, 0xe6, 0xee, 0xf6, 0xfe, 0x18, 0x20, 0x28, 0x30, 0x38, 0xe0, 0xf0, 0xe8, 0xf8, 0xcb, 0x10])
LENGTH[op] = 2;
for (const op of [0x01, 0x11, 0x21, 0x31, 0x08, 0xc2, 0xc3, 0xc4, 0xca, 0xcc, 0xcd, 0xd2, 0xd4, 0xda, 0xdc, 0xea, 0xfa]) LENGTH[op] = 3;
const INVALID = new Set([0xd3, 0xdb, 0xdd, 0xe3, 0xe4, 0xeb, 0xec, 0xed, 0xf4, 0xfc, 0xfd]);
const END = new Set([0xc9, 0xd9, 0xc3, 0xe9, 0x18]);
const LOADS = new Set([0x3e, 0x06, 0x0e, 0x16, 0x1e, 0x26, 0x2e]);
const JUMPS = new Set([0x18, 0x20, 0x28, 0x30, 0x38]);
/** The same instructions for up to n steps, 16-bit operands masked. */
function sameCode(en, fr, e, f, n) {
for (let k = 0; k < n; k++) {
const op = en[e];
if (INVALID.has(op) || fr[f] !== op) return false;
const length = LENGTH[op];
if (length === 2 && !JUMPS.has(op) && en[e + 1] !== fr[f + 1] && !LOADS.has(op)) return false;
e += length;
f += length;
if (END.has(op)) break;
}
return true;
}
// ------------------------------------------------------------ alignment
const BANK = 0x4000;
/** Anchors that occur once in each bank, longest increasing chain. */
function chain(pairs) {
const tails = [],
tailIndex = [],
previous = new Array(pairs.length).fill(-1);
pairs.forEach(([, f], i) => {
let lo = 0,
hi = tails.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (tails[mid] < f) lo = mid + 1;
else hi = mid;
}
tails[lo] = f;
tailIndex[lo] = i;
previous[i] = lo > 0 ? tailIndex[lo - 1] : -1;
});
const out = [];
for (let k = tailIndex.at(-1) ?? -1; k >= 0; k = previous[k]) out.push(pairs[k]);
return out.reverse();
}
function uniqueGrams(text, k, lo, hi) {
const count = new Map();
for (let i = lo; i + k <= hi; i++) {
const gram = text.slice(i, i + k);
count.set(gram, (count.get(gram) ?? 0) + 1);
}
const at = new Map();
for (let i = lo; i + k <= hi; i++) {
const gram = text.slice(i, i + k);
if (count.get(gram) === 1 && new Set(gram).size > Math.min(3, k - 2)) at.set(gram, i);
}
return at;
}
function matchGrams(E, F, k, [e0, e1], [f0, f1]) {
const ge = uniqueGrams(E, k, e0, e1),
gf = uniqueGrams(F, k, f0, f1);
const pairs = [];
for (const [gram, e] of ge) if (gf.has(gram)) pairs.push([e, gf.get(gram)]);
return chain(pairs.sort((a, b) => a[0] - b[0] || a[1] - b[1]));
}
/** Runs [e0, e1, delta] of one bank where 1.1 offset e sits at e + delta. */
function bankRuns(E, F) {
let pairs = [];
for (const k of [16, 10, 7]) {
if (!pairs.length) pairs = matchGrams(E, F, k, [0, E.length], [0, F.length]);
else {
const points = [[-k, -k], ...pairs, [E.length, F.length]];
const found = [...pairs];
for (let i = 0; i + 1 < points.length; i++) {
const [e0, f0] = points[i],
[e1, f1] = points[i + 1];
const a = Math.max(e0 + k, 0),
b = Math.max(f0 + k, 0);
if (e1 - a < k || f1 - b < k || e1 - e0 === f1 - f0) continue;
found.push(...matchGrams(E, F, k, [a, e1], [b, f1]));
}
const unique = [...new Map(found.map((p) => [p.join(), p])).values()];
pairs = chain(unique.sort((a, b) => a[0] - b[0] || a[1] - b[1]));
}
}
const runs = [];
for (const [e, f] of pairs) {
const delta = f - e,
last = runs.at(-1);
if (last && last[2] === delta && e <= last[1] + 64) {
last[1] = Math.max(last[1], e + 7);
last[3]++;
} else runs.push([e, e + 7, delta, 1]);
}
const kept = runs.filter((run) => run[3] >= 2);
// Equal-length gaps between runs keep the delta (only operands changed).
const full = [];
kept.forEach((run, i) => {
full.push(run.slice(0, 3));
if (kept[i + 1]?.[2] === run[2]) full.push([run[1], kept[i + 1][0], run[2]]);
});
return full;
}
function align(en, fr) {
const text = (bytes, bank) => bytes.toString("latin1", bank * BANK, (bank + 1) * BANK);
return Array.from({ length: en.length / BANK }, (_, bank) => bankRuns(text(en, bank), text(fr, bank)));
}
function locate(runs, p) {
const bank = Math.floor(p / BANK),
offset = p % BANK,
list = runs[bank];
let lo = 0,
hi = list.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (list[mid][0] <= offset) lo = mid + 1;
else hi = mid;
}
const run = list[lo - 1];
return run && run[0] <= offset && offset < run[1] ? p + run[2] : undefined;
}
// ------------------------------------------------------------ resolution
function strictMatch(en, fr, e, f) {
if (f === undefined || f + 16 > fr.length) return undefined;
let same = 0;
for (let i = 0; i < 16; i++) if (en[e + i] === fr[f + i]) same++;
if (same >= 14) return "data";
if (![0x00, 0x16, 0x50, 0x57, 0x58].includes(en[e]) && sameCode(en, fr, e, f, 12)) return "code";
return undefined;
}
/** Every 1.1 label's place in the release: label -> ROM offset (or RAM
* address), with how it was found. */
function resolve(en, fr, symbols, refs) {
const runs = align(en, fr);
const { words, banks, halves, cover } = indexReferences(refs);
const rom = new Map(),
sameAddress = new Map();
for (const [name, [bank, address]] of symbols)
if (address < 0x8000) {
const e = flat(bank, address);
rom.set(name, e);
(sameAddress.get(e) ?? sameAddress.set(e, []).get(e)).push(name);
}
const scan = new Map();
for (let p = 0; p + 1 < en.length; p++) {
const w = en[p] | (en[p + 1] << 8);
(scan.get(w) ?? scan.set(w, []).get(w)).push(p);
}
const known = new Map(),
how = new Map();
for (const [name, e] of rom) {
const f = locate(runs, e),
kind = strictMatch(en, fr, e, f);
if (kind) (known.set(name, f), how.set(name, `align-${kind}`));
}
let starts = [],
startAt = new Map();
/** From e (1.1) and f, bytes equal except patched ones, up to p. */
const masked = (e, f, p) => {
for (let d = 0; d <= p - e; d++) {
if (f + d >= fr.length) return undefined;
if (e + d === p) return f + d;
if (!cover.has(e + d) && en[e + d] !== fr[f + d]) return undefined;
}
return undefined;
};
const site = (p) => {
let lo = 0,
hi = starts.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (starts[mid] <= p) lo = mid + 1;
else hi = mid;
}
const i = lo - 1;
for (let j = i; j > i - 8 && j >= 0; j--) {
if (Math.floor(starts[j] / BANK) !== Math.floor(p / BANK) || p - starts[j] > 600) break;
const q = masked(starts[j], startAt.get(starts[j]), p);
if (q !== undefined) return q;
}
// Then the bank alignment: a repeating pattern of small texts can align
// at another offset, which a walk from a resolved label rules out.
const aligned = locate(runs, p);
if (aligned !== undefined) return aligned;
// A few bytes into a resolved label (a menu header's own fields may
// differ before its pointer).
if (i >= 0 && p - starts[i] <= 8 && Math.floor(starts[i] / BANK) === Math.floor(p / BANK))
return startAt.get(starts[i]) + (p - starts[i]);
// A routine the release changed (an inserted instruction): the
// instruction around the pointer, patched bytes as wildcards, found once
// near the resolved routine.
if (i >= 0 && p - starts[i] <= 64 && Math.floor(starts[i] / BANK) === Math.floor(p / BANK)) {
const from = startAt.get(starts[i]);
const pattern = [];
for (let d = -3; d <= 3; d++) pattern.push(cover.has(p + d) ? -1 : en[p + d]);
let hit;
for (let q = from; q < from + 128 && q + 3 < fr.length; q++)
if (pattern.every((b, k) => b < 0 || fr[q - 3 + k] === b)) {
if (hit !== undefined) return undefined;
hit = q;
}
return hit;
}
return undefined;
};
const refresh = () => {
// Labels at one address share it.
for (const names of sameAddress.values()) {
const found = names.find((name) => known.has(name));
if (found) for (const name of names) if (!known.has(name)) (known.set(name, known.get(found)), how.set(name, "alias"));
}
startAt = new Map();
for (const [name, f] of known) if (rom.has(name) && !startAt.has(rom.get(name))) startAt.set(rom.get(name), f);
starts = [...startAt.keys()].sort((a, b) => a - b);
};
/** What the release's bytes at 1.1's references to a label say, ranked. */
const votesFor = (name) => {
const [bank, address] = symbols.get(name);
const inRom = address < 0x8000,
votes = new Map();
const vote = (value, weight) => votes.set(value, (votes.get(value) ?? 0) + weight);
if (inRom && halves.has(name)) {
const at = (half) => halves.get(name).filter(([, h]) => h === half).map(([s]) => site(s));
for (const lo of at("lo"))
for (const hi of at("hi"))
if (lo !== undefined && hi !== undefined) {
const w = fr[lo] | (fr[hi] << 8);
if (w >= 0x4000 && w < 0x8000) vote(bank * BANK + w - 0x4000, 1);
}
}
let sites = words.get(name);
const patched = Boolean(sites);
if (!sites && inRom)
// Resolved while assembling, so no patch: 1.1's pointer bytes in a
// pointer context of the label's own bank, or after its bank (dba).
sites = (scan.get(address) ?? [])
.filter((p) => {
const load = [0x21, 0x11, 0x01].includes(en[p - 1]);
if (load && (bank === 0 || Math.floor(p / BANK) === bank)) return true;
// ld a/b/c, BANK(label) before, or lb bc, BANK(label), n after.
if (load && [0x3e, 0x06, 0x0e, 0x16, 0x1e].includes(en[p - 3]) && en[p - 2] === bank) return true;
if (load && en[p + 2] === 0x01 && en[p + 4] === bank) return true;
return bank && en[p - 1] === bank && Math.floor(p / BANK) !== bank;
})
.map((p) => [p, 0]);
for (const [s, addend] of sites ?? []) {
const q = site(s);
if (q === undefined || q + 1 >= fr.length) continue;
const w = (fr[q] | (fr[q + 1] << 8)) - addend;
if (!inRom) {
vote(w, 1);
continue;
}
if (address < 0x4000) {
if (w >= 0 && w < 0x4000) vote(w, 2);
continue;
}
if (w < 0x4000 || w >= 0x8000) continue;
const siteBank = Math.floor(s / BANK);
let releaseBank;
if (!patched && siteBank !== bank) {
const at =
en[s - 1] === bank ? s - 1 : en[s - 2] === bank && [0x3e, 0x06, 0x0e, 0x16, 0x1e].includes(en[s - 3]) ? s - 2 : en[s + 2] === 0x01 && en[s + 4] === bank ? s + 4 : undefined;
const qb = at === undefined ? undefined : q + (at - s);
if (qb !== undefined) releaseBank = fr[qb];
}
if (releaseBank === undefined)
for (const d of [2, -1, -2, 3, -3, 1, -4, 4])
if (banks.get(name)?.has(s + d)) {
const qb = site(s + d);
if (qb !== undefined) {
releaseBank = fr[qb];
break;
}
}
let weight = 2;
if (releaseBank === undefined) {
if (siteBank === bank) releaseBank = Math.floor(q / BANK);
else ((releaseBank = bank), (weight = 1));
}
if (releaseBank > 0 && releaseBank < 0x80) vote(releaseBank * BANK + w - 0x4000, weight);
}
return [...votes].sort((a, b) => b[1] - a[1]);
};
for (let pass = 0; pass < 6; pass++) {
for (;;) {
refresh();
let added = 0;
for (const name of symbols.keys()) {
if (known.has(name)) continue;
const ranked = votesFor(name);
if (ranked.length && (ranked.length === 1 || ranked[0][1] > ranked[1][1])) {
known.set(name, ranked[0][0]);
how.set(name, `reference ${ranked[0][1]}/${ranked.reduce((sum, [, n]) => sum + n, 0)}`);
added++;
}
}
if (!added) break;
}
// References outrank byte matches outside code: a short text can look
// alike in two languages while the release's pointers name another one.
let repaired = 0;
for (const name of symbols.keys()) {
if (!known.has(name) || how.get(name) === "align-code") continue;
const ranked = votesFor(name);
if (ranked.length === 1 && ranked[0][1] >= 2 && ranked[0][0] !== known.get(name)) {
known.set(name, ranked[0][0]);
how.set(name, `repaired, reference ${ranked[0][1]}`);
repaired++;
}
}
if (!repaired) break;
// Aliases of a repaired label follow it.
for (const names of sameAddress.values())
if (names.some((name) => how.get(name)?.startsWith("repaired")))
for (const name of names) if (how.get(name) === "alias") known.delete(name);
}
// Last, labels nothing points to in a way the release keeps.
refresh();
const patchAt = new Map();
for (const ref of refs) (patchAt.get(ref.site) ?? patchAt.set(ref.site, []).get(ref.site)).push(ref);
for (const [name, [bank, address]] of symbols) {
if (known.has(name) || address >= 0x8000) continue;
const e = flat(bank, address);
// Equal bytes from a resolved label before it (a table's end).
let lo = 0,
hi = starts.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (starts[mid] < e) lo = mid + 1;
else hi = mid;
}
const before = starts[lo - 1];
if (before !== undefined && Math.floor(before / BANK) === Math.floor(e / BANK) && e - before <= 600) {
const q = masked(before, startAt.get(before), e);
if (q !== undefined) {
known.set(name, q);
how.set(name, "walk");
continue;
}
}
// Its first bytes as the release would hold them: fixed bytes, and the
// resolved targets of its pointers; found once.
const pattern = [];
let fixed = 0;
for (let d = 0; d < 16; d++) {
const p = e + d;
const ref = (patchAt.get(p) ?? [])[0];
if (ref && ref.kind === 1 && ref.rpn.length === 1 && ref.rpn[0][0] === "sym" && known.has(ref.rpn[0][1]) && symbols.get(ref.rpn[0][1])[1] < 0x8000) {
const target = known.get(ref.rpn[0][1]);
const word = target < 0x4000 ? target : (target % BANK) + 0x4000;
pattern.push(word & 0xff, word >> 8);
fixed += 2;
d++;
} else if (cover.has(p)) pattern.push(-1);
else (pattern.push(en[p]), fixed++);
}
if (fixed < 8) continue;
const hits = [];
for (let q = 0; q + pattern.length <= fr.length && hits.length < 2; q++)
if (pattern.every((b, k) => b < 0 || fr[q + k] === b)) hits.push(q);
if (hits.length === 1) (known.set(name, hits[0]), how.set(name, "predicted"));
}
return { known, how, cover, votesFor, site };
}
// ------------------------------------------------------------ checks
const TERMINATORS = new Set([0x50, 0x57, 0x58]);
/** How a resolved label agrees with the release's bytes: "data" (equal),
* "pointers" (its patched pointers name the resolved targets), "code" (the
* same instructions), "text" (on a text boundary) or "unverified"; or
* "mismatch" when a pointer names another place. */
function checker(en, fr, symbols, refs, known, cover) {
const patches = new Map();
for (const ref of refs) (patches.get(ref.site) ?? patches.set(ref.site, []).get(ref.site)).push(ref);
const pointer = (name) => {
if (!known.has(name) || !symbols.has(name)) return undefined;
const value = known.get(name);
return symbols.get(name)[1] >= 0x8000 || value < 0x4000 ? value : (value % BANK) + 0x4000;
};
return (name) => {
const e = flat(...symbols.get(name)),
f = known.get(name);
if (f + 32 > fr.length) return "mismatch";
// Patched bytes up to the first other byte that differs: the structure
// both share.
let limit = e;
while (limit < e + 32 && (cover.has(limit) || en[limit] === fr[f + limit - e])) limit++;
let good = 0;
for (let s = e; s < limit; s++)
for (const { kind, rpn } of patches.get(s) ?? []) {
const q = f + s - e;
if (kind === 1 && rpn.length === 1 && rpn[0][0] === "sym") {
const want = pointer(rpn[0][1]);
if (want === undefined) continue;
if ((fr[q] | (fr[q + 1] << 8)) !== want) return "mismatch";
good++;
} else if (kind === 0 && rpn.length === 1 && rpn[0][0] === "bank") {
const target = rpn[0][1];
if (!known.has(target) || symbols.get(target)[1] < 0x4000 || symbols.get(target)[1] >= 0x8000) continue;
if (fr[q] !== Math.floor(known.get(target) / BANK)) return "mismatch";
good++;
}
}
let same = 0;
for (let i = 0; i < 16; i++) if (en[e + i] === fr[f + i]) same++;
if (same >= 14) return "data";
if (good) return "pointers";
if (![0x00, 0x16, 0x50, 0x57, 0x58].includes(en[e]) && sameCode(en, fr, e, f, 12)) return "code";
if (TERMINATORS.has(en[e - 1]) && TERMINATORS.has(fr[f - 1])) return "text";
return "unverified";
};
}
// ------------------------------------------------------------ the profile
/** The profile's address entries: table -> key -> 1.1 label. */
function profileEntries(symbols) {
const profile = readFileSync("src/game/rom/profile.ts", "utf8");
const tables = new Map();
for (const block of profile.matchAll(/export const (\w+)[^=\n]*= \{\n([\s\S]*?)\n\}/g)) {
const entries = new Map();
for (const [, quoted, bare, literal] of block[2].matchAll(/^\s+(?:"([^"]+)"|(\w+)):\s*(0x[0-9a-f]+),/gm)) {
const key = quoted ?? bare;
const label = PROFILE_SYMBOLS[key] ?? PROFILE_RAM[key] ?? (symbols.has(key) ? key : undefined);
if (!label || !symbols.has(label)) continue;
const [bank, address] = symbols.get(label);
if ((address >= 0x8000 ? address : flat(bank, address)) === Number(literal)) entries.set(key, label);
}
if (entries.size) tables.set(block[1], entries);
}
return tables;
}
function generate() {
const symbols = loadSymbols(`${root}/pokecrystal11.sym`);
const en = readFileSync(`${root}/pokecrystal11.gbc`);
const refs = references();
const tables = profileEntries(symbols);
const keys = [...tables].sort(([a], [b]) => a.localeCompare(b)).flatMap(([table, entries]) => [...entries.keys()].sort().map((key) => [table, key]));
const columns = {};
const reports = [];
for (const [language, { rom, sha1 }] of Object.entries(RELEASES)) {
const path = process.env[`CRYSTAL_${language.toUpperCase()}_ROM_PATH`] ?? rom;
const fr = readFileSync(path);
if (createHash("sha1").update(fr).digest("hex") !== sha1) throw new Error(`${path} is not the retail ${language} release.`);
const { known, how, cover } = resolve(en, fr, symbols, refs);
if (process.env.MAP_REVISION_DUMP)
writeFileSync(`${process.env.MAP_REVISION_DUMP}/${language}.json`, JSON.stringify(Object.fromEntries(known)));
const check = checker(en, fr, symbols, refs, known, cover);
const counts = {},
unresolved = [],
mismatched = [],
moved = [];
columns[language] = keys.map(([table, key]) => {
const label = tables.get(table).get(key);
const [bank, address] = symbols.get(label);
if (address >= 0x8000) {
// Work and save RAM keep 1.1's layout except where the release's
// code names other addresses (the Seer's buffers).
if (!known.has(label) || known.get(label) === address) return 0;
moved.push(label);
return known.get(label);
}
if (!known.has(label)) {
unresolved.push(label);
return -1;
}
const result = check(label);
counts[result] = (counts[result] ?? 0) + 1;
if (result === "mismatch") mismatched.push(`${label} (${how.get(label)})`);
return known.get(label) === flat(bank, address) ? 0 : known.get(label);
});
reports.push({ language, counts, unresolved, mismatched, moved });
}
// Only the entries some release moves.
const kept = keys.map((_, i) => i).filter((i) => Object.values(columns).some((values) => values[i] !== 0));
const lines = [
"// Generated by scripts/map-revision.mjs from PRET's English 1.1 build and",
"// each release: where it keeps each profile entry (table.key) whose label",
"// moved from 1.1; 0 where it did not, -1 where the label was not found",
"// there, which the port must not read in that release. Do not edit.",
`export const REVISION_KEYS = ${JSON.stringify(kept.map((i) => `${keys[i][0]}.${keys[i][1]}`))} as const;`,
"export const REVISION_ADDRESSES = {",
...Object.entries(columns).map(([language, values]) => ` ${language}: [${kept.map((i) => values[i]).join(",")}],`),
"} as const;",
];
return { text: lines.join("\n") + "\n", reports };
}
if (import.meta.url === `file://${process.argv[1]}`) {
const { text, reports } = generate();
for (const { language, counts, unresolved, mismatched, moved } of reports) {
console.log(`${language}: ${Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(", ")}; ${unresolved.length} not found`);
if (unresolved.length) console.log(` not found: ${unresolved.join(", ")}`);
if (mismatched.length) console.log(` mismatched: ${mismatched.join(", ")}`);
if (moved.length) console.log(` RAM moved: ${moved.join(", ")}`);
}
if (process.argv.includes("--check")) {
if (readFileSync(output, "utf8") !== text) throw new Error(`${output} is out of date: run node scripts/map-revision.mjs.`);
console.log(`${output} is up to date.`);
} else writeFileSync(output, text);
}
export { generate, resolve, references, loadSymbols, checker, flat };
+162
View File
@@ -0,0 +1,162 @@
// The PRET labels behind the profile's address entries that are not keyed
// by their own label (src/game/rom/profile.ts), shared by check-reference
// and map-revision.
export const PROFILE_SYMBOLS = {
trainerGroups: "TrainerGroups",
trainerClassNames: "TrainerClassNames",
trainerClassAttributes: "TrainerClassAttributes",
trainerClassDVs: "TrainerClassDVs",
statusOnlyEffects: "StatusOnlyEffects",
stallMoves: "StallMoves",
recklessMoves: "RecklessMoves",
residualMoves: "ResidualMoves",
riskyEffects: "RiskyEffects",
mapGroups: "MapGroupPointers",
tilesets: "Tilesets",
collisions: "CollisionPermissionTable",
environmentColors: "EnvironmentColorsPointers",
backgroundPalettes: "TilesetBGPalette",
objectPalettes: "MapObjectPals",
roofPalettes: "RoofPals",
roofTypes: "MapGroupRoofs",
roofs: "Roofs",
housePalettes: "HousePalette",
sprites: "OverworldSprites",
facings: "Facings",
standardScripts: "StdScripts",
mapScenes: "MapScenes",
specials: "SpecialsPointers",
emotes: "Emotes",
landmarks: "Landmarks",
weekdayNames: "SetDayOfWeek.WeekdayStrings",
whatDayText: "SetDayOfWeek.OakTimeWhatDayIsItText",
isItDayText: "SetDayOfWeek.OakTimeIsItText",
dstConfirmText: "InitialSetDSTFlag.DSTIsThatOKText",
timeConfirmText: "InitialClearDSTFlag.TimeAskOkayText",
baseData: "BaseData",
pokemonNames: "PokemonNames",
pokemonPics: "PokemonPicPointers",
picsBanks: "FixPicBank.PicsBanks",
pokemonPalettes: "PokemonPalettes",
growthRates: "GrowthRates",
evosAttacks: "EvosAttacksPointers",
moves: "Moves",
moveNames: "MoveNames",
itemNames: "ItemNames",
itemAttributes: "ItemAttributes",
itemPocketNames: "ItemPocketNames",
giveItemScript: "GiveItemScript",
putItemInPocketText: "PutItemInPocketText",
pocketIsFullText: "PocketIsFullText",
findItemInBallScript: "FindItemInBallScript",
caughtAskNicknameText: "CaughtAskNicknameText",
stringEgg: "String_Egg",
tmhmMoves: "TMHMMoves",
eggPic: "EggPic",
nicknameStrings: "NamingScreenJumptable.NicknameStrings",
rivalNameString: "NamingScreenJumptable.RivalNameString",
rivalDefaultName: "NameRival.DefaultName",
hiddenItemScript: "HiddenItemScript",
talkToTrainerScript: "TalkToTrainerScript",
seenByTrainerScript: "SeenByTrainerScript",
wildBattleScript: "WildBattleScript",
battleWhiteoutScript: "Script_BattleWhiteout",
spawnPoints: "SpawnPoints",
johtoGrassWildMons: "JohtoGrassWildMons",
grassMonProbTable: "GrassMonProbTable",
grassCollisions: "CheckGrassCollision.blocks",
fruitTreeScript: "FruitTreeScript",
fruitTreeItems: "FruitTreeItems",
engineFlags: "EngineFlags",
spriteMovementData: "SpriteMovementData",
getCurTreeFruit: "GetCurTreeFruit",
tryResetFruitTrees: "TryResetFruitTrees",
checkFruitTree: "CheckFruitTree",
pickedFruitTree: "PickedFruitTree",
trainerWalkToPlayer: "TrainerWalkToPlayer",
setMemEvent: "SetMemEvent",
marts: "Marts",
martWelcomeText: "MartWelcomeText",
martAskMoreText: "MartAskMoreText",
martComeAgainText: "MartComeAgainText",
martHowManyText: "MartHowManyText",
martFinalPriceText: "MartFinalPriceText",
martNoMoneyText: "MartNoMoneyText",
martPackFullText: "MartPackFullText",
martThanksText: "MartThanksText",
martCantBuyText: "MartCantBuyText",
martSellHowManyText: "MartSellHowManyText",
martSellPriceText: "MartSellPriceText",
martBoughtText: "MartBoughtText",
fadeOutToWhite: "FadeOutToWhite",
healParty: "HealParty",
healMachineAnim: "HealMachineAnim",
randomPhoneMon: "RandomPhoneMon",
checkPokerus: "CheckPokerus",
stubbedTrainerRankingsHealings: "StubbedTrainerRankings_Healings",
warpToSpawnPoint: "WarpToSpawnPoint",
battleBGMap: "BattleBGMap",
halveMoney: "HalveMoney",
getWhiteoutSpawn: "GetWhiteoutSpawn",
tryReceiveItem: "FindItemInBallScript.TryReceiveItem",
giveItemDummy: "GiveItemScript_DummyFunction",
restartMapMusic: "RestartMapMusic",
fadeOutMusic: "FadeOutMusic",
fadeOutToBlack: "FadeOutToBlack",
fadeInFromBlack: "FadeInFromBlack",
reloadSpritesNoPalettes: "ReloadSpritesNoPalettes",
receiveSpecialPhoneCall: "CheckSpecialPhoneCall.script",
specialPhoneCallList: "SpecialPhoneCallList",
specialCallOnlyWhenOutside: "SpecialCallOnlyWhenOutside",
specialCallWhereverYouAre: "SpecialCallWhereverYouAre",
phoneClickText: "PhoneClickText",
ringTwiceStartCall: "RingTwice_StartCall",
hangUp: "HangUp",
initCallReceiveDelay: "InitCallReceiveDelay",
nameRival: "NameRival",
setDayOfWeek: "SetDayOfWeek",
initialSetDst: "InitialSetDSTFlag",
initialClearDst: "InitialClearDSTFlag",
toggleDecorationsVisibility: "ToggleDecorationsVisibility",
toggleMaptileDecorations: "ToggleMaptileDecorations",
};
// Work and hardware RAM symbols, compared by their CPU address.
export const PROFILE_RAM = {
playerDamageTaken: "wPlayerDamageTaken",
enemyDamageTaken: "wEnemyDamageTaken",
payDayMoney: "wPayDayMoney",
textDecimalByte: "wTextDecimalByte",
battleReward: "wBattleReward",
stringBuffer1: "wStringBuffer1",
stringBuffer2: "wStringBuffer2",
stringBuffer3: "wStringBuffer3",
stringBuffer4: "wStringBuffer4",
stringBuffer5: "wStringBuffer5",
playerName: "wPlayerName",
momsName: "wMomsName",
rivalName: "wRivalName",
battleMonNickname: "wBattleMonNickname",
enemyMonNickname: "wEnemyMonNickname",
curPartyLevel: "wCurPartyLevel",
monOrItemNameBuffer: "wMonOrItemNameBuffer",
curFruit: "wCurFruit",
dailyFlags1: "wDailyFlags1",
dailyRematchFlags: "wDailyRematchFlags",
dailyPhoneTimeOfDayFlags: "wDailyPhoneTimeOfDayFlags",
unusedDailyFlag: "wUnusedDailyFlag",
itemQuantityChange: "wItemQuantityChange",
movementBuffer: "wMovementBuffer",
hiddenItemID: "wHiddenItemID",
runningTrainerBattleScript: "wRunningTrainerBattleScript",
callerContact: "wCallerContact",
surfingPlayerState: "wSurfingPlayerState",
strengthSpecies: "wStrengthSpecies",
curHPAnimDeltaHP: "wCurHPAnimDeltaHP",
visitedSpawns: "wVisitedSpawns",
tempWildMonSpecies: "wTempWildMonSpecies",
parkBallsRemaining: "wParkBallsRemaining",
coins: "wCoins",
bgMapMode: "hBGMapMode",
blueCardBalance: "wBlueCardBalance",
moneyTemp: "hMoneyTemp",
};
+4 -2
View File
@@ -1,4 +1,5 @@
import { PokemonData } from "../rom/pokemon"; import { PokemonData } from "../rom/pokemon";
import { NAME_LETTERS } from "../rom/charset";
import type { Mail } from "./mail"; import type { Mail } from "./mail";
/** A party Pokémon with the fields of the original party_struct. */ /** A party Pokémon with the fields of the original party_struct. */
@@ -75,8 +76,9 @@ export function partyMonDetails(mon: PartyMon): string {
export const PK = "ᴾᴷ", export const PK = "ᴾᴷ",
MN = "ᴹᴺ"; MN = "ᴹᴺ";
/** One character of a name: the naming screen's letters and symbols, plus /** One character of a name: the naming screen's letters and symbols, plus
* the digits and apostrophes of default species names. */ * the digits and apostrophes of default species names and the European
const NAME_CHARACTER = `(?:[A-Za-z0-9 ()":;[\\]?!♂♀/.,×'é-]|${PK}|${MN})`; * releases' letters. */
const NAME_CHARACTER = `(?:[A-Za-z0-9 ()":;[\\]?!♂♀/.,×'é${NAME_LETTERS}-]|${PK}|${MN})`;
/** Characters a nickname can hold (up to ten). */ /** Characters a nickname can hold (up to ten). */
export const NICKNAME_PATTERN = new RegExp(`^${NAME_CHARACTER}{1,10}$`, "u"); 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). */
+3
View File
@@ -1,5 +1,6 @@
import { RomReader } from "./reader"; import { RomReader } from "./reader";
import { decompress } from "./lz"; import { decompress } from "./lz";
import { knownRevision, useRevision } from "./revision";
import { import {
LAST_MAP_GROUP_SIZE, LAST_MAP_GROUP_SIZE,
MAP_GROUP_COUNT, MAP_GROUP_COUNT,
@@ -43,6 +44,8 @@ export class CrystalAssets {
private sceneOwners?: Map<string, string>; private sceneOwners?: Map<string, string>;
constructor(bytes: Uint8Array) { constructor(bytes: Uint8Array) {
this.reader = new RomReader(bytes); this.reader = new RomReader(bytes);
// Synthetic ROMs in tests carry no game code: they read as 1.1.
useRevision(knownRevision(this.reader));
this.pokemon = new PokemonData(this.reader); this.pokemon = new PokemonData(this.reader);
this.pokedex = new PokedexData(this.reader); this.pokedex = new PokedexData(this.reader);
} }
+105
View File
@@ -0,0 +1,105 @@
/** PlaceNextChar's own codes in every European release: $1d places "-"
* and breaks the line (a word hyphenated at a line's end), $1e prints
* nothing (a word break, where the Town Map wraps a long name). */
const EUROPE: Record<number, string> = { 0x1d: "-\n", 0x1e: "" };
/** The characters the European releases add to the English charmap
* (constants/charmap.asm), identified from each release's own 1bpp font
* (Font, $80-$ff). French and German share one font, Italian and Spanish
* another; neither keeps English's apostrophe ligatures at $d0-$d6. */
const NORTH: Record<number, string> = {
...EUROPE,
0xba: "à",
0xbb: "è",
0xbc: "é",
0xbd: "ù",
0xbe: "ß",
0xbf: "ç",
0xc6: "ë",
0xc7: "ï",
0xc8: "â",
0xc9: "ô",
0xca: "û",
0xcb: "ê",
0xcc: "î",
0xcf: "←",
0xd4: "c'",
0xd5: "d'",
0xd6: "j'",
0xd7: "l'",
0xd8: "m'",
0xd9: "n'",
0xda: "p'",
0xdb: "s'",
0xdc: "'s",
0xdd: "t'",
0xde: "u'",
0xdf: "y'",
0xe4: "+",
};
const SOUTH: Record<number, string> = {
...EUROPE,
0xba: "à",
0xbb: "è",
0xbc: "é",
0xbd: "ù",
0xbe: "À",
0xbf: "Á",
0xc6: "È",
0xc7: "É",
0xc8: "Ì",
0xc9: "Í",
0xca: "Ñ",
0xcb: "Ò",
0xcc: "Ó",
0xcd: "Ù",
0xce: "Ú",
0xcf: "á",
0xd0: "ì",
0xd1: "í",
0xd2: "ñ",
0xd3: "ò",
0xd4: "ó",
0xd5: "ú",
0xd6: "º",
0xd7: "&",
0xd8: "'d",
0xd9: "'l",
0xda: "'m",
0xdb: "'r",
0xdc: "'s",
0xdd: "'t",
0xde: "'v",
0xe4: "¿",
0xe5: "¡",
};
export const LANGUAGE_CHARACTERS = { fr: NORTH, de: NORTH, it: SOUTH, es: SOUTH } as const;
/** Every letter a supported release can store in a name: English's
* umlauts ($c0-$c5) and the releases' own. */
export const NAME_LETTERS = [...new Set(
[..."ÄÖÜäöü", ...Object.values(NORTH), ...Object.values(SOUTH)].filter(
(value) => value.length === 1 && /\p{L}/u.test(value),
),
)].join("");
let active: Record<number, string> = {};
let codes = new Map<string, number>();
/** Use a release's characters (none for the English cartridges). */
export function useCharacters(language?: keyof typeof LANGUAGE_CHARACTERS) {
active = language ? LANGUAGE_CHARACTERS[language] : {};
codes = new Map();
for (const [code, value] of Object.entries(active))
if (value.length === 1 && !codes.has(value)) codes.set(value, Number(code));
}
/** The active release's character for a code (undefined when it keeps
* English's). French and German leave $d0-$d3 blank. */
export function releaseCharacter(code: number): string | null | undefined {
if (code in active) return active[code];
if (active === NORTH && code >= 0xd0 && code <= 0xd3) return null;
return undefined;
}
/** The active release's code for a character it adds. */
export const releaseCode = (character: string) => codes.get(character);
+8 -1
View File
@@ -1,5 +1,6 @@
import type { RomReader } from "./reader"; import type { RomReader } from "./reader";
import { JOHTO } from "./profile"; import { JOHTO } from "./profile";
import { releaseCode } from "./charset";
/** TEXTBOX_FRAME_TILES and NUM_FRAMES (constants/text_constants.asm). */ /** TEXTBOX_FRAME_TILES and NUM_FRAMES (constants/text_constants.asm). */
const TEXTBOX_FRAME_TILES = 6, const TEXTBOX_FRAME_TILES = 6,
@@ -51,6 +52,12 @@ const CODES: Record<string, number> = {
";": 0x9d, ";": 0x9d,
"[": 0x9e, "[": 0x9e,
"]": 0x9f, "]": 0x9f,
Ä: 0xc0,
Ö: 0xc1,
Ü: 0xc2,
ä: 0xc3,
ö: 0xc4,
ü: 0xc5,
"'": 0xe0, "'": 0xe0,
"-": 0xe3, "-": 0xe3,
"?": 0xe6, "?": 0xe6,
@@ -81,7 +88,7 @@ export function textTiles(text: string): number[] {
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;
if (character >= "0" && character <= "9") return 0xf6 + code - 48; if (character >= "0" && character <= "9") return 0xf6 + code - 48;
const mapped = CODES[character]; const mapped = CODES[character] ?? releaseCode(character);
if (mapped === undefined) throw new Error(`No tile for "${character}".`); if (mapped === undefined) throw new Error(`No tile for "${character}".`);
return mapped; return mapped;
}); });
+11
View File
@@ -0,0 +1,11 @@
// Generated by scripts/map-revision.mjs from PRET's English 1.1 build and
// each release: where it keeps each profile entry (table.key) whose label
// moved from 1.1; 0 where it did not, -1 where the label was not found
// there, which the port must not read in that release. Do not edit.
export const REVISION_KEYS = ["BATTLE.AlreadyAsleepText","BATTLE.AlreadyConfusedText","BATTLE.AlreadyParalyzedText","BATTLE.AlreadyPoisonedText","BATTLE.AskForgetMoveText","BATTLE.AskGiveNicknameText","BATTLE.AsleepTreeMonsDay","BATTLE.AsleepTreeMonsMorn","BATTLE.AsleepTreeMonsNite","BATTLE.AttackMissed2Text","BATTLE.AttackMissedText","BATTLE.BadgeTypeBoosts","BATTLE.BadlyPoisonedText","BATTLE.BallAlmostHadItText","BATTLE.BallAppearedCaughtText","BATTLE.BallBlockedText","BATTLE.BallBoxFullText","BATTLE.BallBrokeFreeText","BATTLE.BallDontBeAThiefText","BATTLE.BallSentToPCText","BATTLE.BallSoCloseText","BATTLE.BattleCommand_Charge.BattleDugText","BATTLE.BattleCommand_Charge.BattleFlewText","BATTLE.BattleCommand_Charge.BattleGlowingText","BATTLE.BattleCommand_Charge.BattleLoweredHeadText","BATTLE.BattleCommand_Charge.BattleMadeWhirlwindText","BATTLE.BattleCommand_Charge.BattleTookSunlightText","BATTLE.BattleCommand_Charge.UsedText","BATTLE.BattleMonNicknameText","BATTLE.BattleText_AnEGGCantBattle","BATTLE.BattleText_CantEscape","BATTLE.BattleText_CantEscape2","BATTLE.BattleText_EnemyIsAboutToUseWillPlayerChangeMon","BATTLE.BattleText_EnemyMonFainted","BATTLE.BattleText_EnemySentOut","BATTLE.BattleText_EnemyWasDefeated","BATTLE.BattleText_GotAwaySafely","BATTLE.BattleText_ItemHealedConfusion","BATTLE.BattleText_ItemsCantBeUsedHere","BATTLE.BattleText_MonCantBeRecalled","BATTLE.BattleText_MonFainted","BATTLE.BattleText_MonHasNoMovesLeft","BATTLE.BattleText_MonIsAlreadyOut","BATTLE.BattleText_MonsLightScreenFell","BATTLE.BattleText_MonsReflectFaded","BATTLE.BattleText_RainContinuesToFall","BATTLE.BattleText_SafeguardFaded","BATTLE.BattleText_StringBuffer1GrewToLevel","BATTLE.BattleText_TargetRecoveredWithItem","BATTLE.BattleText_TargetWasHitByFutureSight","BATTLE.BattleText_TargetsEncoreEnded","BATTLE.BattleText_TheMoveIsDisabled","BATTLE.BattleText_TheRainStopped","BATTLE.BattleText_TheSandstormRages","BATTLE.BattleText_TheSandstormSubsided","BATTLE.BattleText_TheSunlightFaded","BATTLE.BattleText_TheSunlightIsStrong","BATTLE.BattleText_TheresNoEscapeFromTrainerBattle","BATTLE.BattleText_TheresNoPPLeftForThisMove","BATTLE.BattleText_TheresNoWillToBattle","BATTLE.BattleText_UseNextMon","BATTLE.BattleText_UserFledUsingAStringBuffer1","BATTLE.BattleText_UserHurtBySpikes","BATTLE.BattleText_UserRecoveredPPUsing","BATTLE.BattleText_UserWasReleasedFromStringBuffer1","BATTLE.BattleText_UsersHurtByStringBuffer1","BATTLE.BattleText_UsersStringBuffer1Activated","BATTLE.BattleText_WildFled","BATTLE.BeatUpAttackText","BATTLE.BecameConfusedText","BATTLE.BeganToNapText","BATTLE.BellChimedText","BATTLE.BellyDrumText","BATTLE.BitterBerryEffect","BATTLE.BlewSpikesText","BATTLE.BlownAwayText","BATTLE.BoostedExpPointsText","BATTLE.BracedItselfText","BATTLE.BugContest_SetCaughtContestMon.ContestCaughtMonText","BATTLE.ButItFailedText","BATTLE.CantEscapeNowText","BATTLE.CatchTutorial.Dude","BATTLE.ChooseAMonString","BATTLE.ClampedByText","BATTLE.CoinsScatteredText","BATTLE.ComeBackText","BATTLE.ConfusedNoMoreText","BATTLE.ConsumableEffects","BATTLE.ContestAskSwitchText","BATTLE.CopiedStatsText","BATTLE.CoveredByVeilText","BATTLE.CrashedText","BATTLE.CriticalHitText","BATTLE.DefrostedOpponentText","BATTLE.DestinyBondEffectText","BATTLE.DidNotLearnMoveText","BATTLE.DidntAffect1Text","BATTLE.DidntAffect2Text","BATTLE.DireHitEffect","BATTLE.DisabledMoveText","BATTLE.DisabledNoMoreText","BATTLE.DisplayAlreadyCaughtText.ContestAlreadyCaughtText","BATTLE.DoItMonText","BATTLE.DoesntAffectText","BATTLE.DownpourText","BATTLE.DraggedOutText","BATTLE.DreamEatenText","BATTLE.EliminatedStatsText","BATTLE.EnduredText","BATTLE.EnemyHitTimesText","BATTLE.EnemyText","BATTLE.EnergyRootEffect","BATTLE.EnergypowderEffect","BATTLE.EvadedText","BATTLE.ExpPointsText","BATTLE.FastAsleepText","BATTLE.FellAsleepText","BATTLE.FellInLoveText","BATTLE.FireSpinTrapText","BATTLE.FledFromBattleText","BATTLE.FledInFearText","BATTLE.FlinchedText","BATTLE.ForesawAttackText","BATTLE.FrozenSolidText","BATTLE.FullRestoreEffect","BATTLE.FullyParalyzedText","BATTLE.GettingPumpedText","BATTLE.GoForItMonText","BATTLE.GoMonText","BATTLE.GoodComeBackText","BATTLE.GotAnEncoreText","BATTLE.GotMoneyForWinningText","BATTLE.GuardSpecEffect","BATTLE.HPIsFullText","BATTLE.HappinessChanges","BATTLE.HasANightmareText","BATTLE.HasNoPPLeftText","BATTLE.HasSubstituteText","BATTLE.HealPowderEffect","BATTLE.HealingHPAmounts","BATTLE.HeavyBallMultiplier.WeightsTable","BATTLE.HeavyBall_GetDexEntryBank.PokedexEntryBanks","BATTLE.HeldStatusHealingEffects","BATTLE.HookedPokemonAttackedText","BATTLE.HungOnText","BATTLE.HurtByBurnText","BATTLE.HurtByCurseText","BATTLE.HurtByPoisonText","BATTLE.HurtItselfText","BATTLE.IdentifiedText","BATTLE.IgnoredOrders2Text","BATTLE.IgnoredOrdersText","BATTLE.IgnoredSleepingText","BATTLE.InLoveWithText","BATTLE.InfatuationText","BATTLE.IsConfusedText","BATTLE.IsHMMove.HMMoves","BATTLE.ItFailedText","BATTLE.ItemAttributes","BATTLE.ItemCantUseOnEggText","BATTLE.IteLine truncated
export const REVISION_ADDRESSES = {
fr: [528985,527975,530010,529047,26265,60826,256876,256882,256864,528585,528564,1031749,529018,60772,60767,63433,63453,60762,63438,60816,60777,224300,224295,224290,224285,224275,224280,224224,258802,527258,527410,527290,527163,526842,527214,526889,527359,527947,527492,527546,527068,527633,527521,526595,526633,526670,526565,527681,526477,526532,527660,527606,526760,526728,526811,526784,526700,527309,527573,527231,527082,527382,527429,526501,528020,527995,527473,526265,530720,527928,528323,528953,530605,61711,530538,529248,258351,530380,59122,529804,530108,321009,0,528119,529612,258910,527907,160233,835777,530653,530422,528606,528666,529136,528888,26260,529842,529862,62557,528278,527834,835791,258779,528646,530559,529910,529100,529662,528201,529285,4711,61749,61745,529565,258356,527763,528973,530405,528084,529217,529229,527801,530696,527788,61645,529982,529359,258786,258772,258901,528803,526868,62544,529882,29243,526373,528460,529421,61266,62292,60521,60449,253510,526176,528179,526340,526395,526318,527871,530286,530765,528381,528399,528229,528251,527856,13533,529823,26587,63413,59153,63408,63458,63428,26245,0,529725,528306,529403,1031277,227769,530477,226393,529531,530058,529306,26250,26292,160255,1072770,1072683,1072705,527819,60821,528427,528723,529780,258896,68338,528682,63198,529944,526441,529264,62516,279404,526201,530739,227596,329073,330662,529330,530032,530215,530234,530177,528775,529377,527449,529753,528539,530494,528501,61739,62820,63183,61518,61549,530444,530346,526410,527003,526971,526918,528819,530516,528867,530246,528925,530302,530134,222443,61462,61297,530080,26255,528142,529505,529470,529071,530586,528700,26270,26287,60782,258334,258891,529436,528858,528753,529698,529634,528361,528625,528159,328948,528045,1072652,1072659,1072577,1072674,60811,526246,529126,530162,529584,529151,528999,529549,1031653,1031640,528489,328961,528070,526223,526148,258813,63839,527772,529190,528344,529164,528098,62503,62570,62633,258793,95168,95260,1838551,1838589,1838560,1838562,75027,559274,592741,592735,592730,298334,0,560058,24813,24824,71596,592022,591995,1838283,1842383,1842495,1842550,1842664,1851420,1819420,1819423,1819452,1819455,1819500,1819412,1819390,1819444,1819313,1819475,924544,0,0,89755,89185,89730,89725,89750,89740,89745,89735,0,1942651,0,304732,29711,79408,79427,0,79803,0,920855,921647,920796,920823,920839,922259,920724,919938,29126,0,317577,317917,317912,0,1073943,317940,923196,12768,150408,80458,79669,79651,79692,79674,79715,79697,80489,29749,91984,91900,92508,92601,92616,92621,92522,92606,92611,95310,95341,95443,95433,95448,95438,95453,90668,0,0,568937,0,1950436,0,324090,923158,29095,1383442,0,79379,29471,29827,29809,29818,89690,89720,89700,89695,89715,89710,89705,568796,924208,922612,924976,95375,95380,0,1031319,1031341,569048,304773,0,181589,181732,181762,181752,181737,181757,181747,181742,181727,0,0,0,29739,568816,925216,0,89790,89770,89765,89760,89785,89780,89775,93684,93765,93750,93760,93745,0,919636,0,323792,92368,317922,923818,923286,923323,602939,602957,602975,0,29590,88830,88847,324325,324256,324286,324281,324261,324276,324251,324266,324271,80352,0,603233,603829,604437,602993,602457,602184,602170,29584,29578,150608,0,150621,150615,76035,1157816,1157710,1157748,1157854,0,0,171631,324116,1069249,256932,0,923360,923980,924044,569021,6834,58284,0,29744,1855399,1855487,1855377,1855419,1855430,1855355,1855448,1855479,550666,1823134,1823107,1854563,1854676,1854651,1854588,1854636,1854610,1854583,550187,29711,178723,1093143,0,1955881,1957679,551146,321750,560855,299449,-1,1955718,0,157167,0,0,0,760125,760205,760221,761237,761037,761301,761397,760941,760189,760181,761125,760469,760661,760277,760421,758535,760373,761005,760517,760229,760877,760325,760973,760141,760117,761365,761101,760149,761437,760845,760133,760173,1959834,760909,760197,761173,760157,68358,68328,68323,63217,60845,62772,62778,182482,182477,68353,63210,62783,62789,271486,182487,62452,60852,271491,271501,271804,0,76960,62798,63244,77012,61003,76997,62813,76388,63915,761487,62475,78690,66299,66262,0,0,0,66128,77334,77324,77319,77329,77344,77339,77297,0,150752,77605,63240,62794,63261,63188,63193,0,0,0,0,0,0,77007,76982,76992,76987,76977,77002,63178,61113,68348,62479,62498,63224,329751,63203,75508,61072,61008,271496,78224,62471,62802,182492,68333,60898,68343,90672,91868,91409,91404,91414,91424,91419,91429,91765,91785,91795,91760,91820,91745,91750,91755,91800,91790,91780,91805,91770,91810,91775,91815,91843,91851,91825,91691,91835,75056,75036,70628,79495,74497,934815,934811,934875,934868,0,0,935041,935049,935025,935017,935033,934939,934946,934723,934725,934727,159472,51586,53624,53641,79151,52907,51707,300206,79395,53660,51581,50956,621960,51167,51096,51249,158293,159726,159712,159631,159542,158611,52716,79268,79193,52213,52228,52218,51023,599199,754948,53307,51826,51866,597109,50901,53067,52854,12291,300285,172713,75170,75203,75190,598038,176422,176765,598399,178176,755092,754885,300507,300636,159457,9936,0,15536,0,300127,159467,15945Line truncated
de: [529315,528124,530410,529380,26272,60873,256874,256880,256862,528854,528828,1031749,529355,60819,60814,63475,63495,60809,63480,60863,60824,224296,224291,224286,224281,224271,224276,224220,258800,527293,527442,527321,527187,526817,527238,526864,527385,528085,527528,527593,527080,527708,527572,526610,526638,526664,526578,527765,526493,526550,527744,527680,526733,526715,526790,526762,526687,527340,527632,527269,527100,527405,527461,526519,528162,528146,527507,526259,531166,528068,528552,529280,531046,61753,530972,529589,258349,530813,59169,530224,530516,321001,328922,528313,530002,258908,528040,160233,835780,531101,530853,528880,528953,529458,529218,26267,530251,530282,62599,528511,527948,835794,258777,528930,530994,530349,529426,530077,528418,529635,4713,61791,61787,529956,258354,527841,529303,530835,528254,529557,529568,527898,531147,527877,61687,530390,529716,258784,258770,258899,529107,526845,62586,530311,29250,526401,528741,529784,61308,62334,60568,60496,253509,526168,528391,526338,526423,526317,527995,530725,531218,528621,528644,528452,528475,527978,13530,530241,26594,63455,59200,63450,63500,63470,26252,526366,530159,528537,529754,1031277,227765,530908,226389,529917,530457,529661,26257,26299,160255,1072761,1072674,1072696,527918,60868,528685,529023,530205,258894,68330,528967,63240,530370,526462,529609,62558,279404,0,531181,227592,329070,330663,529693,530431,530644,530668,530594,529083,529736,527482,530181,528812,530928,528784,61781,62862,63225,61560,61591,530875,530788,526441,526998,526958,526905,529128,530941,529176,530705,529253,530745,530539,222439,61504,61339,530489,26262,528353,529884,529848,529406,531021,528991,26277,26294,60829,258332,258889,529814,529161,529061,530121,530039,528600,528911,528371,328935,528189,1072643,1072650,1072568,1072665,60858,526240,529447,530574,529976,529476,529335,529936,1031653,1031640,528768,328946,528232,526221,526149,258811,63881,527858,529528,528579,529498,528276,62545,62612,62675,258791,95161,95253,1839014,1839055,1839023,1839025,75000,559270,592770,592760,592754,298334,557804,560054,24820,24831,71573,592033,592004,1838737,1843476,1843599,1843659,1843788,1851415,1819501,1819504,1819531,1819534,1819589,1819494,1819476,1819524,1819381,1819559,924163,150691,0,89750,89180,89725,89720,89745,89735,89740,89730,0,1943064,569910,304739,29721,79351,79370,0,79746,0,920856,921258,920797,920824,920840,921870,920725,919939,29133,0,317577,317917,317912,0,1073934,317940,922807,12765,150408,80401,79612,79594,79635,79617,79658,79640,80432,29759,91980,91896,92504,92597,92612,92617,92518,92602,92607,95303,95334,95436,95426,95441,95431,95446,90663,0,0,568933,569898,1950431,0,324092,922769,29102,569710,569794,79322,29478,29837,29819,29828,89685,89715,89695,89690,89710,89705,89700,568792,923827,922223,924595,95368,95373,0,1031319,1031341,569043,304780,0,181578,181721,181751,181741,181726,181746,181736,181731,181716,0,0,0,29749,568812,924835,569925,89785,89765,89760,89755,89780,89775,89770,93677,93758,93743,93753,93738,0,919637,0,323794,92364,317922,923429,922897,922934,602947,602965,602983,0,29600,88825,88842,324334,324265,324295,324290,324270,324285,324260,324275,324280,80295,0,603241,603837,604445,603001,602465,602192,602178,29594,29585,150612,150604,150625,150619,76014,1158014,1157891,1157954,1158128,569930,569935,171643,324122,1069240,256930,0,922971,923591,923655,569017,6843,58328,569940,29754,1857983,1858066,1857959,1858001,1858017,1857936,1858031,1858060,550657,1824151,1824128,1855878,1856008,1855977,1855904,1855963,1855928,1855896,550187,29721,178735,1093149,0,1955832,1957630,551137,321742,560851,299450,-1,1955677,0,157172,0,0,0,760137,760217,760233,761249,761049,761313,761409,760953,760201,760193,761137,760481,760673,760289,760433,758547,760385,761017,760529,760241,760889,760337,760985,760153,760129,761377,761113,760161,761449,760857,760145,760185,1959803,760921,760209,761185,760169,68350,68320,68315,63259,60892,62814,62820,182471,182466,68345,63252,62825,62831,271459,182476,62494,60899,271464,271474,271777,66693,76939,62840,63286,76985,61050,76970,62855,76367,63957,761499,62517,78661,66291,66254,66203,66225,66168,66126,77304,77294,77289,77299,77314,77309,77270,150706,150754,77575,63282,62836,63303,63230,63235,65858,328904,328349,328352,0,328228,76980,76955,76965,76960,76950,76975,63220,61155,68340,62521,62540,63266,329748,63245,75481,61114,61055,271469,78194,62513,62844,182481,68325,60945,68335,90667,91865,91404,91399,91409,91419,91414,91424,91760,91780,91790,91755,91815,91740,91745,91750,91795,91785,91775,91800,91765,91805,91770,91810,91840,91850,91820,91686,91832,75029,75009,70620,79438,74470,934786,934782,934847,934839,935147,935151,935026,935038,935002,934990,935014,934912,934919,934694,934696,934698,159472,0,0,0,79122,0,0,300207,79338,0,0,0,621960,0,0,0,158298,159726,159712,159631,159542,158616,0,0,79164,0,0,0,0,599207,754948,0,0,0,597116,0,0,0,12288,300286,172725,75143,75176,75163,598046,176434,176777,598407,178188,755092,754885,300508,300637,159457,9945,0,15561,0,30Line truncated
it: [529031,528005,530046,529086,26264,60871,256874,256880,256862,528623,528610,1031749,529065,60817,60812,63482,63502,60807,63487,60861,60822,224301,224296,224291,224286,224276,224281,224225,258800,527250,527386,527276,527149,526841,527196,526882,527342,527972,527475,527533,527053,527625,527512,526601,526640,526676,526576,527686,526494,526546,527654,527598,526758,526729,526811,526774,526697,527292,527565,527221,527069,527362,527402,526520,528045,528023,527452,526263,530732,527952,528369,528995,530629,61760,530548,529304,258349,530399,59167,529874,530145,320987,328935,528165,529672,258908,527930,160214,835781,530675,530436,528636,528704,529167,528930,26259,529899,529927,62606,528324,527843,835795,258777,528682,530569,529973,529129,529730,528254,529346,4709,61798,61794,529628,258354,527764,529010,530419,528115,529262,529283,527809,530712,527793,61694,530028,529414,258784,258770,258899,528821,526864,62593,529955,29242,526394,528514,529471,61315,62341,60566,60494,0,526175,528232,526332,526411,526304,527895,530314,530780,528429,528451,528266,528288,527881,13534,529888,26586,63462,59198,63457,63507,63477,26244,526361,529798,528347,529449,1031277,227770,530487,226394,529586,530092,529369,26249,26291,160236,1072764,1072677,1072699,527822,60866,528482,528756,529851,258894,68338,528719,63247,529993,526462,529323,62565,279404,526197,530751,227597,329090,330690,529391,530068,530252,530268,530213,528801,529429,527426,529829,528594,530506,528559,61788,62869,63232,61567,61598,530456,530369,526437,526994,526976,526930,528843,530525,528897,530293,528969,530331,530166,222444,61511,61346,530120,26254,528192,529563,529526,529107,530596,528738,26269,26286,60827,258332,258889,529491,528878,528779,529772,529698,528410,528667,528213,328951,528068,1072646,1072653,1072571,1072668,60856,526245,529152,530196,529649,529184,529048,529608,1031653,1031640,528542,328970,528096,526220,0,258811,63888,527774,529232,528390,529200,528134,62552,62619,62682,258791,95173,95265,1838500,1838534,1838507,1838509,75102,559278,592757,592752,592747,298334,557812,560062,24812,24823,71594,592034,592003,1838224,1842422,1842533,1842593,1842696,1851423,1819225,1819228,1819252,1819255,1819310,1819217,1819205,1819244,1819128,1819278,924146,0,0,89760,89190,89735,89730,89755,89745,89750,89740,0,1941607,569911,304739,29712,79478,79497,49995,79873,50049,920855,921257,920796,920823,920839,921869,920724,919938,29125,0,317573,317913,317908,50229,1073937,317936,922806,12769,150407,80528,79739,79721,79762,79744,79785,79767,80559,29750,91983,91899,92507,92600,92615,92620,92521,92605,92610,95315,95346,95448,95438,95453,95443,95458,90670,150307,150348,568941,569899,1950432,50297,323898,922768,29094,569711,569795,79449,29470,29828,29810,29819,89695,89725,89705,89700,89720,89715,89710,568800,923810,922222,924578,95380,95385,0,1031319,1031341,569048,304780,150401,181597,181740,181770,181760,181745,181765,181755,181750,181735,49850,0,0,29740,568820,924818,569926,89795,89775,89770,89765,89790,89785,89780,93689,93770,93755,93765,93750,150246,919636,50291,323600,92367,317918,923428,922896,922933,602935,602953,602971,50211,29591,88835,88852,324130,324061,324091,324086,324066,324081,324056,324071,324076,80422,50036,603229,603825,604433,602989,602453,602180,602166,29584,29577,150609,150602,150623,150615,76121,1157844,1157730,1157786,1157949,569931,569936,171613,323923,1069243,256930,50017,922970,923590,923654,569025,6837,58334,569941,29745,1855673,1855769,1855657,1855692,1855710,1855637,1855732,1855764,550666,1823011,1822993,1854702,1854824,1854797,1854730,1854786,1854759,1854724,550187,29712,178705,1093151,50336,1955866,1957664,551144,321727,560859,299452,-1,1955701,50349,157157,50238,50280,50362,760128,760208,760224,761240,761040,761304,761400,760944,760192,760184,761128,760472,760664,760280,760424,758538,760376,761008,760520,760232,760880,760328,760976,760144,760120,761368,761104,760152,761440,760848,760136,760176,1959808,760912,760200,761176,760160,68358,68328,68323,63266,60890,62821,62827,182490,182485,68353,63259,62832,62838,271474,182495,62501,60897,271479,271489,271792,66700,77046,62847,63293,77094,61048,77079,62862,76474,63964,761490,62524,78773,66297,66260,66208,66231,66171,66128,77414,77404,77399,77409,77424,77419,77379,0,150747,77685,63289,62843,63310,63237,63242,0,328917,0,328362,328223,0,77089,77064,77074,77069,77059,77084,63227,61162,68348,62528,62547,63273,329768,63252,75583,61121,61053,271484,78304,62520,62851,182500,68333,60943,68343,90674,91869,91411,91406,91416,91426,91421,91431,91767,91787,91797,91762,91822,91747,91752,91757,91802,91792,91782,91807,91772,91812,91777,91817,91843,91852,91827,91693,91836,75131,75111,70628,79565,74572,934806,934803,934865,934858,935149,935152,935034,935043,935016,935007,935025,934929,934936,934715,934717,934719,159453,51636,53674,53691,79234,52957,51757,300209,79465,53710,51631,51006,621958,51217,51146,51299,158281,159707,159693,159612,159523,158599,52766,79351,79276,52263,52278,52268,51073,599195,754948,53357,51876,51916,597104Line truncated
es: [529173,528047,530289,529245,26270,60823,256874,256880,256862,528742,528721,1031749,529214,60769,60764,63433,63453,60759,63438,60813,60774,224300,224295,224290,224285,224275,224280,224224,258800,527227,527378,527258,527134,526820,527189,526866,527326,528016,527470,527526,527022,527628,527503,526611,526650,526688,526576,527694,526493,526548,527668,527594,526754,526723,526790,526771,526706,527276,527557,527207,527043,527353,527396,526519,528090,528068,527447,526260,531030,527998,528434,529131,530932,61711,530855,529471,258349,530678,59119,530110,530399,320992,328934,528200,529901,258908,527977,160236,0,530970,530722,528763,528825,529329,529065,26265,530133,530150,62557,528385,527882,0,258777,528808,530872,530198,529289,529968,528298,529513,4710,61749,61745,529848,258354,527790,529158,530701,528158,529426,529448,527845,531008,527825,61645,530257,529604,258784,258770,258899,528953,526848,62544,530167,29248,0,528606,529670,61266,62292,60518,60446,0,526169,528274,0,526395,526312,527933,530582,531081,528510,528531,528320,528345,527915,13530,530124,26592,63413,59150,63408,63458,63428,26250,526341,530032,528416,529647,1031277,227769,530789,226393,529805,530343,529535,26255,26297,160258,1072761,1072674,1072696,527861,60818,528570,528883,530085,258894,68358,528845,63198,530218,526449,529491,62516,279404,526198,531049,227596,0,330685,529570,530313,530510,530531,530477,528921,529627,527417,530063,528698,530808,528656,61739,62820,63183,61518,61549,530749,530643,526421,526964,526940,526903,528989,530832,529039,530556,529098,530602,530421,222443,61462,61297,530372,26260,528225,529779,529734,529266,530907,528864,26275,26292,60779,258332,258889,529691,529027,528904,530004,529933,528484,528791,528254,328949,528117,1072643,1072650,1072568,1072665,60808,526242,529315,530455,529871,529351,529194,529829,1031653,1031640,528635,328966,528140,526222,526149,258811,63839,527808,529398,528460,529370,528176,62503,62570,62633,258791,95154,95246,1838736,1838776,1838745,1838747,75137,559287,592778,592774,592769,298334,557813,560071,24818,24829,71616,592043,592006,1838451,1842873,1842981,1843045,1843153,1851424,1819501,1819443,1819501,1819474,1819536,1819432,1819417,1819462,1819339,1819501,924543,150689,1510290,89752,89182,89727,89722,89747,89737,89742,89732,1509001,1942287,569921,304728,29717,79504,79523,49993,79899,50047,920856,921648,920797,920824,920840,922260,920725,919939,29131,1510357,317573,317913,317908,50227,1073934,317936,923197,12765,150407,80554,79765,79747,79788,79770,79811,79793,80585,29755,91975,91891,92499,92592,92607,92612,92513,92597,92602,95296,95327,95429,95419,95434,95424,95439,90670,150308,150349,568950,569909,1950435,50295,324089,923159,29100,569721,569805,79475,29476,29833,29815,29824,89687,89717,89697,89692,89712,89707,89702,568809,924207,922613,924975,95361,95366,1510214,1031319,1031341,569057,304769,150402,181585,181728,181758,181748,181733,181753,181743,181738,181723,49848,2078033,2078061,29745,568829,925215,569936,89787,89767,89762,89757,89782,89777,89772,93670,93751,93736,93746,93731,150247,919637,50289,323790,92359,317918,923819,923287,923324,602957,602975,602993,50209,29596,88827,88844,324321,324252,324282,324277,324257,324272,324247,324262,324267,80448,50034,603251,603823,604431,603011,602475,602202,602188,29589,29583,150609,0,150623,150615,76150,1158057,1157941,1157989,1158177,569941,569946,171629,324112,1069240,256930,50015,923361,923981,924045,569034,6833,58284,569951,29750,1857778,1857861,1857762,1857796,1857813,1857738,1857832,1857855,550664,1823861,1823842,1855709,1855852,1855817,1855739,1855798,1855768,1855727,550187,29717,178721,1093151,50334,1955868,1957666,551147,321732,560868,299448,-1,1955709,50347,0,50236,50278,50360,760135,760215,760231,761247,761047,761311,761407,760951,760199,760191,761135,760479,760671,760287,760431,758545,760383,761015,760527,760239,760887,760335,760983,760151,760127,761375,761111,760159,761447,760855,760143,760183,1959824,760919,760207,761183,760167,68378,68348,68343,63217,60842,62772,62778,182478,182473,68373,63210,62783,62789,271494,182483,62452,60849,271499,271509,271812,66717,77075,62798,63244,77123,61000,77108,62813,76503,63915,761497,62475,78811,66313,66274,66217,66242,66178,66132,77443,77433,77428,77438,77453,77448,77408,150704,150752,77714,63240,62794,63261,63188,63193,65861,328916,0,328361,328224,0,77118,77093,77103,77098,77088,77113,63178,61113,68368,62479,62498,63224,0,63203,75618,61072,61005,271504,78333,62471,62802,182488,68353,60895,68363,90674,91863,91411,91406,91416,91426,91421,91431,91767,91787,91797,91762,91822,91747,91752,91757,91802,91792,91782,91807,91772,91812,91777,91817,91842,91848,91827,91693,91836,75166,75146,70648,79591,74607,934808,934805,934869,934860,935167,935170,935048,935059,935026,935015,935037,934935,934943,934717,934719,0,159475,51585,53623,53640,79272,52906,51706,300205,79491,53659,51580,50955,621952,51166,51095,51248,158293,159729,159715,159634,159545,158611,52715,79391,79314,52212,52227,52217,51022,599217,754948,53306,51825,51Line truncated
} as const;
+64 -10
View File
@@ -1,17 +1,71 @@
import type { RomReader } from "./reader"; import type { RomReader } from "./reader";
import * as PROFILE from "./profile";
import { useCharacters } from "./charset";
import { REVISION_ADDRESSES, REVISION_KEYS } from "./revision-addresses";
/** The cartridges the port reads, as PRET builds them (`make crystal`, /** The cartridges the port reads: the English US/Europe revisions 1.0 and
* `crystal11` and `crystal_au`): the English US/Europe revisions 1.0 and * 1.1 and the Australian release (PRET's `crystal`, `crystal11` and
* 1.1, and the Australian release, which is 1.1 with the Game Corners' * `crystal_au`, which keep every label the port reads at the same address),
* gambling texts rewritten. All three keep every label the port reads at the * and the French, German, Italian and Spanish releases, whose addresses
* same address; only the routines `romRevision` callers check differ. */ * scripts/map-revision.mjs derives from 1.1 (revision-addresses.ts). */
export type Revision = "1.0" | "1.1" | "au"; export type Revision = "1.0" | "1.1" | "au" | Language;
export type Language = keyof typeof REVISION_ADDRESSES;
const CODES: Record<string, Revision> = { BYTU: "au", BYTF: "fr", BYTD: "de", BYTI: "it", BYTS: "es" };
/** The revision from the cartridge header (rgbfix's -i and -n): game code /** 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. */ * BYTE with mask ROM version 0 or 1, or a release's own code. */
export function romRevision(r: RomReader): Revision { export function romRevision(r: RomReader): Revision {
const code = String.fromCharCode(r.byte(0x13f), r.byte(0x140), r.byte(0x141), r.byte(0x142)); const code = String.fromCharCode(r.byte(0x13f), r.byte(0x140), r.byte(0x141), r.byte(0x142));
if (code === "BYTU") return "au"; if (code === "BYTE") return r.byte(0x14c) === 0 ? "1.0" : "1.1";
if (code !== "BYTE") throw new Error("Unknown cartridge game code."); const revision = CODES[code];
return r.byte(0x14c) === 0 ? "1.0" : "1.1"; if (!revision) throw new Error("Unknown cartridge game code.");
return revision;
}
/** romRevision, or 1.1 for a ROM without a known game code. */
export function knownRevision(r: RomReader): Revision {
try {
return romRevision(r);
} catch {
return "1.1";
}
}
type Table = Record<string, number>;
const tables = PROFILE as unknown as Record<string, Table>;
/** A release's profile entries that differ from 1.1: table -> key -> address
* (-1 where the label was not found there). */
export function revisionAddresses(language: Language): Record<string, Table> {
const out: Record<string, Table> = {};
REVISION_ADDRESSES[language].forEach((address, i) => {
if (!address) return;
const entry = REVISION_KEYS[i],
dot = entry.indexOf(".");
(out[entry.slice(0, dot)] ??= {})[entry.slice(dot + 1)] = address;
});
return out;
}
/** 1.1's values of every entry a release overrides. */
const english = new Map<string, Table>();
for (const entry of REVISION_KEYS) {
const dot = entry.indexOf("."),
table = entry.slice(0, dot),
key = entry.slice(dot + 1);
const saved = english.get(table) ?? {};
saved[key] = tables[table][key];
english.set(table, saved);
}
let current: Revision = "1.1";
/** Point the profile at a cartridge's addresses and characters. The port
* reads one cartridge at a time: CrystalAssets calls this for its ROM. */
export function useRevision(revision: Revision) {
if (revision === current) return;
for (const [table, saved] of english) Object.assign(tables[table], saved);
const language = revision in REVISION_ADDRESSES ? (revision as Language) : undefined;
if (language)
for (const [table, entries] of Object.entries(revisionAddresses(language))) Object.assign(tables[table], entries);
useCharacters(language);
current = revision;
} }
+9 -7
View File
@@ -520,7 +520,9 @@ interface MartTexts {
thanks: number; thanks: number;
soldOut?: number; soldOut?: number;
} }
const MART_TEXTS = { /** The marts' texts, read from the profile when used: its addresses follow
* the cartridge (game/rom/revision.ts). */
const martTexts = () => ({
standard: { standard: {
howMany: SYMBOLS.martHowManyText, howMany: SYMBOLS.martHowManyText,
finalPrice: SYMBOLS.martFinalPriceText, finalPrice: SYMBOLS.martFinalPriceText,
@@ -549,7 +551,7 @@ const MART_TEXTS = {
packFull: JOHTO.PharmacyPackFullText, packFull: JOHTO.PharmacyPackFullText,
thanks: JOHTO.PharmacyThanksText, thanks: JOHTO.PharmacyThanksText,
}, },
} satisfies Record<string, MartTexts>; }) satisfies Record<string, MartTexts>;
/** One row of a mart's buy or sell list. `owned` is set only when selling. */ /** One row of a mart's buy or sell list. `owned` is set only when selling. */
/** The mart's lists and quantity box, which the PC's item storage reuses. */ /** The mart's lists and quantity box, which the PC's item storage reuses. */
@@ -2708,12 +2710,12 @@ export class ScriptRun {
break; break;
case MARTTYPE_BITTER: case MARTTYPE_BITTER:
yield* textbox(JOHTO.HerbShopLadyIntroText); yield* textbox(JOHTO.HerbShopLadyIntroText);
yield* this.martBuy(martStock(), pages, MART_TEXTS.herb); yield* this.martBuy(martStock(), pages, martTexts().herb);
yield* textbox(JOHTO.HerbalLadyComeAgainText); yield* textbox(JOHTO.HerbalLadyComeAgainText);
return; return;
case MARTTYPE_PHARMACY: case MARTTYPE_PHARMACY:
yield* textbox(JOHTO.PharmacyIntroText); yield* textbox(JOHTO.PharmacyIntroText);
yield* this.martBuy(martStock(), pages, MART_TEXTS.pharmacy); yield* this.martBuy(martStock(), pages, martTexts().pharmacy);
yield* textbox(JOHTO.PharmacyComeAgainText); yield* textbox(JOHTO.PharmacyComeAgainText);
return; return;
case MARTTYPE_BARGAIN: { case MARTTYPE_BARGAIN: {
@@ -2721,7 +2723,7 @@ export class ScriptRun {
const bought = yield* this.martBuy( const bought = yield* this.martBuy(
pricedStock(JOHTO.BargainShopData), pricedStock(JOHTO.BargainShopData),
pages, pages,
MART_TEXTS.bargain, martTexts().bargain,
); );
// wBargainShopFlags: any purchase closes the shop for the day. // wBargainShopFlags: any purchase closes the shop for the day.
if (bought) this.state.flags.add(OW.ENGINE_GOLDENROD_UNDERGROUND_MERCHANT_CLOSED); if (bought) this.state.flags.add(OW.ENGINE_GOLDENROD_UNDERGROUND_MERCHANT_CLOSED);
@@ -2736,7 +2738,7 @@ export class ScriptRun {
), ),
pages, pages,
// The rooftop sale shares the standard mart's texts. // The rooftop sale shares the standard mart's texts.
MART_TEXTS.standard, martTexts().standard,
); );
yield* textbox(SYMBOLS.martComeAgainText); yield* textbox(SYMBOLS.martComeAgainText);
return; return;
@@ -2754,7 +2756,7 @@ export class ScriptRun {
}; };
first = false; first = false;
const option = typeof choice === "number" ? choice : 2; const option = typeof choice === "number" ? choice : 2;
if (option === 0) yield* this.martBuy(martStock(), pages, MART_TEXTS.standard); if (option === 0) yield* this.martBuy(martStock(), pages, martTexts().standard);
else if (option === 1) yield* this.martSell(pages); else if (option === 1) yield* this.martSell(pages);
else { else {
write(SYMBOLS.martComeAgainText); write(SYMBOLS.martComeAgainText);
+4
View File
@@ -1,5 +1,6 @@
import { RomReader } from "../rom/reader"; import { RomReader } from "../rom/reader";
import { RAM } from "../rom/profile"; import { RAM } from "../rom/profile";
import { releaseCharacter } from "../rom/charset";
const CHARACTERS: Record<number, string> = { const CHARACTERS: Record<number, string> = {
0x1f: " ", 0x1f: " ",
@@ -138,6 +139,9 @@ function battler(context: TextContext, enemy: boolean): string | undefined {
} }
function character(code: number, context: TextContext): string | undefined { function character(code: number, context: TextContext): string | undefined {
// The European releases' own characters (charset.ts).
const release = releaseCharacter(code);
if (release !== undefined) return release ?? undefined;
// PlaceMoveTargetsName / PlaceMoveUsersName // PlaceMoveTargetsName / PlaceMoveUsersName
if (code === 0x59) return battler(context, context.battle?.turn === 0); if (code === 0x59) return battler(context, context.battle?.turn === 0);
if (code === 0x5a) return battler(context, context.battle?.turn === 1); if (code === 0x5a) return battler(context, context.battle?.turn === 1);
+3 -7
View File
@@ -26,12 +26,8 @@ const PUZZLE_BORDER = 0xee,
const PLACES = 6 * 6, const PLACES = 6 * 6,
PIECES = 16; PIECES = 16;
/** UNOWNPUZZLE_* (constants/script_constants.asm), in .LZPointers order. */ /** UNOWNPUZZLE_* (constants/script_constants.asm), in .LZPointers order. */
const PUZZLE_GRAPHICS = [ const puzzleGraphics = () =>
JOHTO.KabutoPuzzleLZ, [JOHTO.KabutoPuzzleLZ, JOHTO.OmanytePuzzleLZ, JOHTO.AerodactylPuzzleLZ, JOHTO.HoOhPuzzleLZ] as const;
JOHTO.OmanytePuzzleLZ,
JOHTO.AerodactylPuzzleLZ,
JOHTO.HoOhPuzzleLZ,
] as const;
/** .EnlargedTiles: each bit of a nybble doubled. */ /** .EnlargedTiles: each bit of a nybble doubled. */
const enlarge = (nybble: number) => const enlarge = (nybble: number) =>
(nybble & 8) * 0x18 + (nybble & 4) * 0x0c + (nybble & 2) * 0x06 + (nybble & 1) * 0x03; (nybble & 8) * 0x18 + (nybble & 4) * 0x0c + (nybble & 2) * 0x06 + (nybble & 1) * 0x03;
@@ -42,7 +38,7 @@ const enlarge = (nybble: number) =>
* then the cursor and START/CANCEL graphics. */ * then the cursor and START/CANCEL graphics. */
export function unownPuzzleTiles(reader: RomReader, puzzle: number) { export function unownPuzzleTiles(reader: RomReader, puzzle: number) {
const tiles = new Uint8Array(0x100 * 16); const tiles = new Uint8Array(0x100 * 16);
const picture = decompress(reader, PUZZLE_GRAPHICS[puzzle & 3], 0x1000); const picture = decompress(reader, puzzleGraphics()[puzzle & 3], 0x1000);
let out = 0; let out = 0;
for (let row = 0; row < 6; row++) for (let row = 0; row < 6; row++)
for (let half = 0; half < 2; half++) for (let half = 0; half < 2; half++)
+15
View File
@@ -21,6 +21,16 @@ export const CRYSTAL_ROM = {
size: 2 * 1024 * 1024, size: 2 * 1024 * 1024,
} as const; } as const;
/** The French, German, Italian and Spanish releases: their addresses and
* characters are known (game/rom/revision.ts), but parts of their own
* logic (units, word order, layouts) are not ported yet (board #84). */
export const RELEASES_IN_PROGRESS = [
{ name: "Pokémon Crystal · French", sha1: "c055992b16b7399c687647725cdd1f4f13a2f75c" },
{ name: "Pokémon Crystal · German", sha1: "accb584293ba056152f1fd908439b019017ff2fe" },
{ name: "Pokémon Crystal · Italian", sha1: "6cee05e5b95beeae74b8365ad18ec4a07a8c4af8" },
{ name: "Pokémon Crystal · Spanish", sha1: "889a06fc0bb863666865aa69def0adf97945ac2a" },
] as const;
/** The supported cartridge with this SHA-1, if any. */ /** The supported cartridge with this SHA-1, if any. */
export const supportedRom = (sha1: unknown) => export const supportedRom = (sha1: unknown) =>
CRYSTAL_ROMS.find((rom) => rom.sha1 === sha1); CRYSTAL_ROMS.find((rom) => rom.sha1 === sha1);
@@ -48,6 +58,11 @@ export async function validateRom(file: File): Promise<ValidatedRom> {
byte.toString(16).padStart(2, "0"), byte.toString(16).padStart(2, "0"),
).join(""); ).join("");
const rom = supportedRom(sha1); const rom = supportedRom(sha1);
const later = RELEASES_IN_PROGRESS.find((release) => release.sha1 === sha1);
if (later)
throw new Error(
`This is the ${later.name.replace("Pokémon Crystal · ", "")} release of Pokémon Crystal. It is recognized, but parts of its own logic are not ported yet; for now, use an English cartridge.`,
);
if (!rom) { if (!rom) {
throw new Error( throw new Error(
"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.", "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.",
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { JOHTO, MENU } from "../src/game/rom/profile";
import { revisionAddresses, romRevision } from "../src/game/rom/revision";
import { RELEASES_IN_PROGRESS, validateRom } from "../src/rom/validate";
import { readString } from "../src/game/scripts/text";
import { textTiles } from "../src/game/rom/font";
import { Playthrough } from "./playthrough-driver";
/** The European releases at the repository root, each skipped when absent. */
const releases = [
{ language: "fr", rom: "Pokemon - Version Cristal (France).gbc", release: RELEASES_IN_PROGRESS[0] },
{ language: "de", rom: "Pokemon - Kristall-Edition (Germany).gbc", release: RELEASES_IN_PROGRESS[1] },
{ language: "it", rom: "Pokemon - Versione Cristallo (Italy).gbc", release: RELEASES_IN_PROGRESS[2] },
{ language: "es", rom: "Pokemon - Edicion Cristal (Spain).gbc", release: RELEASES_IN_PROGRESS[3] },
] as const;
const english = "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
for (const { language, rom, release } of releases)
describe.skipIf(!existsSync(rom))(`the ${language} release`, () => {
const bytes = existsSync(rom) ? new Uint8Array(readFileSync(rom)) : new Uint8Array();
it("is recognized from its header, then refused until its logic is ported", async () => {
const assets = new CrystalAssets(bytes);
expect(romRevision(assets.reader)).toBe(language);
await expect(validateRom(new File([bytes], "crystal.gbc"))).rejects.toThrow(
`This is the ${release.name.replace("Pokémon Crystal · ", "")} release`,
);
});
it("points the profile at its addresses and back to English for another cartridge", () => {
const overrides = revisionAddresses(language).JOHTO;
const [key, value] = Object.entries(overrides).find(([, address]) => address > 0)!;
new CrystalAssets(bytes);
expect((JOHTO as Record<string, number>)[key]).toBe(value);
if (existsSync(english)) {
new CrystalAssets(new Uint8Array(readFileSync(english)));
expect((JOHTO as Record<string, number>)[key]).not.toBe(value);
new CrystalAssets(bytes);
}
});
it("decodes its location names and menu strings, and writes them back as tiles", () => {
const assets = new CrystalAssets(bytes);
const context = { player: "", rival: "", mom: "", strings: {}, weekday: "" };
for (let landmark = 1; landmark < 0x60; landmark++) {
const name = assets.landmark(landmark);
expect(name.length).toBeGreaterThan(0);
// One tile per character, except the release's own word breaks.
expect(textTiles(name.replace(/\n/g, " ")).length).toBeGreaterThan(0);
}
expect(readString(assets.reader, MENU["PlacePartyMonTMHMCompatibility.string_able"], context).length).toBeGreaterThan(0);
});
it("plays the introduction and Mom's scene in its language, then steps outside", () => {
const p = new Playthrough(new CrystalAssets(bytes));
p.start();
p.world.beginIntro();
p.play([0, "", 1, "", 1, 0, "KAWA"]);
expect(p.world.intro).toBe(false);
expect(p.world.name).toBe("KAWA");
// The release's own words: none of the English introduction's.
const log = p.log.join(" ");
expect(log.length).toBeGreaterThan(200);
expect(log).not.toContain("Welcome to the world of POKéMON!");
p.reload();
p.walk(7, 0);
expect(p.world.mapId).toBe("24:6");
p.walk(9, 3);
expect(p.world.move("down")).toBe(true);
p.play([1, 1, 1, 1, 1]);
p.reload();
p.walk(6, 7);
expect(p.world.move("down")).toBe(true);
p.play();
expect(p.world.mapId).toBe("24:4");
expect(p.world.notice).toBe("");
});
});