Port the fishing rods and wild Magikarp

The Old, Good and Super Rods run FishFunction facing water: the map's fish
group and Fish pick no fish, no bite, or a Pokémon from FishGroups and the
time of day's TimeFishGroups. The player casts and jiggles with the
fishing frames drawn from LoadFishingGFX's tiles, shows "!" on a bite, and
the hooked Pokémon attacks as BATTLETYPE_FISH. Facing pieces now use
signed offsets, which the rod above and left of the player needs.

Wild Magikarp no longer stop a battle: LoadEnemyMon redraws their DVs
through CalcMagikarpLength and its filters, as the original does. The
length routine matches the original in the reference emulator.

Part of #27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-03 00:49:35 +02:00
1 parent 4cb4676c7d
commit 397dcef44c
20 files changed
+681 -47

No files matched your search

+1 -1
View File
@@ -179,7 +179,7 @@ describe.skipIf(!existsSync(romPath))(
});
it("refuses battles that need untranslated rules", () => {
const { battle: game } = battle([starter()], { species: 129, level: 5 });
const { battle: game } = battle([starter()], { species: 201, level: 5 });
expect(() => play(game)).toThrow(UnsupportedBattle);
});
},
+200 -2
View File
@@ -13,13 +13,16 @@ import {
import { StartMenuRun } from "../src/game/scripts/start-menu";
import { defaultTextContext } from "../src/game/scripts/text";
import { OVERWORLD } from "../src/game/rom/profile";
import { close, useItem } from "./menu-driver";
import { fish } from "../src/game/scripts/fish";
import { Battle, magikarpLength } from "../src/game/battle/battle";
import { close, open, pick, pickItem, useItem } from "./menu-driver";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const PLAYERS_ROOM = "24:7",
NEW_BARK_TOWN = "24:4",
ROUTE_30 = "26:1",
RADIO_TOWER_3F = "3:19",
GOLDENROD_UNDERGROUND = "3:53",
@@ -38,7 +41,16 @@ const POTION = 0x12,
ITEMFINDER = 0x37,
CARD_KEY = 0x7f,
BASEMENT_KEY = 0x85,
SQUIRTBOTTLE = 0xaf;
SQUIRTBOTTLE = 0xaf,
OLD_ROD = 0x3a,
GOOD_ROD = 0x3b,
SUPER_ROD = 0x3d;
const MAGIKARP = 129,
KRABBY = 98,
CORSOLA = 222,
STARYU = 120;
const FISHGROUP_SHORE = 1;
const FACING_FISH_RIGHT = 0x13;
const SPRITEMOVEDATA_SUDOWOODO = 0x17;
const OAK_WARNING = "OAK: CHRIS! This isn't the time to use that!";
@@ -410,4 +422,190 @@ describe.skipIf(!existsSync(romPath))("field items", () => {
"CHRIS used the\nSQUIRTBOTTLE.",
]);
});
it("fishes from FishGroups by rod and time of day", () => {
const r = new CrystalAssets(new Uint8Array(readFileSync(romPath))).reader;
const cast = (rod: number, time: number, ...draws: number[]) =>
fish(r, FISHGROUP_SHORE, rod, time, () => {
const draw = draws.shift();
if (draw === undefined) throw new Error("No draw left.");
return draw;
});
// A bite below 50 percent + 1 ($80), then the first entry the roll
// does not exceed: 70 percent + 1, 85 percent + 1, 100 percent.
expect(cast(0, 1, 127, 0)).toEqual({ species: MAGIKARP, level: 10 });
expect(cast(0, 1, 128)).toBeUndefined();
expect(cast(0, 1, 0, 179)).toEqual({ species: MAGIKARP, level: 10 });
expect(cast(0, 1, 0, 255)).toEqual({ species: KRABBY, level: 10 });
// The Good Rod's last entry is TimeFishGroups 0: Corsola by day,
// Staryu from the night on.
expect(cast(1, 0, 0, 255)).toEqual({ species: CORSOLA, level: 20 });
expect(cast(1, 1, 0, 255)).toEqual({ species: CORSOLA, level: 20 });
expect(cast(1, 2, 0, 255)).toEqual({ species: STARYU, level: 20 });
expect(cast(2, 1, 0, 0)).toEqual({ species: KRABBY, level: 40 });
});
it("draws a wild Magikarp's DVs again below four feet, except at the Lake of Rage", () => {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const id = 0x1234;
const feet = (dvs: number) => magikarpLength(assets.reader, dvs, id).feet;
// Nonzero, unlike the draws that follow.
const short = [...Array(0x10000).keys()].find(
(dvs) => dvs > 0x100 && feet(dvs) < 4,
)!,
long = [...Array(0x10000).keys()].find(
(dvs) => dvs > 0x100 && feet(dvs) >= 4,
)!;
/** The wild Magikarp's DVs for these Random draws (then zeros). */
const dvs = (mapId: string, ...draws: number[]) => {
const game = new Battle({
data: assets.pokemon,
party: [
generatePartyMon(assets.pokemon, {
species: 155,
level: 5,
item: 0,
otId: id,
ot: "CHRIS",
dvs: 0x823b,
caughtData: 0,
}),
],
playerId: id,
playerName: "CHRIS",
timeOfDay: 1,
pokedex: { seen: new Set(), caught: new Set() },
random: () => draws.shift() ?? 0,
wild: { species: MAGIKARP, level: 10 },
fish: true,
mapId,
text: () => defaultTextContext("CHRIS"),
});
const first = game.run().next().value;
expect(first && "text" in first && first.text).toBe(
"The hooked\nMAGIKARP\nattacked!",
);
return game.enemy.mon.dvs;
};
const hi = (value: number) => value >> 8,
lo = (value: number) => value & 0xff;
// No item (0), the DVs, then 39 percent + 1 (100) skips the floor.
expect(dvs(NEW_BARK_TOWN, 0, hi(short), lo(short), 99)).toBe(short);
expect(
dvs(NEW_BARK_TOWN, 0, hi(short), lo(short), 100, hi(long), lo(long), 100),
).toBe(long);
// The Lake of Rage (9:6) skips it without a draw, and so does any map
// in group 9 or numbered 6.
for (const map of ["9:6", "9:1", "24:6"])
expect(dvs(map, 0, hi(short), lo(short), 255)).toBe(short);
});
describe("fishing in New Bark Town", () => {
/** Facing the sea, with a rod and a constant random byte. */
function shore(random: number, rod = OLD_ROD) {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const world = new GameWorld(
assets,
() => new Date("2026-09-22T12:00:00"),
() => random,
);
world.state.party = [
generatePartyMon(assets.pokemon, {
species: 155,
level: 5,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x823b,
caughtData: 0,
}),
];
world.state.bag.keyItems = [rod];
world.state.scenes[NEW_BARK_TOWN] = 1;
world.restore({
...world.save(),
map: NEW_BARK_TOWN,
x: 17,
y: 7,
direction: "right",
objects: [],
});
return world;
}
/** USE from the pack, then tick until a page or a battle appears,
* recording the player's facings and emotes meanwhile. */
function cast(world: GameWorld, rod = OLD_ROD) {
open(world);
pick(world, "PACK");
pickItem(world, rod);
const items =
world.dialogue?.choice?.kind === "menu"
? world.dialogue.choice.items
: [];
world.choose(items.indexOf("USE"));
const facings = new Set<number | undefined>(),
emotes = new Set<number>();
let ticks = 0;
for (; ticks < 400 && !world.dialogue && !world.battle; ticks++) {
world.tick();
facings.add(world.poses().find((pose) => pose.id === 0)?.facing);
if (world.emote) emotes.add(world.emote.emote);
}
return { facings, emotes, ticks };
}
it("hooks a Pokémon that attacks", () => {
const world = shore(0);
const { facings, emotes } = cast(world);
// Cast, four bites with the "!" bubble (EMOTE_SHOCK), then the rod
// is pulled in.
expect(facings).toContain(FACING_FISH_RIGHT);
expect([...emotes]).toEqual([0]);
expect(world.dialogue?.pages).toEqual(["Oh!\nA bite!"]);
// .Movement_RestoreRod hid the bubble before the text.
expect(world.emote).toBeUndefined();
expect(world.poses().find((pose) => pose.id === 0)?.facing).toBe(
FACING_FISH_RIGHT,
);
world.advanceDialogue();
// PutTheRodAway, then the battle: BATTLETYPE_FISH.
for (let tick = 0; tick < 100 && !world.battle?.message; tick++)
world.tick();
expect(
world.poses().find((pose) => pose.id === 0)?.facing,
).toBeUndefined();
expect(world.battle?.message?.text).toBe(
"The hooked\nMAGIKARP\nattacked!",
);
expect(world.diagnostic).toBe("");
});
it("gets no bite, or nothing at all where the map has no fish", () => {
for (const group of [undefined, 0]) {
const world = shore(200, SUPER_ROD);
if (group !== undefined) world.map.fishGroup = group;
const { facings, emotes } = cast(world, SUPER_ROD);
expect(facings).toContain(FACING_FISH_RIGHT);
expect(emotes.size).toBe(0);
expect(world.dialogue?.pages).toEqual(["Not even a nibble!"]);
expect(close(world)).toEqual(["Not even a nibble!"]);
expect(
world.poses().find((pose) => pose.id === 0)?.facing,
).toBeUndefined();
expect(world.battle).toBeUndefined();
expect(world.state.bag.keyItems).toEqual([SUPER_ROD]);
}
});
it("needs water ahead and no surfing, or Oak objects", () => {
const turned = shore(0, GOOD_ROD);
turned.restore({ ...turned.save(), direction: "left" });
expect(useItem(turned, GOOD_ROD)).toEqual([OAK_WARNING]);
close(turned);
const surfing = shore(0, GOOD_ROD);
surfing.restore({ ...surfing.save(), x: 18, playerState: 4 });
expect(useItem(surfing, GOOD_ROD)).toEqual([OAK_WARNING]);
close(surfing);
});
});
});
+28 -1
View File
@@ -19,7 +19,8 @@ async function start(
| "pokegear"
| "mom"
| "fly"
| "headbutt" = false,
| "headbutt"
| "fishing" = false,
) {
await page.route("**/src/game/runtime.ts", async (route) => {
const response = await route.fetch(),
@@ -507,6 +508,32 @@ test("Headbutt shakes a tree and a sleeping Hoothoot falls out", async ({
await expect(screen).toHaveAttribute("data-map", "24:3");
});
test("the Old Rod hooks a Magikarp from New Bark Town's shore", async ({
page,
}) => {
await start(page, "fishing");
const text = page.locator("#dialogue-text"),
choices = page.locator("#dialogue-choice"),
pack = page.locator("#mart");
await page.keyboard.press("Enter");
await choices.getByRole("option", { name: /^PACK/ }).click();
await page.keyboard.press("ArrowRight");
await page.keyboard.press("ArrowRight");
await pack.getByRole("button", { name: "OLD ROD", exact: true }).click();
await choices.getByRole("option", { name: "USE", exact: true }).click();
// The cast, 40 frames, four bites with "!", 40 more frames.
await page.clock.runFor(5_000);
await expect(text).toHaveText(/Oh!\s+A bite!/);
await page.keyboard.press("KeyX");
await page.clock.runFor(5_000);
await expect(page.locator("#battle")).toBeVisible();
await expect(page.locator("#battle-text")).toHaveText(
/The hooked\s+MAGIKARP\s+attacked!/,
);
await expect(page.locator("#battle-foe-name")).toHaveText("MAGIKARP");
await expect(page.locator("#game-notice")).toHaveText("");
});
test("Mom keeps the player's money with the digit box", async ({ page }) => {
await start(page, "mom");
const text = page.locator("#dialogue-text"),
+38 -14
View File
@@ -12,7 +12,8 @@ import { generatePartyMon } from "../src/game/party/pokemon";
* the Storm Badge, and New Bark Town and Cherrygrove City visited.
* "headbutt" faces Route 29's first reachable Headbutt tree from below
* with a Pokémon that knows Headbutt; Random always returning 0 drops a
* sleeping Hoothoot. */
* sleeping Hoothoot. "fishing" faces the sea on New Bark Town's shore
* with the Old Rod, which then hooks a Magikarp. */
export function foundationFixture(
assets: CrystalAssets,
room:
@@ -23,7 +24,8 @@ export function foundationFixture(
| "pokegear"
| "mom"
| "fly"
| "headbutt",
| "headbutt"
| "fishing",
) {
const world = new GameWorld(
assets,
@@ -45,7 +47,8 @@ export function foundationFixture(
}),
);
world.state.bag.items = [{ item: 18, quantity: 5 }]; // POTION
if (room === "candy") world.state.bag.items.push({ item: 0x20, quantity: 1 });
if (room === "candy")
world.state.bag.items.push({ item: 0x20, quantity: 1 });
}
if (room === "pokegear" || room === "mom") {
world.state.party = [
@@ -80,10 +83,26 @@ export function foundationFixture(
mon.pp = [15, 0, 0, 0];
world.state.party = [mon];
world.state.flags.add(OW.ENGINE_STORMBADGE);
for (const spawn of [14, 15]) // SPAWN_NEW_BARK, SPAWN_CHERRYGROVE
for (const spawn of [14, 15])
// SPAWN_NEW_BARK, SPAWN_CHERRYGROVE
world.state.flags.add(assets.spawnEngineFlag(spawn)!);
for (let event = 0; event < 8; event++) world.state.events.delete(event);
}
if (room === "fishing") {
world.state.party = [
generatePartyMon(assets.pokemon, {
species: 155,
level: 8,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x9abc,
caughtData: 0,
}),
];
world.state.bag.keyItems.push(0x3a); // OLD_ROD
for (let event = 0; event < 8; event++) world.state.events.delete(event);
}
if (room === "headbutt") {
const mon = generatePartyMon(assets.pokemon, {
species: 158, // TOTODILE
@@ -111,21 +130,26 @@ export function foundationFixture(
const [map, x, y] =
room === "headbutt"
? headbuttSpot(assets)
: room === "center" || room === "candy"
? ["26:5", 9, 2]
: room === "poster"
? ["24:7", 6, 1]
: room === "mom"
? ["24:6", 7, 3]
: room && room !== "pokegear" && room !== "fly"
? ["24:7", 2, 2]
: ["24:4", 9, 6];
: room === "fishing"
? ["24:4", 17, 7]
: room === "center" || room === "candy"
? ["26:5", 9, 2]
: room === "poster"
? ["24:7", 6, 1]
: room === "mom"
? ["24:6", 7, 3]
: room &&
room !== "pokegear" &&
room !== "fly" &&
room !== "fishing"
? ["24:7", 2, 2]
: ["24:4", 9, 6];
world.restore({
...save,
map,
x,
y,
direction: room === "mom" ? "down" : "up",
direction: room === "mom" ? "down" : room === "fishing" ? "right" : "up",
objects: [],
});
return world;
+1
View File
@@ -28,6 +28,7 @@ export class MovementAssets extends CrystalAssets {
environment: 1,
paletteMode: 1,
phoneService: 0,
fishGroup: 0,
blocks: new Uint8Array(16),
warps: [],
backgroundEvents: [],
+73
View File
@@ -6,6 +6,7 @@ import {
Battle,
applyTypeMatchups,
calculateDamage,
magikarpLength,
restoreHealth,
restoredPP,
sendOutText,
@@ -638,3 +639,75 @@ test("native switch texts, healing, PP, happiness and CalcPlayerStats match the
).toEqual(results.stats[index]);
});
});
test("native Magikarp lengths match CalcMagikarpLength", async ({ page }) => {
test.skip(
!existsSync(romPath) || !existsSync(symbolsPath),
"Requires the local user ROM and the private PRET rebuild.",
);
const { at, routine } = loadSymbols();
const reader = new CrystalAssets(new Uint8Array(readFileSync(romPath))).reader;
let seed = 0x4a39;
const next = (limit: number) => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 8) % limit;
};
// Random DVs and IDs, plus indexes below 10 and at the table's edges.
const cases = [
...Array.from({ length: 1500 }, () => ({ dvs: next(0x10000), id: next(0x10000) })),
...[0x0000, 0x0009, 0x000a, 0x00ff, 0x0100, 0xfdff, 0xfe00, 0xff00, 0xffff].map((dvs) => ({
dvs,
id: 0,
})),
];
await openReference(page);
const addresses = {
calc: routine("CalcMagikarpLength"),
length: at("wMagikarpLength"),
};
const results = await page.evaluate(
async ({ addresses, cases }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const file = (document.querySelector("#reference-fixture") as HTMLInputElement).files![0];
const reference = new BinjgbEmulator();
await reference.load(new Uint8Array(await file.arrayBuffer()), document.createElement("canvas"));
const write = (address: number, value: number) => reference.writeMemory(address, value & 0xff);
// Trampoline: DI; SP=$c7fe; switch to the routine's bank; set BC (the
// ID) and DE (the DVs); CALL CalcMagikarpLength; write a sentinel; loop.
const dvs = 0xc100,
id = 0xc102;
const program = [
0xf3, 0x31, 0xfe, 0xc7, 0x3e, addresses.calc.bank, 0xe0, 0x9d, 0xea, 0x00, 0x20,
0x01, id & 0xff, id >> 8, 0x11, dvs & 0xff, dvs >> 8,
0xcd, addresses.calc.address & 0xff, addresses.calc.address >> 8,
0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe,
];
write(0xff70, 1);
const lengths = cases.map((input) => {
write(dvs, input.dvs >> 8);
write(dvs + 1, input.dvs);
write(id, input.id >> 8);
write(id + 1, input.id);
write(0xc6ff, 0);
program.forEach((value, index) => write(0xc000 + index, value));
reference.setProgramCounter(0xc000);
reference.advanceFrames(2);
return {
returned: reference.readMemory(0xc6ff) === 0x42,
feet: reference.readMemory(addresses.length),
inches: reference.readMemory(addresses.length + 1),
};
});
reference.dispose();
return lengths;
},
{ addresses, cases },
);
cases.forEach((input, index) => {
const { returned, ...original } = results[index];
expect(returned, `case ${index} returned`).toBe(true);
expect(magikarpLength(reader, input.dvs, input.id), JSON.stringify(input)).toEqual(original);
});
const lengths = new Set(results.map(({ feet, inches }) => feet * 12 + inches));
expect(lengths.size).toBeGreaterThan(30);
});
+1 -8
View File
@@ -20,7 +20,6 @@ const BICYCLE = 7,
ANTIDOTE = 9,
FULL_RESTORE = 0x0e,
POTION = 0x12,
OLD_ROD = 0x3a,
THUNDERSTONE = 0x17,
HP_UP = 0x1a,
RARE_CANDY = 0x20,
@@ -625,10 +624,8 @@ describe.skipIf(!existsSync(romPath))("the start menu", () => {
expect(world.state.registeredItem).toEqual({ which: 0, item: 0 });
});
it("offers no USE without a field menu, and a notice for unported effects", () => {
it("offers no USE without a field menu", () => {
const world = fixture();
world.state.bag.items.push({ item: BERRY, quantity: 1 });
world.state.bag.keyItems.push(OLD_ROD);
world.state.bag.balls = [{ item: 5, quantity: 3 }];
open(world);
pick(world, "PACK");
@@ -636,11 +633,7 @@ describe.skipIf(!existsSync(romPath))("the start menu", () => {
pickItem(world, 5);
expect(menuItems(world)).toEqual(["GIVE", "TOSS", "QUIT"]);
pick(world, "QUIT");
pickItem(world, OLD_ROD);
pick(world, "USE");
expect(world.notice).toBe("Using OLD ROD is not ported yet.");
close(world);
expect(world.state.bag.keyItems).toContain(OLD_ROD);
});
it("raises a level with a Rare Candy, forgetting a move to learn a new one", () => {