From acdaf62eaa6a4710b0661aed36240272eee963cb Mon Sep 17 00:00:00 2001 From: kawa Date: Sat, 17 Oct 2026 16:06:28 +0200 Subject: [PATCH] Name the Battle Tower's ZABOROWSKI instead of refusing the battle BattleTowerTrainers' names are ten bytes padded with "@" only when shorter, and the cartridge copies the ten bytes to wOTPlayerName before adding the terminator. The port read the name as an "@"-terminated string, so drawing ZABOROWSKI (trainer 5) threw "Unterminated string." and the challenge was refused. Co-Authored-By: Claude Opus 5.5 --- src/game/scripts/battle-tower.ts | 6 ++++-- src/game/scripts/text.ts | 19 +++++++++++++++++++ tests/battle-tower.test.ts | 23 ++++++++++++++++++++++- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/game/scripts/battle-tower.ts b/src/game/scripts/battle-tower.ts index fc47a7e..5075668 100644 --- a/src/game/scripts/battle-tower.ts +++ b/src/game/scripts/battle-tower.ts @@ -2,7 +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 { readString } from "./text"; +import { readFixedString, readString } from "./text"; import type { Gen, SpecialContext } from "./special-context"; /** BATTLETOWER_* (constants/battle_tower_constants.asm). */ @@ -209,7 +209,9 @@ export function loadOpponent(c: SpecialContext, bt: BattleTowerState): BattleTow const trainer = sample(c, BATTLE_TOWER.uniqueTrainers, (index) => bt.trainers.includes(index)); bt.trainers[bt.beaten] = trainer; const entry = JOHTO.BattleTowerTrainers + trainer * TRAINER_ENTRY; - const name = readString(r, entry, c.text(), TRAINER_ENTRY - 1); + // Copied to wOTPlayerName with a "@" after its ten bytes: ZABOROWSKI + // fills them. + const name = readFixedString(r, entry, c.text(), TRAINER_ENTRY - 1); const trainerClass = r.byte(entry + TRAINER_ENTRY - 1); // LoadRandomBattleTowerMon: wBT_OTMon1-3 start as species 0 and item $ff. const species = [0, 0, 0], diff --git a/src/game/scripts/text.ts b/src/game/scripts/text.ts index 9a9b454..86ef6fb 100644 --- a/src/game/scripts/text.ts +++ b/src/game/scripts/text.ts @@ -153,6 +153,25 @@ function character(code: number, context: TextContext): string | undefined { return CHARACTERS[code]; } +/** Decode a name of at most `length` characters, as CopyBytes followed by + * a "@" leaves it: one that fills its field has no terminator of its own. */ +export function readFixedString( + reader: RomReader, + start: number, + context: TextContext, + length: number, +): string { + let output = ""; + for (let p = start; p < start + length; p++) { + const code = reader.byte(p); + if (code === 0x50) break; + const value = character(code, context); + if (value === undefined) throw new UnsupportedText(p, code); + output += value; + } + return output; +} + /** Decode a plain "@"-terminated string such as a name or item label. */ export function readString( reader: RomReader, diff --git a/tests/battle-tower.test.ts b/tests/battle-tower.test.ts index 409f535..ad8c3f9 100644 --- a/tests/battle-tower.test.ts +++ b/tests/battle-tower.test.ts @@ -3,7 +3,8 @@ import { existsSync, readFileSync } from "node:fs"; import { CrystalAssets } from "../src/game/rom/assets"; import { GameWorld } from "../src/game/world/world"; import { calcStats, generatePartyMon, maxPP, type PartyMon } from "../src/game/party/pokemon"; -import { CHALLENGE } from "../src/game/scripts/battle-tower"; +import { CHALLENGE, emptyBattleTowerState, loadOpponent } from "../src/game/scripts/battle-tower"; +import type { SpecialContext } from "../src/game/scripts/special-context"; import { Playthrough } from "./playthrough-driver"; const romPath = @@ -88,6 +89,26 @@ describe.skipIf(!existsSync(romPath))("the Battle Tower with the local ROM", () expect(p.world.notice, p.world.diagnostic).toBe(""); }; + it("names a trainer whose name fills all ten characters", () => { + // BattleTowerTrainers' sixth entry, ZABOROWSKI, has no "@" of its own. + let seed = 5; + const context = { + reader: assets!.reader, + data: assets!.pokemon, + random: () => { + seed = (seed * 1103515245 + 12345) >>> 0; + return (seed >>> 16) & 255; + }, + text: () => ({ player: "KAWA", rival: "SILVER", mom: "MOM", strings: {}, weekday: "SUNDAY" }), + } as unknown as SpecialContext; + const names = new Map(); + for (let draw = 0; draw < 300 && !names.has(5); draw++) { + const opponent = loadOpponent(context, { ...emptyBattleTowerState(), levelGroup: 1 }); + names.set(opponent.trainer, opponent.name); + } + expect(names.get(5)).toBe("ZABOROWSKI"); + }); + it("is reached from Route 40 through its gate", () => { const p = new Playthrough(assets!); p.world = new GameWorld(assets!, p.now, p.random);