Files
Crystal/tests/languages.test.ts
kawaandClaude Opus 5.5 d3e3faff72
Checks / web (push) Failing after 19s
Private ROM checks / private-rom (push) Failing after 27m7s
Measure, weigh and name the European releases' own way (#84)
The French, German, Italian and Spanish releases store Magikarp's
length in millimetres (and print a tenth of them), start the Magikarp
house's record at their own length and holder, keep Pokédex heights in
decimetres and weights in hectograms (printed in metres and kilograms),
read the Heavy Ball's weight byte without converting it, and place the
enemy's nickname before EnemyText (French, Italian) or the trainer's
name before the class (French, Italian, Spanish).

map-revision --symbols writes each release's symbol file, so the
reference specs run against a release; specs that pin English data skip
there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:04:51 +02:00

137 lines
6.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { JOHTO, MENU, RAM } from "../src/game/rom/profile";
import { initialMagikarpRecord, magikarpHouseSign } from "../src/game/scripts/lake-of-rage";
import { magikarpLength, magikarpLengthBytes } from "../src/game/battle/battle";
import { revisionAddresses, romRevision, useRevision } from "../src/game/rom/revision";
import { RomReader } from "../src/game/rom/reader";
import { RELEASES_IN_PROGRESS, validateRom } from "../src/rom/validate";
import { defaultTextContext, readString } from "../src/game/scripts/text";
import { textTiles } from "../src/game/rom/font";
import { dexHeight, dexWeight } from "../src/game/rom/pokedex";
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("");
});
});
describe("the European releases' word order", () => {
it("places the enemy's nickname before EnemyText in French and Italian", () => {
// <USER> on the enemy's turn ($5a), then the terminator.
const reader = new RomReader(new Uint8Array([0x5a, 0x50]));
const name = (revision: Parameters<typeof useRevision>[0], prefix: string) => {
useRevision(revision);
return readString(reader, 0, {
...defaultTextContext("KAWA"),
battle: { turn: 1, player: "CYNDAQUIL", enemy: "RATTATA", enemyPrefix: prefix },
});
};
expect(name("fr", " ennemi")).toBe("RATTATA ennemi");
expect(name("it", " nemico")).toBe("RATTATA nemico");
expect(name("de", "Gegn. ")).toBe("Gegn. RATTATA");
expect(name("1.1", "Enemy ")).toBe("Enemy RATTATA");
});
});
describe.skipIf(!existsSync(releases[0].rom))("the European releases' Magikarp", () => {
it("measure in millimetres and print a tenth of them, from 105.3 by the release's own holder", () => {
const bytes = new Uint8Array(readFileSync(releases[0].rom));
const assets = new CrystalAssets(bytes);
const record = initialMagikarpRecord(assets.reader);
// InitializeMagikarpHouse's bytes: 1053 mm.
expect([record.feet, record.inches]).toEqual([0x04, 0x1d]);
expect(record.holder).not.toBe("RALPH");
const strings: Record<number, string> = {};
magikarpHouseSign({ strings, write: () => {} } as never, record);
expect(strings[RAM.stringBuffer1]).toBe("105.3");
// CalcMagikarpLength stores the millimetres (English converts them).
const [high, low] = magikarpLengthBytes(assets.reader, 0x1234, 0x5678);
expect((high << 8) | low).toBe(magikarpLength(assets.reader, 0x1234, 0x5678).mm);
});
});
describe.skipIf(!existsSync(releases[0].rom))("the European releases' Pokédex", () => {
it("reads metric heights and weights and prints them in metres and kilograms", () => {
for (const { rom } of releases) {
if (!existsSync(rom)) continue;
const dex = new CrystalAssets(new Uint8Array(readFileSync(rom))).pokedex;
// PIKACHU: 0.4 m and 6.0 kg; its pages still follow.
const entry = dex.entry(25);
expect([entry.height, entry.weight]).toEqual([4, 60]);
expect(entry.pages[0].length).toBeGreaterThan(10);
const height = dexHeight(dex.dexString("Pokedex_DrawDexEntryScreenBG.Height"), entry.height);
const weight = dexWeight(dex.dexString("Pokedex_DrawDexEntryScreenBG.Weight"), entry.weight);
expect(height).toMatch(/^\S+ +0\.4m$/);
expect(weight).toMatch(/^\S+ +6\.0kg$/);
}
});
});