From 397dcef44c0cfc70f19af4917a4c2ef41ed948a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20QUEROL?= Date: Sat, 3 Oct 2026 00:49:35 +0200 Subject: [PATCH] Port the fishing rods and wild Magikarp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/NATIVE_PORT.md | 32 ++++- docs/PLAN.md | 4 +- scripts/check-reference.mjs | 1 + src/game/battle/battle.ts | 79 +++++++++++- src/game/battle/constants.ts | 1 + src/game/rendering/renderer.ts | 28 ++++- src/game/rom/assets.ts | 25 +++- src/game/rom/profile.ts | 17 +++ src/game/scripts/fish.ts | 45 +++++++ src/game/scripts/interpreter.ts | 44 ++++++- src/game/scripts/start-menu.ts | 18 +++ src/game/world/types.ts | 3 + src/game/world/world.ts | 63 +++++++++- tests/battle.test.ts | 2 +- tests/field-items.test.ts | 202 ++++++++++++++++++++++++++++++- tests/foundation-browser.spec.ts | 29 ++++- tests/foundation-fixture.ts | 52 +++++--- tests/movement-fixture.ts | 1 + tests/reference-parity.spec.ts | 73 +++++++++++ tests/start-menu.test.ts | 9 +- 20 files changed, 681 insertions(+), 47 deletions(-) create mode 100644 src/game/scripts/fish.ts diff --git a/docs/NATIVE_PORT.md b/docs/NATIVE_PORT.md index cf83f3c..7ef0e20 100644 --- a/docs/NATIVE_PORT.md +++ b/docs/NATIVE_PORT.md @@ -32,7 +32,7 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta | Movement interpreter | `macros/scripts/movement.asm`, `engine/overworld/movement.asm`, `engine/overworld/map_objects.asm` | Turns, steps, slides, jumps (two tiles with the original arc), sleeps, facing locks, visibility and emotes. `follow` takes one tile toward the leader's previous square. All movement opcodes decode, including teleport, fishing, skyfall, shake and flicker poses; Controlled reference scenes check spin, teleport, skyfall, fishing and flicker direction, visibility and lift. | | Specials | `data/events/special_pointers.asm`, `engine/rtc/timeset.asm`, `engine/overworld/decorations.asm` | Selected by routine address from the ROM table: `SetDayOfWeek`, initial DST flags, `RestartMapMusic` (no audio yet), room decoration toggles, `PokemonCenterPC` and `PlayersHousePC` (Bill's, the player's and Oak's PCs), the Town Map, `HealParty`, `HealMachineAnim` (presentation only), `CheckPokerus` and the `StubbedTrainerRankings_Healings` stub. | | `game/scripts/pc.ts` | `engine/events/pokecenter_pc.asm`, `engine/pokemon/bills_pc_top.asm`, `engine/pokemon/bills_pc.asm`, `engine/events/prof_oaks_pc.asm`, `engine/events/std_collision.asm` | The Pokémon Center and house PCs: Bill's PC (deposit, withdraw, release, change and name boxes, MOVE PKMN W/O MAIL, stats), item storage, the empty mail box and Oak's rating. Menu strings and texts come from the ROM; lists are native. Printing is not ported. | -| `game/scripts/start-menu.ts` | `engine/menus/start_menu.asm`, `engine/pokemon/mon_menu.asm`, `mon_submenu.asm`, `party_menu.asm`, `switchpartymons.asm`, `engine/items/pack.asm`, `engine/items/item_effects.asm` | The start menu, the party menu with the Pokémon submenu (STATS, SWITCH, MOVE, ITEM) and the pack (USE, GIVE, TOSS) with the field item effects listed below, including Rare Candy, stones and vitamins. Menu strings, prompts and texts come from the ROM; screens are native. Field moves from the menu (checked by the world), Softboiled and Milk Drink, SEL and the Select button (`SelectMenu`). MAIL and the rods give a notice. | +| `game/scripts/start-menu.ts` | `engine/menus/start_menu.asm`, `engine/pokemon/mon_menu.asm`, `mon_submenu.asm`, `party_menu.asm`, `switchpartymons.asm`, `engine/items/pack.asm`, `engine/items/item_effects.asm` | The start menu, the party menu with the Pokémon submenu (STATS, SWITCH, MOVE, ITEM) and the pack (USE, GIVE, TOSS) with the field item effects listed below, including Rare Candy, stones and vitamins. Menu strings, prompts and texts come from the ROM; screens are native. Field moves from the menu (checked by the world), Softboiled and Milk Drink, SEL and the Select button (`SelectMenu`). MAIL gives a notice. | | `game/scripts/pokegear.ts` | `engine/pokegear/pokegear.asm`, `engine/phone/phone.asm`, `engine/phone/scripts/mom.asm`, `elm.asm`, `engine/rtc/print_hours_mins.asm` | `PokeGear` with each card's joypad handling: the clock (`Pokegear_UpdateClock`, `PrintHoursMins`), the map card's landmark cursor (`TownMap_InitCursorAndPlayerIconPositions`, `TownMap_GetKantoLandmarkLimits`), the phone list (`PokegearPhone_UpdateDisplayList`, `GetCallerClassAndName`, `PokegearPhoneContactSubmenu`, `CheckCanDeletePhoneNumber`) and calls (`MakePhoneCallFromPokegear`, `CheckPhoneContactTimeOfDay`, `GetMapPhoneService`, `HangUp`). Callee scripts run in the event interpreter on the start menu's draft. Screens are native; the radio's stations are not ported. | | `game/scripts/trainer-card.ts` | `engine/menus/trainer_card.asm`, `engine/gfx/player_gfx.asm` (`GetCardPic`), `engine/gfx/cgb_layouts.asm` (`_CGB_TrainerCard`), `home/game_time.asm` | `TrainerCard`'s two reachable pages with their joypad rules, strings from `.Name_Money`, `.Dex_PlayTime` and `.Badges`, the caught count, and the badges from `TrainerCard_JohtoBadgesOAM` with their eight-frame steps. `rendering/trainer-card.ts` draws the card picture, leader faces and badges from the ROM with the card's palettes and attribute map. `world/game-time.ts` is `GameTimer` (capped at 999:59:59). Text is native; the blinking colon's and badges' phase start at the page load. | | `game/scripts/options.ts` | `engine/menus/options_menu.asm`, `data/default_options.asm`, `engine/battle/core.asm` (`CheckWhetherToAskSwitch`), `engine/menus/start_menu.asm` | `_Option` with `OptionsControl` and each option's routine on the four option bytes, `DefaultOptions`, and the two settings consumed so far: `BATTLE_SHIFT` and `MENU_ACCOUNT`. Strings come from the ROM; the screen is native. | @@ -325,7 +325,8 @@ event flag), `ItemFinder` (the world's `CheckForHiddenItems` picks the queued script), `_CardKey`, `_BasementKey` (map and facing tile in `wPlayerMapX/Y` coordinates) and `_Squirtbottle`, whose `.CheckCanUseSquirtbottle` asks the world's `GetFacingObject` and leaves the -object in `hLastTalked`. Items are used from the +object in `hLastTalked`, and the rods (`UseRod`, below). Items are used +from the Items pocket slot they were chosen from (`wCurItemQuantity`). `ItemActionTextWaitButton` prints `PrintPartyMenuActionText` with the HP change. `ChangeHappiness` outside battle does not touch a battle Pokémon. @@ -356,6 +357,24 @@ keeps the step's count. `TryWildEncounter`'s `CheckRepelEffect` follows `ChooseWildEncounter`. `text_ram` of `wPlayerName`, `wRivalName` and `wMomsName` reads the text context's names. +`FishFunction` runs in the world (`.TryFish`'s surf and `WATER_TILE` checks, +`GetFishingGroup` from the map header's `MAP_FISHGROUP`) and queues +`Script_GotABite`, `Script_NotEvenANibble` or `Script_NotEvenANibble2`; +`scripts/fish.ts` is `Fish` over `FishGroups` and `TimeFishGroups` +(`GetFishGroupIndex`'s swarm groups need `wFishingSwarmFlag`, which only +trainer calls set). The interpreter gained `loademote` (an emote loaded to +the bubble's tiles is what `show_emote` shows next), `loadmem hBGMapMode` +(ignored), `Fishing_CheckFacingUp`, `PutTheRodAway` (after the pending +text) and an accepted `LoadFishingGFX`. `fish_cast_rod` and `fish_got_bite` +set the actor's `OBJECT_ACTION_FISHING` until `PutTheRodAway`; the renderer +then draws `FACING_FISH_*` from the player's tiles with `FishingGFX` or +`KrisFishingGFX` over tiles 2-3, 6-7 and 10-11 and the rod at $fc-$fd, +all in VRAM bank 1 like the player's sprite (`.InitSprite` sets +`OAM_BANK1` below tile $80). Facing offsets are now read as signed bytes. +The battle's `BATTLETYPE_FISH` prints `HookedPokemonAttackedText`. +`LoadEnemyMon`'s `.Magikarp` filters and `CalcMagikarpLength` (with its +`.BCLessThanDE` high-byte comparison) replace the Magikarp refusal. + Tests: `tests/field-items.test.ts` (local ROM) uses each Repel, walks with one through a test-only grass table (weaker Pokémon kept away, a fainted lead, an equal level let through, the last step's message and its replay @@ -363,7 +382,14 @@ after a reload), and covers the Coin Case, the Blue Card, Sacred Ash with and without a fainted Pokémon, both boxes, the save migration, the Itemfinder at each edge of the screen around Route 30's hidden Potion, the keys (a start-menu run with the unopened maps' positions) and the -Squirtbottle (the interpreter facing a test Sudowoodo on Route 36). +Squirtbottle (the interpreter facing a test Sudowoodo on Route 36), +`Fish` against the Shore tables, a bite, no bite and no fish in New Bark +Town with the poses and the bubble, the surf and water refusals, and the +Magikarp DV filters. `tests/reference-parity.spec.ts` compares +`magikarpLength` with `CalcMagikarpLength` in the reference emulator over +1,509 DV and ID pairs. A Chromium/WebKit test hooks a Magikarp with the +Old Rod; the four fishing directions were checked in close-up +screenshots, not against the original. ## Evolution diff --git a/docs/PLAN.md b/docs/PLAN.md index 7e9ecd3..7b5b58e 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -169,7 +169,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli - PACK: the Items, Balls, Key and TM pockets (left and right change pockets; there are no TMs or HMs yet), and each item's submenu chosen by its permissions and field menu (`USE`, `GIVE`, `TOSS`, `SEL`, `QUIT`). TOSS asks how many and confirms. USE follows `UseItem`: Oak's warning without a field use, "You don't have a POKéMON!" for party items without one, and `_DoItemEffect` by the item's `ItemEffects` address: Potions and the other `HealingHPAmounts` items, status healers, Full Restore, Revive and Max Revive, the bitter Energypowder, Energy Root, Heal Powder and Revival Herb, Ethers, Elixers, the MysteryBerry and PP Up. The Bicycle closes the menu and runs `Script_GetOnBike` or `Script_GetOffBike` outdoors; elsewhere Oak objects, because `wFieldMoveSucceeded` shares `wItemEffectSucceeded`'s byte. - PP Up keeps a quirk: `wUsePPUp` shares `wTempPP`'s byte, so `ComputeMaxPP` adds one step (a fifth of the base PP, at most 7) to the current PP as well as the maximum. Sketch and moves with three PP Ups are "maxed out". - The menu runs on a script draft, like the PC. Changes commit when it closes; a save made while it is open keeps the state from before it. The start menu cursor, the party menu cursor and the last pocket stay in memory between openings and are cleared after a battle, as `CleanUpBattleRAM` does. The party cursor reaches the next battle's `ChangeHappiness`. -- Not ported: MAIL, switching to another party member's moves with left and right, item reordering with Select and the fishing rods (each gives a notice and changes nothing; the Escape Rope runs since the Fly section below, Repels and the other items of the last section since then). Rare Candy, evolution stones and vitamins run since the evolution section below; field moves, SEL and Select since the section after it. +- Not ported: MAIL, switching to another party member's moves with left and right, and item reordering with Select (each gives a notice and changes nothing; the Escape Rope runs since the Fly section below, Repels and the other items of the last section since then). Rare Candy, evolution stones and vitamins run since the evolution section below; field moves, SEL and Select since the section after it. ## Implemented: evolution @@ -248,6 +248,8 @@ The numbered sections below keep their full scope. Each one opens with a checkli - The Normal Box and the Gorgeous Box hold the silver and the gold trophy doll (`SetSpecificDecorationFlag`), sent home for the bedroom PC. - The Itemfinder answers "Yes! ITEMFINDER indicates there's an item nearby." when a hidden item not found yet lies on the original's screen around the player (`CheckForHiddenItems`: four squares left and up, five right, four down), and "Nope!" otherwise. Route 30's hidden Potion is the only one on an open map. - The Card Key and the Basement Key run their door scripts only facing the Radio Tower 3F slot or the Goldenrod Underground door (Oak objects elsewhere). The Squirtbottle sprinkles water, and on Route 36 starts the Sudowoodo battle when facing it (`WateredWeirdTreeScript`). None of these maps is open yet. +- The Old, Good and Super Rods fish facing water, never while surfing (Oak objects otherwise; the facing tile may hold an object, as in the original). The map header's fish group decides: none gives "Not even a nibble!" at once; otherwise `Fish` bites below 50% + 1 and picks the rod's `FishGroups` entry, with `TimeFishGroups` for the morning and day or the night. The player casts with the fishing frames (`LoadFishingGFX`'s body and rod tiles), jiggles four times on a bite, shows "!", and after "Oh! A bite!" puts the rod away before a `BATTLETYPE_FISH` battle: "The hooked MAGIKARP attacked!". Qwilfish and Remoraid swarms come from trainer calls, which are not ported. New Bark Town, Route 30 and Cherrygrove City have fish. +- Wild Magikarp now battle: `LoadEnemyMon` draws their DVs again as the original does, through `CalcMagikarpLength` (compared with the original routine) and its filters, whose millimetre limits only work as a floor of four feet that about 39% of draws skip (39 percent + 1), nowhere in map group 9 or on maps numbered 6 (the Lake of Rage check). Unown are still refused. - Save format 3 gained `repel`, `coins` and `blueCardBalance`, migrated by absence to 0. ## Native battles: current state diff --git a/scripts/check-reference.mjs b/scripts/check-reference.mjs index e4b5f7d..3532880 100644 --- a/scripts/check-reference.mjs +++ b/scripts/check-reference.mjs @@ -192,6 +192,7 @@ const ramNames = { visitedSpawns: "wVisitedSpawns", tempWildMonSpecies: "wTempWildMonSpecies", coins: "wCoins", + bgMapMode: "hBGMapMode", blueCardBalance: "wBlueCardBalance", moneyTemp: "hMoneyTemp", }; diff --git a/src/game/battle/battle.ts b/src/game/battle/battle.ts index 2f18b8d..d446be5 100644 --- a/src/game/battle/battle.ts +++ b/src/game/battle/battle.ts @@ -177,8 +177,13 @@ export interface BattleOptions { tutorial?: boolean; /** BATTLETYPE_TREE: a wild Pokémon that fell from a Headbutt tree. */ tree?: boolean; + /** BATTLETYPE_FISH: a wild Pokémon hooked on a fishing rod. */ + fish?: boolean; /** GetWorldMapLocation for the current map (LevelUpHappinessMod). */ landmark?: number; + /** wMapGroup:wMapNumber, which LoadEnemyMon compares with the Lake of + * Rage for wild Magikarp. */ + mapId?: string; /** wPartyMenuCursor as the last party menu outside battle left it. */ partyMenuCursor?: number; /** wMomSavingMoney's MOM_SAVING_MONEY_MASK bits and wMomsMoney: how much @@ -219,6 +224,40 @@ export interface TypeMatchup { /** BattleCommand_DamageCalc's arithmetic. `boost` is a type-boosting held * item's percentage; `current` is the damage already in wCurDamage. * Returns undefined when the original leaves wCurDamage unchanged. */ +/** MAP_LAKE_OF_RAGE (constants/map_constants.asm). */ +const LAKE_OF_RAGE = { group: 9, number: 6 }; + +/** CalcMagikarpLength (engine/events/magikarp.asm): a length in feet and + * inches from the DVs and the trainer ID. The index is rrc(rrc(DV byte)) + * xor rrc(ID byte), per byte. Its .BCLessThanDE compares only the high + * bytes, so MagikarpLengths' thresholds act on the high byte alone and + * the difference below them wraps around 16 bits, as in the original. */ +export function magikarpLength( + r: RomReader, + dvs: number, + playerId: number, +): { feet: number; inches: number } { + const rrc = (value: number) => ((value >> 1) | (value << 7)) & 0xff; + const b = rrc(rrc(dvs >> 8)) ^ rrc((playerId >> 8) & 0xff), + c = rrc(rrc(dvs & 0xff)) ^ rrc(playerId & 0xff), + bc = (b << 8) | c; + let mm: number | undefined; + let p = BATTLE.MagikarpLengths; + if (bc < 10) mm = bc + 190; + else + for (let z = 2; z < 16; z++, p += 3) + if (b < r.byte(p + 1)) { + const quotient = Math.floor(((bc - r.word(p)) & 0xffff) / r.byte(p + 2)); + mm = ((quotient & 0xff) + 100 * z) & 0xffff; + break; + } + // Past the table: bc minus its last threshold, plus 1600. + mm ??= (((bc - r.word(p - 3)) & 0xffff) + 1600) & 0xffff; + // Millimetres × 10 / 254 in a byte, then feet and inches. + const total = Math.floor(((mm * 10) & 0xffff) / 254) & 0xff; + return { feet: Math.floor(total / 12), inches: total % 12 }; +} + export function calculateDamage(input: { power: number; level: number; @@ -479,6 +518,7 @@ export class Battle { if (options.trainer) this.battleMode = C.TRAINER_BATTLE; if (options.tutorial) this.battleType = C.BATTLETYPE_TUTORIAL; else if (options.tree) this.battleType = C.BATTLETYPE_TREE; + else if (options.fish) this.battleType = C.BATTLETYPE_FISH; } private get reader(): RomReader { return this.options.data.reader; @@ -637,14 +677,16 @@ export class Battle { } if (!this.options.wild) throw new UnsupportedBattle("Missing encounter."); const { species, level } = this.options.wild; - if (species === C.UNOWN || species === C.MAGIKARP) - throw new UnsupportedBattle("Unown and Magikarp encounters are not ported yet."); + if (species === C.UNOWN) + throw new UnsupportedBattle("Unown encounters are not ported yet."); const data = this.options.data, base = data.baseData(species); // 25% chance of an item: 23% the first, 2% the second. let item = 0; if (this.random() >= percent(75) + 1) item = this.random() < percent(8) ? base.items[1] : base.items[0]; - const dvs = (this.random() << 8) | this.random(); + let dvs: number; + do dvs = (this.random() << 8) | this.random(); + while (species === C.MAGIKARP && this.rerollMagikarp(dvs)); const stats = calcStats(data, species, dvs, level); const moves: number[] = []; for (const learned of data.learnset(species)) { @@ -680,6 +722,29 @@ export class Battle { this.wildMoves = [...moves]; this.wildPP = [...this.enemy.mon.pp]; } + /** LoadEnemyMon's .Magikarp filters, true when the DVs are drawn again. + * Their limits are in millimetres but CalcMagikarpLength gives feet and + * inches: the two size checks need 6 feet, which no Magikarp reaches + * (they only draw from Random), and the floor of 1024 mm compares + * feet with 4. The Lake of Rage test matches its map group or its map + * number on their own, and skips the floor there. */ + private rerollMagikarp(dvs: number): boolean { + const { feet, inches } = magikarpLength( + this.reader, + dvs, + this.options.playerId, + ); + if (feet === 1536 >> 8 && this.random() >= percent(5)) { + if (inches >= (1616 & 0xff)) return true; + if (this.random() >= percent(20) - 1 && inches >= (1600 & 0xff)) + return true; + } + const [group, number] = (this.options.mapId ?? "0:0").split(":").map(Number); + if (group === LAKE_OF_RAGE.group || number === LAKE_OF_RAGE.number) + return false; + if (this.random() < percent(39) + 1) return false; + return feet < 1024 >> 8; + } /** CheckSleepingTreeMon: in a tree battle, the time of day's * AsleepTreeMons list names the species that sleep. */ private sleepingTreeMon(species: number) { @@ -794,9 +859,11 @@ export class Battle { yield* this.say(BATTLE.BattleText_EnemySentOut); } else yield* this.say( - this.battleType === C.BATTLETYPE_TREE - ? BATTLE.PokemonFellFromTreeText - : BATTLE.WildPokemonAppearedText, + this.battleType === C.BATTLETYPE_FISH + ? BATTLE.HookedPokemonAttackedText + : this.battleType === C.BATTLETYPE_TREE + ? BATTLE.PokemonFellFromTreeText + : BATTLE.WildPokemonAppearedText, ); this.resetBattleParticipants(); this.initBattleMon(); diff --git a/src/game/battle/constants.ts b/src/game/battle/constants.ts index a585c5d..4782867 100644 --- a/src/game/battle/constants.ts +++ b/src/game/battle/constants.ts @@ -311,6 +311,7 @@ export const C = { BATTLETYPE_NORMAL: 0, BATTLETYPE_DEBUG: 2, BATTLETYPE_TUTORIAL: 3, + BATTLETYPE_FISH: 4, BATTLETYPE_ROAMING: 5, BATTLETYPE_CONTEST: 6, BATTLETYPE_FORCESHINY: 7, diff --git a/src/game/rendering/renderer.ts b/src/game/rendering/renderer.ts index 328f004..2e58f9d 100644 --- a/src/game/rendering/renderer.ts +++ b/src/game/rendering/renderer.ts @@ -14,6 +14,11 @@ import { * Headbutt tree back: HideHeadbuttTree's WaitBGMap and the 32 shaking * frames. */ const HEADBUTT_HIDDEN_FRAMES = 36; +/** FACING_FISH_DOWN, followed by the up, left and right fishing frames. */ +const FACING_FISH_DOWN = 0x10; +/** A FACING_* piece's ABSOLUTE_TILE_ID flag: the tile ignores the + * object's first tile. */ +const ABSOLUTE_TILE_ID = 0x04; export class WorldRenderer { width: number = BASE_VIEWPORT.width; @@ -161,7 +166,13 @@ export class WorldRenderer { { transparent: true, flipX }, ); } - for (const pose of [...poses].sort((a, b) => a.y - b.y)) + for (const pose of [...poses].sort((a, b) => a.y - b.y)) { + // The player's fishing frames use LoadFishingGFX's tiles. + const fishing = + pose.id === 0 && + pose.facing !== undefined && + pose.facing >= FACING_FISH_DOWN && + pose.facing <= FACING_FISH_DOWN + 3; this.actor( pose.sprite, pose.x - cameraX, @@ -172,7 +183,9 @@ export class WorldRenderer { pose.palette, pose.facing, pose.inGrass, + fishing ? world.avatar : undefined, ); + } const emote = world.emote && poses.find((pose) => pose.id === world.emote!.object); if (emote) { @@ -251,9 +264,13 @@ export class WorldRenderer { paletteIndex?: number, facing?: number, inGrass = false, + fishing?: "chris" | "kris", ) { const sprite = this.assets.sprite(id); if (!sprite) return; + const graphics = fishing + ? this.assets.fishingSprite(sprite.graphics, fishing === "kris") + : sprite.graphics; const palette = this.assets.objectPalette( paletteIndex ?? sprite.palette, time, @@ -263,12 +280,17 @@ export class WorldRenderer { ? this.assets.facingTiles(facing) : this.assets.facing(direction, sprite.walking ? frame : 0); for (const piece of pieces) { - const tile = piece.tile >= 0x80 ? piece.tile - 0x80 + 12 : piece.tile; + const tile = + fishing && piece.attributes & ABSOLUTE_TILE_ID + ? 24 + piece.tile - 0xfc + : piece.tile >= 0x80 + ? piece.tile - 0x80 + 12 + : piece.tile; drawTile( this.image.data, this.width, this.height, - sprite.graphics, + graphics, tile, x + piece.x, y + piece.y, diff --git a/src/game/rom/assets.ts b/src/game/rom/assets.ts index 3628ffd..d7e13a8 100644 --- a/src/game/rom/assets.ts +++ b/src/game/rom/assets.ts @@ -126,6 +126,7 @@ export class CrystalAssets { environment: r.byte(header + 2), paletteMode: r.byte(header + 7) & 15, phoneService: r.byte(header + 7) >> 4, + fishGroup: r.byte(header + 8), attributes, blocks: r.slice(r.far(attributes + 3), blockWidth * blockHeight), warps: [], @@ -500,6 +501,24 @@ export class CrystalAssets { r.byte(entry + 2), ); } + /** The first vTiles0 tile an emote's graphics are loaded to. */ + emoteTile(index: number): number { + if (index < 0 || index > 11) throw new Error("Unknown emote."); + return (this.reader.word(SYMBOLS.emotes + index * 6 + 4) - 0x8000) >> 4; + } + /** LoadFishingGFX: the player's sprite (24 tiles) with the lower halves + * of the standing frames replaced by FishingGFX or KrisFishingGFX, then + * the two rod tiles that the fishing frames place at $fc and $fd. All + * of them are in VRAM bank 1, as the player's sprite is. */ + fishingSprite(graphics: Uint8Array, kris: boolean): Uint8Array { + const source = kris ? OVERWORLD.KrisFishingGFX : OVERWORLD.FishingGFX; + const result = new Uint8Array(26 * 16); + result.set(graphics.subarray(0, 24 * 16)); + [2, 6, 10, 24].forEach((tile, chunk) => + result.set(this.reader.slice(source + chunk * 32, 32), tile * 16), + ); + return result; + } /** HeadbuttTreeGFX: the shaking tree's two 2x2 frames, eight tiles. */ headbuttTree(): Uint8Array { return this.reader.slice(OVERWORLD.HeadbuttTreeGFX, 8 * 16); @@ -553,9 +572,11 @@ export class CrystalAssets { pointer = r.near(SYMBOLS.facings + facing * 2, r.bank(SYMBOLS.facings)); return Array.from({ length: r.byte(pointer) }, (_, i) => { const p = pointer + 1 + i * 4; + // The offsets are added to the sprite's position modulo 256, so + // they act as signed bytes. return { - y: r.byte(p), - x: r.byte(p + 1), + y: (r.byte(p) << 24) >> 24, + x: (r.byte(p + 1) << 24) >> 24, attributes: r.byte(p + 2), tile: r.byte(p + 3), }; diff --git a/src/game/rom/profile.ts b/src/game/rom/profile.ts index bf52b0f..8c61d0c 100644 --- a/src/game/rom/profile.ts +++ b/src/game/rom/profile.ts @@ -161,6 +161,8 @@ export const RAM = { visitedSpawns: 0xdca5, tempWildMonSpecies: 0xd22e, coins: 0xd855, + /** hBGMapMode: HRAM, like hMoneyTemp below. */ + bgMapMode: 0xffd4, blueCardBalance: 0xdc4b, /** hMoneyTemp: HRAM, not WRAM like the other entries above. */ moneyTemp: 0xffc3, @@ -274,6 +276,16 @@ export const OVERWORLD = { "_BasementKey.BasementKeyScript": 0x507e1, "_Squirtbottle.SquirtbottleScript": 0x5073c, "_Squirtbottle.CheckCanUseSquirtbottle": 0x50753, + FishGroups: 0x92488, + TimeFishGroups: 0x9266f, + Script_GotABite: 0xd035, + Script_NotEvenANibble: 0xd01e, + Script_NotEvenANibble2: 0xd027, + Fishing_CheckFacingUp: 0xd06c, + LoadFishingGFX: 0xb84b3, + PutTheRodAway: 0xd095, + FishingGFX: 0xb84f2, + KrisFishingGFX: 0xb8582, Flypoints: 0x91c5e, "TownMapBubble.Where": 0x91bf4, RuinsOfAlphKabutoChamber_MapAttributes: 0x958d0, @@ -531,6 +543,9 @@ export const MENU = { CardKeyEffect: 0xf745, BasementKeyEffect: 0xf74c, SquirtbottleEffect: 0xf73e, + OldRodEffect: 0xf5a5, + GoodRodEffect: 0xf5a9, + SuperRodEffect: 0xf5ad, VitaminEffect: 0xee3d, RareCandyEffect: 0xef14, ItemStatRoseText: 0xeea6, @@ -548,6 +563,8 @@ export const MENU = { /** Battle engine tables and texts, keyed by their PRET labels so that * \`npm run check:reference\` can verify each address by name. */ export const BATTLE = { + HookedPokemonAttackedText: 0x8075c, + MagikarpLengths: 0xfbca8, ItemUsedText: 0xf83d, BallBrokeFreeText: 0xedb5, BallAppearedCaughtText: 0xedba, diff --git a/src/game/scripts/fish.ts b/src/game/scripts/fish.ts new file mode 100644 index 0000000..52ebddb --- /dev/null +++ b/src/game/scripts/fish.ts @@ -0,0 +1,45 @@ +import type { RomReader } from "../rom/reader"; +import { OVERWORLD } from "../rom/profile"; + +/** The fishing rods' encounters (engine/events/fish.asm and + * data/wild/fish.asm). */ +export interface FishMon { + species: number; + level: number; +} + +/** FISHGROUP_DATA_LENGTH: the bite chance, then a table per rod. */ +const FISHGROUP_DATA_LENGTH = 7; +/** NITE_F: TimeFishGroups' second species from the night on. */ +const NITE_F = 2; + +/** Fish: rod 0 (Old), 1 (Good) or 2 (Super) in FishGroups entry `group` + * (FISHGROUP_*, never FISHGROUP_NONE). A bite when Random falls below the + * group's chance, then the first entry whose chance a second Random does + * not exceed; species 0 picks the time of day's TimeFishGroups entry. + * Undefined when nothing bites. GetFishGroupIndex's swarm groups need + * DAILYFLAGS1_FISH_SWARM_F and wFishingSwarmFlag, which only trainer calls + * set; they are not ported, so the map's own group is used. */ +export function fish( + r: RomReader, + group: number, + rod: number, + timeOfDay: number, + random: () => number, +): FishMon | undefined { + const entry = OVERWORLD.FishGroups + (group - 1) * FISHGROUP_DATA_LENGTH; + if (random() >= r.byte(entry)) return undefined; + let p = r.near(entry + 1 + rod * 2, r.bank(entry)); + const roll = random(); + while (roll > r.byte(p)) p += 3; + let species = r.byte(p + 1), + level = r.byte(p + 2); + if (!species) { + // .TimeEncounter: the level byte indexes TimeFishGroups. + const time = + OVERWORLD.TimeFishGroups + level * 4 + (timeOfDay >= NITE_F ? 2 : 0); + species = r.byte(time); + level = r.byte(time + 1); + } + return { species, level }; +} diff --git a/src/game/scripts/interpreter.ts b/src/game/scripts/interpreter.ts index e060259..af67b58 100644 --- a/src/game/scripts/interpreter.ts +++ b/src/game/scripts/interpreter.ts @@ -234,6 +234,10 @@ const FACING_VECTORS = [ [-1, 0], [1, 0], ] as const; +/** EMOTE_FROM_MEM (constants/script_constants.asm). */ +const EMOTE_FROM_MEM = 0xff; +/** wPlayerDirection's UP, as the host's facing() numbers it. */ +const UP = 1; /** MAP_ROUTE_36 (constants/map_constants.asm). */ const ROUTE_36 = "10:3"; /** The phone holds at most ten contacts (CONTACT_LIST_SIZE). */ @@ -310,7 +314,14 @@ export type ScriptEffect = tutorial?: boolean; /** BATTLETYPE_TREE: a Pokémon that fell from a Headbutt tree. */ tree?: boolean; + /** BATTLETYPE_FISH: a Pokémon hooked on a fishing rod. */ + fish?: boolean; } + /** loademote: the emote graphics (EMOTE_*) that show_emote displays + * next, when they go to the emote bubble's tiles. */ + | { kind: "loademote"; emote: number } + /** PutTheRodAway: the player stops fishing (UpdatePlayerSprite). */ + | { kind: "putrodaway" } /** warp and newloadmap: the world enters this map after the script * ends, facing `facing` (warpfacing) or down (SpawnInFacingDown). * MAPSETUP_DOOR (`door`) keeps the facing unless the arrival tile turns @@ -568,6 +579,9 @@ export class ScriptRun { /** An item used from the pack: UseItem names it in wStringBuffer1 and * CopyName1 copies it to wStringBuffer2. */ usedItem?: number; + /** wBattleType BATTLETYPE_FISH, which FishFunction sets with the + * hooked Pokémon (`wild`). */ + fish?: boolean; } = {}, ) { this.state = copyScriptState(state); @@ -621,9 +635,11 @@ export class ScriptRun { spawn = this.options.fieldMove?.spawn ?? 0; let wild = this.options.wild; // wTempWildMonSpecies/wCurPartyLevel, as the encounter routines leave - // them for randomwildmon, and wBattleType's BATTLETYPE_TREE. + // them for randomwildmon, and wBattleType's BATTLETYPE_TREE and + // BATTLETYPE_FISH. let tempWild = this.options.wild, - treeBattle = false; + treeBattle = false, + fishBattle = this.options.fish ?? false; let trainer: { classId: number; id: number } | undefined; // LoadTrainer_continue's copy fills wWinTextPointer/wLossTextPointer too. let winText = this.tempTrainer?.beatenText || undefined, @@ -950,6 +966,14 @@ export class ScriptRun { sudowoodo = facing?.movement === OW.SPRITEMOVEDATA_SUDOWOODO; } value = Number(sudowoodo); + } else if (routine === OVERWORLD.LoadFishingGFX) { + // The renderer draws the fishing frames from FishingGFX. + } else if (routine === OVERWORLD.Fishing_CheckFacingUp) { + value = Number(this.host.facing() === UP); + } else if (routine === OVERWORLD.PutTheRodAway) { + // After the rod's text, which ends with a prompt. + yield* flush(); + yield synchronous({ kind: "putrodaway" }); } else if (routine === OVERWORLD["ItemFinder.ItemfinderSound"]) { // Four rounds of the Itemfinder's two sound effects. } else if (routine === OVERWORLD["UsedSurfScript.stubbed_fn"]) { @@ -995,9 +1019,11 @@ export class ScriptRun { break; } case 0x1b: { - // loadmem: only the trainer script's wRunningTrainerBattleScript. + // loadmem: the trainer script's wRunningTrainerBattleScript, and + // hBGMapMode, which only affects how the screen is redrawn. const address = word(), stored = byte(); + if (address === RAM.bgMapMode) break; if (address !== RAM.runningTrainerBattleScript) throw new UnsupportedScript(offset, opcode); temp(offset, opcode); @@ -1124,13 +1150,14 @@ export class ScriptRun { trainer: trainer && { ...trainer, winText, lossText }, canLose, tree: treeBattle || undefined, + fish: fishBattle || undefined, }); if (typeof result !== "number") throw new Error("Invalid battle result."); battleResult = result; value = result & 0x3f; // Script_startbattle also updates wScriptVar. // CleanUpBattleRAM clears wBattleType and wTempWildMonSpecies. - treeBattle = false; + treeBattle = fishBattle = false; tempWild = undefined; break; } @@ -1677,6 +1704,15 @@ export class ScriptRun { }); break; } + case 0x74: { + // loademote: EMOTE_FROM_MEM takes the emote from wScriptVar. + const operand = byte(); + yield synchronous({ + kind: "loademote", + emote: operand === EMOTE_FROM_MEM ? value : operand, + }); + break; + } case 0x75: { const emote = byte(), target = byte(), diff --git a/src/game/scripts/start-menu.ts b/src/game/scripts/start-menu.ts index 83f5bab..6ef8f89 100644 --- a/src/game/scripts/start-menu.ts +++ b/src/game/scripts/start-menu.ts @@ -111,6 +111,8 @@ export type MenuFieldMove = wild?: { species: number; level: number }; /** hLastTalked, when the move acts on a facing object. */ lastTalked?: number; + /** BATTLETYPE_FISH: the wild Pokémon is on a fishing rod's hook. */ + fish?: boolean; } | { texts: number[] } | { notice: string } @@ -144,6 +146,8 @@ export interface StartMenuHost { gameTime(): GameTime; /** CheckForHiddenItems: an unfound hidden item on the screen. */ hiddenItemOnScreen(state: ScriptState): boolean; + /** FishFunction with rod 0-2: a script, or a silent failure. */ + fish(state: ScriptState, rod: number): MenuFieldMove; } /** The overworld start menu (engine/menus/start_menu.asm) with its @@ -989,6 +993,20 @@ export class StartMenuRun { this.deferred = MENU.SacredAshScript; this.deferredItem = item; return true; + case MENU.OldRodEffect: + case MENU.GoodRodEffect: + case MENU.SuperRodEffect: { + // UseRod: FishFunction queues a script unless it fails. + const result = this.host.fish( + this.state, + effect === MENU.OldRodEffect ? 0 : effect === MENU.GoodRodEffect ? 1 : 2, + ); + if (!("script" in result)) return false; + this.deferred = result.script; + this.deferredItem = item; + this.deferredMove = result; + return true; + } case MENU.ItemfinderEffect: // ItemFinder: one script or the other, and always a success. this.deferred = this.host.hiddenItemOnScreen(this.state) diff --git a/src/game/world/types.ts b/src/game/world/types.ts index 96da2f2..bd05abc 100644 --- a/src/game/world/types.ts +++ b/src/game/world/types.ts @@ -60,6 +60,9 @@ export interface GameMap { /** The palette byte's high nybble: nonzero where the phone has no * signal (GetMapPhoneService). */ phoneService: number; + /** MAP_FISHGROUP: the FishGroups entry the rods fish from, or + * FISHGROUP_NONE. */ + fishGroup: number; /** The map attributes' ROM offset, which some routines compare. */ attributes?: number; blocks: Uint8Array; diff --git a/src/game/world/world.ts b/src/game/world/world.ts index 3110d51..f64b76f 100644 --- a/src/game/world/world.ts +++ b/src/game/world/world.ts @@ -61,6 +61,7 @@ import { type MenuFieldMove, } from "../scripts/start-menu"; import { PartyEvolution } from "../scripts/evolution"; +import { fish } from "../scripts/fish"; import type { MovementPose, MovementStep } from "../scripts/movement"; import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag"; import { @@ -211,6 +212,9 @@ interface Actor { hidden: boolean; fixedFacing: boolean; moved: boolean; + /** OBJECT_ACTION_FISHING, from fish_cast_rod or fish_got_bite until + * PutTheRodAway: the fishing frames replace the standing ones. */ + fishing?: boolean; /** HandleNPCStep state for map objects: OBJECT_INIT_X/Y, the step type * and OBJECT_STEP_DURATION. "reset" runs the movement function next. */ npc?: { @@ -476,6 +480,8 @@ const forcedDirection = (tile: number): Direction | undefined => { const TURN_WAIT_TICKS = 3; /** STEP_BIKE: 4 ticks at 4 pixels per tick (bikes and ice). */ const BIKE_TICKS = 4; +/** The emote bubble's first tile (Emotes' vTiles0 tile $f8). */ +const EMOTE_BUBBLE_TILE = 0xf8; /** BGEVENT_ITEM: a hidden item's bg_event (constants/script_constants.asm). */ const BGEVENT_ITEM = 7; /** An object's state as LoadMapObjects creates it from the map data. */ @@ -653,6 +659,9 @@ export class GameWorld { /** ShakeHeadbuttTree while it plays: the facing square and the frames * shown. */ private shakingTree?: { x: number; y: number; frames: number }; + /** The EMOTE_* whose graphics are in the emote bubble's tiles, which + * show_emote displays. */ + private bubbleEmote = 0; constructor( readonly assets: CrystalAssets, @@ -877,7 +886,9 @@ export class GameWorld { : {}; const posed = actor.pose && this.posePresentation(actor.pose); const poseFacing = - actor.pose?.pose === "fish-bite" || actor.pose?.pose === "fish-cast" + actor.pose?.pose === "fish-bite" || + actor.pose?.pose === "fish-cast" || + (!actor.pose && actor.fishing) ? 0x10 + DIRECTION_ORDER.indexOf(actor.direction) : actor.pose?.pose === "tree" ? FACING_WEIRD_TREE_0 + ((actor.stepFrame >> 2) & 3) @@ -1977,6 +1988,34 @@ export class GameWorld { facingObject: () => this.facingObject(), }; } + /** FishFunction with rod 0-2 (Old, Good, Super): never while surfing, + * and only facing water (the facing tile may hold an object, as in the + * original). The map's fish group decides the script: nothing at all, + * no bite, or a bite with the hooked Pokémon. A failure (.FailFish) + * prints nothing before Oak's warning. */ + private fishFunction(state: ScriptState, rod: number): MenuFieldMove { + const surfing = + state.playerState === OW.PLAYER_SURF || + state.playerState === OW.PLAYER_SURF_PIKA; + const [dx, dy] = VECTORS[this.player.direction]; + const tile = + this.assets.collision(this.map, this.player.x + dx, this.player.y + dy) ?? 0; + if (surfing || this.assets.permission(tile) !== OW.WATER_TILE) + return { texts: [] }; + const fieldMove = { mon: 0 }, + group = this.map.fishGroup; + if (!group) return { script: OVERWORLD.Script_NotEvenANibble2, fieldMove }; + const hooked = fish( + this.assets.reader, + group, + rod, + this.clockTimeOfDay, + () => this.random() & 0xff, + ); + return hooked + ? { script: OVERWORLD.Script_GotABite, fieldMove, wild: hooked, fish: true } + : { script: OVERWORLD.Script_NotEvenANibble, fieldMove }; + } /** GetFacingObject: the visible object on the tile the player faces. */ private facingObject() { const [dx, dy] = VECTORS[this.player.direction]; @@ -2021,8 +2060,11 @@ export class GameWorld { callerScript, fieldMove, usedItem, + fish, }: { usedItem?: number; + /** BATTLETYPE_FISH, set by FishFunction for a hooked Pokémon. */ + fish?: boolean; fieldMove?: FieldMoveContext; committedPosition?: { x: number; y: number }; scene?: boolean; @@ -2055,6 +2097,7 @@ export class GameWorld { callerScript, fieldMove, usedItem, + fish, }, ), committed, @@ -2126,6 +2169,7 @@ export class GameWorld { landmarkOf: (mapId) => this.assets.map(mapId).landmark, gameTime: () => this.gameTime, hiddenItemOnScreen: (state) => this.hiddenItemOnScreen(state), + fish: (state, rod) => this.fishFunction(state, rod), }, this.menuCursors, select, @@ -2321,6 +2365,7 @@ export class GameWorld { case "emote": // ShowEmoteScript: show (1 tick), pause N ticks, hide (1 tick). this.emote = { object: effect.object, emote: effect.emote }; + this.bubbleEmote = effect.emote; script.waitTicks = effect.ticks + 2; return true; case "pause": @@ -2376,6 +2421,8 @@ export class GameWorld { trainer, canLose: effect.canLose, tree: effect.tree, + fish: effect.fish, + mapId: this.mapId, text: () => this.host(PLAYER).text(), bag: draft.bag, storage: draft.storage, @@ -2419,6 +2466,15 @@ export class GameWorld { this.flight = { ...effect, frames: 0 }; script.waitTicks = effect.landing ? 33 : 65; return true; + case "loademote": + // Emotes bound for the bubble's tiles ($f8) replace its graphics; + // the others (the rod, the shadow, dust) go elsewhere. + if (this.assets.emoteTile(effect.emote) === EMOTE_BUBBLE_TILE) + this.bubbleEmote = effect.emote; + return false; + case "putrodaway": + this.player.fishing = false; + return false; case "headbutt": { const [dx, dy] = VECTORS[this.player.direction]; this.shakingTree = { @@ -2663,6 +2719,8 @@ export class GameWorld { return true; case "pose": actor.pose = this.startPose(actor, step.pose); + if (step.pose === "fish-cast" || step.pose === "fish-bite") + actor.fishing = true; if (step.pose === "tree") actor.stepFrame = (actor.stepFrame + 1) & 15; movement.wait = step.ticks; return true; @@ -2680,7 +2738,7 @@ export class GameWorld { return false; case "emote": this.emote = step.shown - ? { object: id, emote: this.emote?.emote ?? 0 } + ? { object: id, emote: this.bubbleEmote } : undefined; return false; case "shake": @@ -2945,6 +3003,7 @@ export class GameWorld { usedItem: script.run.deferredItem, fieldMove: script.run.deferredMove?.fieldMove, wild: script.run.deferredMove?.wild, + fish: script.run.deferredMove?.fish, } : {}, ); diff --git a/tests/battle.test.ts b/tests/battle.test.ts index a9a8fba..8808af4 100644 --- a/tests/battle.test.ts +++ b/tests/battle.test.ts @@ -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); }); }, diff --git a/tests/field-items.test.ts b/tests/field-items.test.ts index eb941e6..795f19d 100644 --- a/tests/field-items.test.ts +++ b/tests/field-items.test.ts @@ -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(), + emotes = new Set(); + 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); + }); + }); }); diff --git a/tests/foundation-browser.spec.ts b/tests/foundation-browser.spec.ts index 83a4fe7..7299973 100644 --- a/tests/foundation-browser.spec.ts +++ b/tests/foundation-browser.spec.ts @@ -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"), diff --git a/tests/foundation-fixture.ts b/tests/foundation-fixture.ts index e0e3e49..120a3b9 100644 --- a/tests/foundation-fixture.ts +++ b/tests/foundation-fixture.ts @@ -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; diff --git a/tests/movement-fixture.ts b/tests/movement-fixture.ts index 25a9da3..4de2d9c 100644 --- a/tests/movement-fixture.ts +++ b/tests/movement-fixture.ts @@ -28,6 +28,7 @@ export class MovementAssets extends CrystalAssets { environment: 1, paletteMode: 1, phoneService: 0, + fishGroup: 0, blocks: new Uint8Array(16), warps: [], backgroundEvents: [], diff --git a/tests/reference-parity.spec.ts b/tests/reference-parity.spec.ts index a7837b2..55dfd7d 100644 --- a/tests/reference-parity.spec.ts +++ b/tests/reference-parity.spec.ts @@ -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); +}); diff --git a/tests/start-menu.test.ts b/tests/start-menu.test.ts index faabd15..aac1aad 100644 --- a/tests/start-menu.test.ts +++ b/tests/start-menu.test.ts @@ -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", () => {