Read the English 1.0 and Australian cartridges besides 1.1 (#84)
PRET's crystal and crystal_au builds reproduce both retail cartridges, and every label the profile pins keeps its address in all three; check:reference now verifies that when the rebuilds are present. The revision comes from the header. Revision 1.0's Battle Tower draws only the first 21 trainers and picks its texts' gender from the name's sixth character, checked against each cartridge. A native backup imports across the three, which share every map, event and object. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
acdaf62eaa
commit
3c480a28f0
17 files changed
+317
-26
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import type { GameRuntime } from "./runtime";
|
||||
import type { SessionState } from "./session";
|
||||
import type { ValidatedRom } from "../rom/validate";
|
||||
import { supportedRom, type ValidatedRom } from "../rom/validate";
|
||||
import { encodeSave, type SaveRecord } from "../storage/saves";
|
||||
import {
|
||||
SLOT_VERSION,
|
||||
@@ -54,6 +54,9 @@ export type ImportPreview =
|
||||
conflicts: { slot: SaveSlot; identical: boolean }[];
|
||||
/** Present when the file was a cartridge save. */
|
||||
cartridge?: CartridgeImport;
|
||||
/** The other supported cartridge a native backup was made with; it
|
||||
* continues with this one. */
|
||||
madeWith?: string;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
@@ -306,7 +309,9 @@ export class SlotSession {
|
||||
reason: "This file is not a Crystal backup.",
|
||||
identity,
|
||||
};
|
||||
if (value.romHash !== this.romHash)
|
||||
// The English cartridges share their native saves.
|
||||
const other = value.romHash === this.romHash ? undefined : supportedRom(value.romHash);
|
||||
if (value.romHash !== this.romHash && !other)
|
||||
return {
|
||||
ok: false,
|
||||
reason: "This backup was made with a different cartridge.",
|
||||
@@ -348,6 +353,7 @@ export class SlotSession {
|
||||
record,
|
||||
...inspected,
|
||||
conflicts: await this.conflicts(record, inspected.summary),
|
||||
...(other && { madeWith: other.name }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { RomReader } from "./reader";
|
||||
|
||||
/** The cartridges the port reads, as PRET builds them (`make crystal`,
|
||||
* `crystal11` and `crystal_au`): the English US/Europe revisions 1.0 and
|
||||
* 1.1, and the Australian release, which is 1.1 with the Game Corners'
|
||||
* gambling texts rewritten. All three keep every label the port reads at the
|
||||
* same address; only the routines `romRevision` callers check differ. */
|
||||
export type Revision = "1.0" | "1.1" | "au";
|
||||
|
||||
/** The revision from the cartridge header (rgbfix's -i and -n): game code
|
||||
* BYTU for the Australian release, else BYTE with mask ROM version 0 or 1. */
|
||||
export function romRevision(r: RomReader): Revision {
|
||||
const code = String.fromCharCode(r.byte(0x13f), r.byte(0x140), r.byte(0x141), r.byte(0x142));
|
||||
if (code === "BYTU") return "au";
|
||||
if (code !== "BYTE") throw new Error("Unknown cartridge game code.");
|
||||
return r.byte(0x14c) === 0 ? "1.0" : "1.1";
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { JOHTO, RAM, WRAM } from "../rom/profile";
|
||||
import { maxPP, validPartyMon, type PartyMon } from "../party/pokemon";
|
||||
import type { PokemonData } from "../rom/pokemon";
|
||||
import { DIRECTION_ORDER, type Direction } from "../world/types";
|
||||
import { romRevision } from "../rom/revision";
|
||||
import { readFixedString, readString } from "./text";
|
||||
import type { Gen, SpecialContext } from "./special-context";
|
||||
|
||||
@@ -51,6 +52,8 @@ const MON_STRUCT = 48,
|
||||
NICKNAMED_MON = MON_STRUCT + 11;
|
||||
/** BattleTowerTrainers' entries: a 10-character name and the class. */
|
||||
const TRAINER_ENTRY = 11;
|
||||
/** NAME_LENGTH_JAPANESE (constants/text_constants.asm). */
|
||||
const NAME_LENGTH_JAPANESE = 6;
|
||||
const HP_UP = 0x1a,
|
||||
CALCIUM = 0x1f,
|
||||
LUCKY_PUNCH = 0x1e,
|
||||
@@ -206,7 +209,10 @@ function sample(
|
||||
* others and none of the last two trainers' species. */
|
||||
export function loadOpponent(c: SpecialContext, bt: BattleTowerState): BattleTowerOpponent {
|
||||
const r = c.reader;
|
||||
const trainer = sample(c, BATTLE_TOWER.uniqueTrainers, (index) => bt.trainers.includes(index));
|
||||
// BUG (Crystal 1.0): masked and compared with BATTLETOWER_NUM_UNIQUE_MON,
|
||||
// so only the first 21 trainers are drawn.
|
||||
const trainers = romRevision(r) === "1.0" ? BATTLE_TOWER.uniqueMons : BATTLE_TOWER.uniqueTrainers;
|
||||
const trainer = sample(c, trainers, (index) => bt.trainers.includes(index));
|
||||
bt.trainers[bt.beaten] = trainer;
|
||||
const entry = JOHTO.BattleTowerTrainers + trainer * TRAINER_ENTRY;
|
||||
// Copied to wOTPlayerName with a "@" after its ten bytes: ZABOROWSKI
|
||||
@@ -251,7 +257,14 @@ export const battleTowerSprite = (c: SpecialContext, trainerClass: number) =>
|
||||
* player lost) and 3 (the player won) reuse it. Returns the text. */
|
||||
export function battleTowerText(c: SpecialContext, bt: BattleTowerState, which: 1 | 2 | 3) {
|
||||
const r = c.reader;
|
||||
const trainerClass = bt.opponent?.trainerClass ?? 0;
|
||||
// BUG (Crystal 1.0): the sixth character of wBT_OTName stands in for
|
||||
// wBT_OTTrainerClass as the index into BTTrainerClassGenders.
|
||||
const trainerClass =
|
||||
romRevision(r) === "1.0"
|
||||
? bt.opponent
|
||||
? r.byte(JOHTO.BattleTowerTrainers + bt.opponent.trainer * TRAINER_ENTRY + NAME_LENGTH_JAPANESE - 1)
|
||||
: 0
|
||||
: (bt.opponent?.trainerClass ?? 0);
|
||||
const female = r.byte(JOHTO.BTTrainerClassGenders + ((trainerClass - 1) & 0xff)) !== 0;
|
||||
const table = female ? JOHTO.BTFemaleTrainerTexts : JOHTO.BTMaleTrainerTexts;
|
||||
if (which === 1) {
|
||||
|
||||
@@ -2237,6 +2237,8 @@ function previewImport(
|
||||
? `Native save, game data version ${preview.schema}: upgraded to version 3 on import`
|
||||
: `Native save, game data version ${preview.schema}`,
|
||||
);
|
||||
if (preview.madeWith)
|
||||
row("Cartridge", `Made with ${preview.madeWith.replace("Pokémon Crystal · ", "")}; it continues with this one`);
|
||||
if (cartridge?.notes.length) {
|
||||
conflicts.append(element("h3", "", "Conversion notes"));
|
||||
const list = element("ul", "");
|
||||
|
||||
+28
-6
@@ -1,9 +1,30 @@
|
||||
/** The English cartridges the port reads (src/game/rom/revision.ts), as
|
||||
* PRET rebuilds them byte for byte. Their native saves are interchangeable:
|
||||
* the three share every map, event and object. */
|
||||
export const CRYSTAL_ROMS = [
|
||||
{
|
||||
name: "Pokémon Crystal · English US/Europe 1.1",
|
||||
sha1: "f2f52230b536214ef7c9924f483392993e226cfb",
|
||||
},
|
||||
{
|
||||
name: "Pokémon Crystal · English US/Europe 1.0",
|
||||
sha1: "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133",
|
||||
},
|
||||
{
|
||||
name: "Pokémon Crystal · English Australia",
|
||||
sha1: "a0fc810f1d4e124434f7be2c989ab5b5892ddf36",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const CRYSTAL_ROM = {
|
||||
name: "Pokémon Crystal · English US/Europe 1.1",
|
||||
sha1: "f2f52230b536214ef7c9924f483392993e226cfb",
|
||||
...CRYSTAL_ROMS[0],
|
||||
size: 2 * 1024 * 1024,
|
||||
} as const;
|
||||
|
||||
/** The supported cartridge with this SHA-1, if any. */
|
||||
export const supportedRom = (sha1: unknown) =>
|
||||
CRYSTAL_ROMS.find((rom) => rom.sha1 === sha1);
|
||||
|
||||
export interface ValidatedRom {
|
||||
bytes: Uint8Array;
|
||||
sha1: string;
|
||||
@@ -13,7 +34,7 @@ export interface ValidatedRom {
|
||||
export async function validateRom(file: File): Promise<ValidatedRom> {
|
||||
if (file.size !== CRYSTAL_ROM.size) {
|
||||
throw new Error(
|
||||
"Choose an uncompressed Pokémon Crystal English US/Europe 1.1 ROM (.gbc, 2 MiB).",
|
||||
"Choose an uncompressed English Pokémon Crystal ROM (.gbc, 2 MiB).",
|
||||
);
|
||||
}
|
||||
if (!globalThis.crypto?.subtle) {
|
||||
@@ -26,10 +47,11 @@ export async function validateRom(file: File): Promise<ValidatedRom> {
|
||||
const sha1 = Array.from(new Uint8Array(digest), (byte) =>
|
||||
byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
if (sha1 !== CRYSTAL_ROM.sha1) {
|
||||
const rom = supportedRom(sha1);
|
||||
if (!rom) {
|
||||
throw new Error(
|
||||
"This ROM is not the supported English US/Europe revision 1.1. Other languages, revisions, and modified ROMs are not supported yet.",
|
||||
"This ROM is not a supported English Pokémon Crystal: US/Europe revision 1.0 or 1.1, or the Australian release. Other languages and modified ROMs are not supported yet.",
|
||||
);
|
||||
}
|
||||
return { bytes: new Uint8Array(buffer), sha1, name: CRYSTAL_ROM.name };
|
||||
return { bytes: new Uint8Array(buffer), sha1, name: rom.name };
|
||||
}
|
||||
+1
-1
@@ -22,7 +22,7 @@ export function renderShell(root: HTMLElement) {
|
||||
<canvas id="game-screen" width="160" height="144" tabindex="0" aria-label="Game screen"></canvas>
|
||||
<div id="screen-empty" class="screen-empty">
|
||||
<form class="load-card" onsubmit="return false">
|
||||
<p class="menu-title">NEW SESSION</p><h1>Load your Crystal ROM</h1><p>English US/Europe · Revision 1.1</p>
|
||||
<p class="menu-title">NEW SESSION</p><h1>Load your Crystal ROM</h1><p>English · US/Europe 1.0 or 1.1, or Australia</p>
|
||||
<label class="primary-button file-button" id="rom-label" for="rom-file"><span aria-hidden="true">▶</span> Choose ROM</label>
|
||||
<p class="privacy-note">Opened locally. Never uploaded.</p>
|
||||
</form>
|
||||
|
||||
Reference in new issue
Block a user