-- The `special` command's handlers (data/events/special_pointers.asm). -- -- Lifted out of src/script/gen2/Vm.lua because the two are different kinds of -- code: the VM is one interpreter with a shared control flow, and this is 169 -- INDEPENDENT routines that happen to share a dispatch table. Growing them -- inside runList's else-chain would have buried the interpreter. -- -- Dispatch is by NAME. The script byte is an index into SpecialsPointers, -- which the extractor turns into constants.specialOrder, and Vm:specialName -- resolves the one into the other; keying on the label rather than on the -- number means a repointed table cannot silently call the wrong routine, and -- it means a test can assert the mapping against the cache. A Gold cache's -- order has 112 rows and a Crystal one 169 -- the asm files' `add_special` -- match counts are one higher each, because they include the MACRO line. -- -- Three kinds of entry live here: -- -- HANDLERS the routine, ported. Most read or write wScriptVar, so each -- takes the Vm and leaves its answer in `vm.scriptVar`, exactly -- as the asm leaves it in wScriptVar. -- STUBS deliberately out of scope, with the reason written down and a -- SANE return rather than a fall-through. Everything link cable, -- Mystery Gift and the printer is here: those need a second -- console or a Game Boy Printer, and a script that asks and gets -- no answer at all takes the wrong branch. -- ALL the merge, which is what Vm.SPECIALS is. The two sets are -- disjoint by construction and tests/gen2_vm_test.lua asserts it. -- -- Everything a handler needs from the game is one call into `vm.specials`, the -- hook table src/world/gen2/World.lua builds in World:specialHooks. A handler -- with no hooks at all still runs and still leaves the right wScriptVar: that -- is what makes the whole table testable headless. -- -- BLOCKING. A handler runs INSIDE the VM's coroutine (runList calls -- Vm:runSpecial), so it may yield, and Specials.block below is how it parks on -- a screen: the async work is started first and the coroutine only yields if -- the callback has not already fired, which is what lets the same handler work -- against a real pushed screen and against a test stub that answers on the -- spot. The parked yield carries a kind Vm:resume does not recognise, and -- that is deliberate: it means "nothing to do, wait", and the screen's own -- callback is the only thing that can start the script again. -- Two cart tables this file USES but must not re-transcribe. Each already has -- exactly one home, and a second copy here is how the pair drift apart: -- Happiness data/events/happiness_changes.asm, plus ChangeHappiness's tier -- pick, its two carry clamps and its `cp EGG / ret z`. -- Roamers InitRoamMons' three wRoamMon structs, plus the roam walk that -- indexes them BY SLOT. -- BugContest data/wild/bug_contest_mons.asm, ContestScore's tally, the ten -- contestants and the podium, plus the twenty minute clock. -- Apricorns data/items/apricorn_balls.asm and FindApricornsInBag's walk of -- it, plus the fruit trees and the daily rollover Kurt waits on. local Apricorns = require("src.core.gen2.Apricorns") local BugContest = require("src.core.gen2.BugContest") local GameVersion = require("src.core.GameVersion") local Happiness = require("src.core.gen2.Happiness") local Phone = require("src.core.gen2.Phone") local Pokerus = require("src.core.gen2.Pokerus") local Strings = require("src.core.Strings") local Roamers = require("src.core.gen2.Roamers") local Unown = require("src.core.gen2.Unown") local Specials = {} -- constants/script_constants.asm local TRUE, FALSE = 1, 0 -- constants/pokemon_constants.asm, for the handlers that name a species. local MAGIKARP = "MAGIKARP" local SHUCKLE = "SHUCKLE" -- constants/misc_constants.asm GBCHECK_*: what GameboyCheck answers. local GBCHECK_GB, GBCHECK_SGB, GBCHECK_CGB = 0, 1, 2 -- data/events/magikarp_lengths.asm is one table, but the ARITHMETIC around it -- (CalcMagikarpLength) is what actually produces a length, so both are below. -- Rolls. Kept on the module rather than taken from the VM so a test can pin -- every weighted table below without reaching into love.math, and so the two -- calling conventions in this codebase (`random(n) -> 1..n` here, -- `random(n) -> 0..n-1` in src/battle/gen2) cannot get crossed. Specials.random = math.random -------------------------------------------------------------------------- -- Plumbing -------------------------------------------------------------------------- local function hooks(vm) return (vm and vm.specials) or {} end -- Park the coroutine on `start`, which must call its `done` exactly once. -- -- The order matters. `start` runs FIRST, while the coroutine is still on the -- stack, so a hook that answers synchronously (no love, no stack, a test stub) -- sets `finished` before the yield is ever reached and the handler simply -- carries on -- resuming a coroutine that is not suspended would be an error. -- A hook that answers later leaves the coroutine parked on a yield nothing in -- Vm:resume claims, and its own callback is what resumes it. function Specials.block(vm, start) local finished, answer = false, nil start(function(value) answer = value finished = true if vm.co and coroutine.status(vm.co) == "suspended" then vm:resume(value) end end) if finished then return answer end return coroutine.yield({ kind = "specialwait" }) end -- Print a page whose very next act is this handler's OWN `yesorno`. -- -- Vm's one-command lookahead (Vm:textStays) is what keeps a text box standing -- under a YES/NO prompt, but it reads the SCRIPT LIST: inside a hand-ported -- special the row being run is the `special` itself, so nextOp is whatever -- follows it (`waitbutton` for MomScript) and the lookahead is structurally -- blind to a prompt this file raises. Answering it for the length of one page -- is what the cart does anyway -- `PrintText / call YesNoBox` with nothing in -- between, and each of these texts ends in `done`, so DoneText returns with no -- PromptButton (home/text.asm:484) and YesNoBox goes straight up over the box -- nothing closed. Without this the box pops on a press the cart never asks -- for and the question is re-printed underneath the prompt. local function showRawHeld(vm, body) local nextOp = vm.nextOp vm.nextOp = "yesorno" vm:showRaw(body) vm.nextOp = nextOp end -- The party, as the handlers see it. wPartyMon* is one array on the cart and -- one Lua list here, and every routine below that walks it walks this. local function party(vm) local h = hooks(vm) return (h.party and h.party()) or {} end local function save(vm) local h = hooks(vm) return h.save and h.save() or nil end local function data(vm) local h = hooks(vm) return h.data and h.data() or nil end -- wScriptVar, spelled the way the asm spells it so a handler reads as its -- source: `ld a, TRUE / ld [wScriptVar], a`. local function answer(vm, value) vm.scriptVar = value or 0 end -- WaitSFX (pokegold home/audio.asm); a test stub that calls a handler off -- the coroutine has no sfx to drain. local function drainSfx() if coroutine.running() then coroutine.yield({ kind = "waitsfx" }) end end -- Every routine that ends `call GetPokemonName / jp -- CopyPokemonName_Buffer1_Buffer3` puts a name where the next writetext's -- {STRBUF} will find it. local function nameMon(vm, species) local h = hooks(vm) local name = h.monName and h.monName(species) if not name and type(species) == "string" then name = species end if name then vm:setStringBuffer(name) end end -- SelectMonFromParty: the carry flag is "the player pressed B". `onDone` gets -- (index, mon) or (nil, nil), and the handler blocks on it. local function selectMon(vm, prompt) local h = hooks(vm) if not h.selectPartyMon then return nil, nil end local picked = Specials.block(vm, function(done) h.selectPartyMon(prompt, function(index, mon) done({ index = index, mon = mon }) end) end) picked = picked or {} return picked.index, picked.mon end Specials.shared = { TRUE = TRUE, FALSE = FALSE, block = Specials.block, hooks = hooks, party = party, save = save, data = data, answer = answer, nameMon = nameMon, selectMon = selectMon, showRawHeld = showRawHeld, } -------------------------------------------------------------------------- -- Magikarp lengths (engine/events/magikarp.asm) -------------------------------------------------------------------------- -- MagikarpLengths (data/events/magikarp_lengths.asm): fourteen `dwb` triplets -- of "threshold word, divisor byte". Not extracted -- nothing in the ROM's -- script bytecode points at it -- so it is transcribed here beside the -- arithmetic that reads it, in the file's own order. Specials.MAGIKARP_LENGTHS = { { 110, 1 }, -- not used unless the .BCLessThanDE bug is fixed { 310, 2 }, { 710, 4 }, { 2710, 20 }, { 7710, 50 }, { 17710, 100 }, { 32710, 150 }, { 47710, 150 }, { 57710, 100 }, { 62710, 50 }, { 64710, 20 }, { 65210, 5 }, { 65410, 2 }, { 65510, 1 }, -- not used } -- `rrc` on one byte: an 8-bit rotate right, the bit that falls off coming back -- in at the top. local function rrc(byte) byte = (byte or 0) % 256 return math.floor(byte / 2) + (byte % 2) * 128 end local function xorByte(a, b) a, b = (a or 0) % 256, (b or 0) % 256 local out, bit = 0, 1 for _ = 1, 8 do if (a % 2) ~= (b % 2) then out = out + bit end a, b, bit = math.floor(a / 2), math.floor(b / 2), bit * 2 end return out end -- CalcMagikarpLength (engine/events/magikarp.asm), transcribed rather than -- approximated, bug and all: the whole Lake of Rage guru sub-plot is this one -- number, and the bug is what makes long Magikarp rare. -- -- bc = rrc(id_hi) ++ rrc(id_lo) XOR rrc(rrc(dv_hi)) ++ rrc(rrc(dv_lo)) -- -- Then, walking MagikarpLengths with an index that starts at 2: -- * bc < 10 is a special case: the length is bc + 190 mm -- * otherwise the first row whose threshold's HIGH BYTE exceeds bc's high -- byte wins. That is .BCLessThanDE's bug -- `ret c / ret nc` makes the -- low-byte comparison behind it dead code, so only b and d are compared -- -- and it is why `bc - de` underflows and the length lands where it does. -- * the length is 100 * index + low_byte_of((bc - de) / divisor) -- * falling off the end of the table is (bc - 65510) + 1600 -- -- Both truncations are the cart's: Divide leaves a 32-bit quotient and the -- routine reads only hQuotient + 3, its LOW BYTE. function Specials.magikarpLength(otId, dvWord) otId = (otId or 0) % 65536 dvWord = (dvWord or 0) % 65536 local b = xorByte(rrc(math.floor(otId / 256)), rrc(rrc(math.floor(dvWord / 256)))) local c = xorByte(rrc(otId % 256), rrc(rrc(dvWord % 256))) local bc = b * 256 + c local mm if b == 0 and c < 10 then mm = bc + 190 else local index = 2 local de = 0 for _, row in ipairs(Specials.MAGIKARP_LENGTHS) do de = row[1] if b < math.floor(de / 256) then local dividend = (bc - de) % 65536 mm = 100 * index + (math.floor(dividend / row[2]) % 256) break end index = index + 1 end if not mm then mm = ((bc - de) % 65536) + 1600 end end mm = mm % 65536 -- `hl = de * 10`, then a 254-step division: inches = mm * 10 / 254, i.e. -- mm / 25.4. `a` is one byte, so a length past 2550 inches wraps -- which -- no reachable length does. local inches = math.floor(mm * 10 / 254) % 256 return math.floor(inches / 12), inches % 12, mm end -- PrintMagikarpLength: PRINTNUM_LEFTALIGN over one byte each, with the ′ and ″ -- glyphs (font codes $6e / $6f) between them. Written as the plain ASCII pair -- because Font.split has no charmap entry for the two prime marks. local function magikarpLengthText(feet, inches) return string.format("%d'%d\"", feet or 0, inches or 0) end Specials.magikarpLengthText = magikarpLengthText -- The DV word CalcMagikarpLength is handed, out of the port's DV table: -- (attack << 12) | (defense << 8) | (speed << 4) | special, which is the -- MON_DVS pair's own layout. function Specials.dvWord(dvs) if type(dvs) == "number" then return dvs % 65536 end if type(dvs) ~= "table" then return 0 end return ((dvs.attack or 0) % 16) * 4096 + ((dvs.defense or 0) % 16) * 256 + ((dvs.speed or 0) % 16) * 16 + ((dvs.special or 0) % 16) end -------------------------------------------------------------------------- -- The handlers -------------------------------------------------------------------------- local H = {} -- ---- 0 WarpToSpawnPoint --------------------------------------------------- -- The whiteout warp, and the few scripted trips home that borrow it. H.WarpToSpawnPoint = function(vm) if vm.warpToSpawnFn then vm.warpToSpawnFn() end end -- ---- 20-24 the Bug Catching Contest --------------------------------------- -- -- Every rule below lives in src/core/gen2/BugContest.lua and NOT here. These -- five handlers plus BugContestJudging are the gate scripts' half of the -- system: Route35NationalParkGate's officer runs ContestDropOffMons, -- GiveParkBalls and SelectRandomBugContestContestants on the way in, and -- BugContestResultsScript runs BugContestJudging, ContestReturnMons and -- CheckPartyFullAfterContest on the way out. Keeping the state on the SAVE -- (save.bugContest) rather than on the Vm is required, not tidiness: the cart's -- masked party and its park balls are in SRAM and survive a save mid-contest. -- The party as save.bugContest reads it. The hooks hand back the same table -- the save holds, so this only fills in a save that has no `party` key at all -- (a headless test that passed nothing but a hook). local function contestSave(vm) local record = save(vm) if not record then -- A VM built with no `save` hook at all (a headless test) still has a -- party, and the mask has to go somewhere: the VM stands in for the save, -- which is enough for every rule and nothing like enough to survive a -- reload -- exactly what a cartridge with no SRAM would do. vm.contestSave = vm.contestSave or { party = party(vm) } return vm.contestSave end if record.party == nil then record.party = party(vm) end return record end -- ContestDropOffMons: the party is not stored anywhere, it is MASKED -- the -- count is written down to 1 and the second species byte is replaced with the -- -1 terminator, so only the lead mon exists for the duration of the contest. -- -- `.fainted`: a lead mon with 0 HP refuses, and answers TRUE so the gate -- attendant can say so. H.ContestDropOffMons = function(vm) answer(vm, BugContest.dropOffMons(contestSave(vm))) end -- ContestReturnMons: the count is RECOMPUTED by walking to the terminator, so -- a mon caught during the contest is still in slot 2 when the tail goes back -- behind it. Restoring after the caught mon rather than over it is that walk. H.ContestReturnMons = function(vm) BugContest.returnMons(contestSave(vm)) end -- GiveParkBalls: BUG_CONTEST_BALLS is 20, wContestMon is cleared first, and the -- StartBugContestTimer it farcalls is what puts the twenty minutes on the clock. Specials.BUG_CONTEST_BALLS = BugContest.BALLS H.GiveParkBalls = function(vm) BugContest.start(contestSave(vm)) end -- CheckPartyFullAfterContest: the mon caught in the contest goes into the -- party if there is room and into the current box if there is not. wScriptVar -- is the three-way answer BugContestResults_DidNotLeaveMons branches on: -- BUGCONTEST_CAUGHT_MON 0, BUGCONTEST_BOXED_MON 1, BUGCONTEST_NO_CATCH 2 -- (constants/script_constants.asm). -- _CaughtAskNicknameText (data/text/common_2.asm:717), engine-printed so -- the extractor never reaches it. local CONTEST_NICKNAME_PROMPT = Strings.source("Give a nickname to\nthe {STRBUF} you\nreceived?") -- GiveANickname_YesNo (engine/pokemon/caught_nickname.asm:123) function Specials.askNickname(vm, mon) nameMon(vm, mon.species) showRawHeld(vm, Strings(CONTEST_NICKNAME_PROMPT)) if coroutine.yield({ kind = "yesorno" }) then -- InitNickname (engine/pokemon/move_mon.asm:1787) local h = hooks(vm) local name = h.renameMon and Specials.block(vm, function(done) h.renameMon(mon, done, { blank = true }) end) -- _InitString's blank test (home/string.asm:6-30) if name and name:gsub(" ", "") ~= "" then mon.nickname = name end end end H.CheckPartyFullAfterContest = function(vm) local Breeding = require("src.core.gen2.Breeding") local result, mon = BugContest.collectCaughtMon(contestSave(vm), Breeding.PARTY_SIZE) -- GiveANickname_YesNo runs on both contest arms, party and box if mon and result ~= BugContest.NO_CATCH then Specials.askNickname(vm, mon) end answer(vm, result) end -- _BugContestJudging (engine/events/bug_contest/judging.asm): ContestScore over -- wContestMon, BugContest_JudgeContestants for the podium, then the three text -- pages -- third, second, first, in that order -- each followed by its score -- page and its placing jingle. The placing itself is what -- BugContest_GetPlayersResult leaves in wScriptVar, and the gate script's three -- `ifequal`s are the prize branches. -- -- The pages are authored here rather than read out of text.lua because nothing -- in the ROM's script bytecode points at ContestJudging_*Text: they hang off -- engine code, so the extractor never reaches them. local JUDGING = { third = Strings.source("Placing third was\n%s,\fwho caught a\n%s!"), second = Strings.source("Placing second was\n%s,\fwho caught a\n%s!"), first = Strings.source( "This Bug-Catching\nContest winner is\f%s,\nwho caught a\n%s!"), score = Strings.source("The score was\n%d points!"), winningScore = Strings.source("The winning score\nwas %d points!"), } -- SFX_1ST_PLACE / SFX_2ND_PLACE / SFX_3RD_PLACE, by their pokegold labels: the -- text_asm arm of each page plays one and waits for it. local PLACE_SFX = { "Sfx_1stPlace", "Sfx_2ndPlace", "Sfx_3rdPlace" } H.BugContestJudging = function(vm) local record = contestSave(vm) local place = BugContest.runJudging(record) local state = BugContest.state(record) or {} local results = state.results or {} local h = hooks(vm) -- wPlayerName, which LoadContestantName copies straight out for ID 1. local playerName = record.player and record.player.name -- LoadContestantName reads the winner ID, and GetPokemonName the species the -- podium recorded, so a slot nobody filled prints nothing at all. local order = { { entry = results.third, page = JUDGING.third, score = JUDGING.score, sfx = PLACE_SFX[3] }, { entry = results.second, page = JUDGING.second, score = JUDGING.score, sfx = PLACE_SFX[2] }, { entry = results.first, page = JUDGING.first, score = JUDGING.winningScore, sfx = PLACE_SFX[1] }, } for _, row in ipairs(order) do local entry = row.entry if entry then local who = BugContest.contestantName(data(vm), entry.id, playerName) local what = (h.monName and h.monName(entry.species)) or entry.species or "" vm:showRaw(Strings(row.page, who, what)) -- pokegold engine/events/bug_contest/judging.asm:29-32 drainSfx() if h.playSfxNamed then h.playSfxNamed(row.sfx) end vm:showRaw(Strings(row.score, entry.score or 0), nil, nil, true) end end answer(vm, place) end -- ---- 25, 26 the Magikarp guru --------------------------------------------- -- CheckMagikarpLength's four answers, spelled out at the top of the routine: -- 3 a Magikarp that beats the record -- 2 a Magikarp the record still beats -- 1 B pressed in the party list -- 0 the mon picked is not a Magikarp H.CheckMagikarpLength = function(vm) local _, mon = selectMon(vm, "choose") if not mon then answer(vm, 1) return end if mon.species ~= MAGIKARP then answer(vm, 0) nameMon(vm, mon.species) return end local feet, inches = Specials.magikarpLength(mon.otId, Specials.dvWord(mon.dvs)) vm:setStringBuffer(magikarpLengthText(feet, inches)) local record = save(vm) local best = record and record.magikarpRecord local total = (feet or 0) * 12 + (inches or 0) local bestTotal = best and ((best.feet or 0) * 12 + (best.inches or 0)) or 0 if total <= bestTotal then answer(vm, 2) return end if record then record.magikarpRecord = { feet = feet, inches = inches, name = record.player and record.player.name, } end answer(vm, 3) end -- MagikarpHouseSign: the record on the wall, in the string buffer for the -- writetext that follows. A house nobody has beaten yet reads 0'00", which is -- what InitializeMagikarpHouse leaves behind. H.MagikarpHouseSign = function(vm) local record = save(vm) local best = record and record.magikarpRecord vm:setStringBuffer(magikarpLengthText(best and best.feet, best and best.inches)) end -- ---- 27-29 the Pokecenter ------------------------------------------------- H.HealParty = function(vm) if vm.healPartyFn then vm.healPartyFn() end end -- The heal machine's light show, BLOCKING: LoadBallsOntoMachine holds 30 -- frames per party ball and .FlashPalettes8Times ten more per flash, and the -- nurse's "thank you for waiting" must not come up over the machine still -- running. wScriptVar carries the machine's location (`setval HEALMACHINE_*` -- right before the special): 0 Pokecenter, 1 Elm's lab, 2 Hall of Fame. H.HealMachineAnim = function(vm) if not vm.healAnimFn then return end Specials.block(vm, function(done) vm.healAnimFn(vm.scriptVar or 0, done) end) end -- PokemonCenterPC (engine/events/pokecenter_pc.asm): the Pokecenter PC's -- whose-PC top menu. The special only opens the screen -- World:openPc -- pushes src/ui/gen2/CenterPcMenu.lua, which carries the party gate, the -- BILL's / 's / PROF.OAK's / HALL OF FAME gating and the shutdown. -- The asm never writes wScriptVar, so neither does this. H.PokemonCenterPC = function(vm) if vm.openPcFn then vm.openPcFn() end end -- PlayersHousePC ends `ld a, c / ld [wScriptVar], a`, and _PlayersHousePC's c -- is TRUE for exactly one reason: the DECORATION menu moved something. That -- is the branch behind it -- PlayersHousePCScript's `iftrue .Warp`, whose -- `warp NONE, 0, 0` reloads the room so the two decoration callbacks run -- again. A decoration placed with no reload would not appear until the next -- time the map loaded anyway, which is the cart's behaviour and the reason the -- warp is there. H.PlayersHousePC = function(vm) local h = hooks(vm) if not h.playersHousePc then -- No bedroom PC on this side (a test harness, or the Pokecenter hook only): -- open what there is and answer the way a player who changed nothing does. answer(vm, FALSE) if vm.openPcFn then vm.openPcFn() end return end local changed = Specials.block(vm, function(done) h.playersHousePc(done) end) answer(vm, changed and TRUE or FALSE) end -- ToggleDecorationsVisibility / ToggleMaptileDecorations -- (engine/overworld/decorations.asm), the two PLAYERS_HOUSE_2F map callbacks. -- Both rebuild the room from the eight wDeco* bytes: the first writes the four -- object slots (wVariableSprites plus each object's event flag), the second -- paints the bed, the plant, the poster and the carpet into the block buffer. -- -- Neither writes wScriptVar, and neither may block: a map callback is a nested -- script run. With no hooks they do nothing at all, which is the room a save -- with no decorations shows. H.ToggleDecorationsVisibility = function(vm) local h = hooks(vm) if h.toggleDecorationsVisibility then h.toggleDecorationsVisibility() end end H.ToggleMaptileDecorations = function(vm) local h = hooks(vm) if h.toggleMaptileDecorations then h.toggleMaptileDecorations() end end -- ---- 30-32, 68-69 the Day Care -------------------------------------------- -- -- All three doors are the same screen with a different `side`, and the model -- behind it is src/core/gen2/Breeding.lua. DayCareManOutside is the only one -- that writes wScriptVar (TRUE = the party was full, so the egg is kept and -- the script asks again), which is why the push carries its answer back. local function dayCare(vm, side) local h = hooks(vm) if not h.dayCare then answer(vm, FALSE) return end local value = Specials.block(vm, function(done) h.dayCare(side, done) end) answer(vm, value or FALSE) end H.DayCareMan = function(vm) dayCare(vm, "man") end H.DayCareLady = function(vm) dayCare(vm, "lady") end H.DayCareManOutside = function(vm) dayCare(vm, "outside") end -- DayCareMon1 / DayCareMon2: not the conversation, just the "you left X here" -- line, the deposited mon's cry, and -- only when the OTHER side is occupied -- too -- the compatibility line about the pair. Both texts live in -- data/text/common_2.asm and are printed by engine/pokemon/breeding.asm -- rather than by any bytecode, so the extractor seeds its text walker at them -- by name (RomExtractorGen2's NAMED_TEXT); `line` below prefers the cache's -- own characters and falls back to the transcription, which is the shape -- src/ui/gen2/DayCareMenu.lua uses for the same block. local DAY_CARE_LEFT = { man = { label = "_LeftWithDayCareManText", body = Strings.source( "It's {STRBUF}\nthat was left with\nthe DAY-CARE MAN.") }, lady = { label = "_LeftWithDayCareLadyText", body = Strings.source( "It's {STRBUF}\nthat was left with\nthe DAY-CARE LADY.") }, } -- DayCareMonCompatibilityText's five verdicts, keyed by the string -- Breeding.compatibilityText hands back (Breeding.COMPATIBILITY_*), in the -- ASM's own fall-through order. The name in {STRBUF} is the OTHER parent's: -- `ld hl, wBreedMon2Nickname / call DayCareMonCompatibilityText` copies it -- into wStringBuffer1 before the verdict is picked. local COMPATIBILITY = { brimming = { label = "_BreedBrimmingWithEnergyText", body = Strings.source("It's brimming with\nenergy.") }, none = { label = "_BreedNoInterestText", body = Strings.source("It has no interest\nin {STRBUF}.") }, cares = { label = "_BreedAppearsToCareForText", body = Strings.source("It appears to care\nfor {STRBUF}.") }, friendly = { label = "_BreedFriendlyText", body = Strings.source("It's friendly with\n{STRBUF}.") }, interest = { label = "_BreedShowsInterestText", body = Strings.source("It shows interest\nin {STRBUF}.") }, } -- The extracted string for one of the entries above, or its transcription. local function commonLine(vm, entry) if not entry then return "" end local CommonText = require("src.core.gen2.CommonText") return CommonText.get(vm and vm.text, entry.label) or Strings(entry.body) end local function dayCareMon(vm, side) local Breeding = require("src.core.gen2.Breeding") local record = save(vm) local h = hooks(vm) local mine = Breeding.side(record, side) or {} local other = Breeding.side(record, side == "man" and "lady" or "man") or {} local mon = mine.mon if not mon then return end local function monName(m) return m.nickname or m.name or m.species or "#MON" end vm:setStringBuffer(monName(mon)) vm:showRaw(commonLine(vm, DAY_CARE_LEFT[side])) local index = h.monIndex and h.monIndex(mon.species) if index and vm.cryFn then vm.cryFn(index) end -- `bit DAYCARE*_HAS_MON_F / jr z, DayCareMonCursor`: with only one mon in -- there the routine stops at the blinking cursor and says nothing else. if not other.mon then return end local value = Breeding.compatibility(data(vm), mon, other.mon) vm:setStringBuffer(monName(other.mon)) vm:showRaw(commonLine(vm, COMPATIBILITY[Breeding.compatibilityText(value)])) end H.DayCareMon1 = function(vm) dayCareMon(vm, "man") end H.DayCareMon2 = function(vm) dayCareMon(vm, "lady") end -- ---- the Blackthorn move deleter ------------------------------------------- -- -- MoveDeletion (engine/events/move_deleter.asm). MoveDeletersHouse's own -- script is `faceplayer / opentext / special MoveDeletion / waitbutton / -- closetext` (maps/MoveDeletersHouse.asm), so every PrintText below runs -- inside a textbox the caller already opened and this never opens or closes -- one itself. The asm never writes wScriptVar on any path -- there is no -- `ld [wScriptVar], a` anywhere in the routine -- so this handler leaves -- vm.scriptVar exactly as `special` found it, the same way the deliberate -- stubs this replaces used to leave it at a fixed value; nothing reads it -- after this call because MoveDeleter's own script has no branch behind it. -- -- data/text/common_3.asm, transcribed the way DAY_CARE_LEFT above transcribes -- its own common_* text: the extractor does not reach the common banks. local MOVE_DELETER_TEXT = { intro = Strings.source( "Um… Oh, yes, I'm\nthe MOVE DELETER.\n\nI can make #MON\n" .. "forget moves.\n\nShall I make a\n#MON forget?"), declined = Strings.source("No? Come visit me\nagain."), whichMon = Strings.source("Which #MON?"), egg = Strings.source("An EGG doesn't\nknow any moves!"), onlyOneMove = Strings.source("That #MON knows\nonly one move."), whichMove = Strings.source("Which move should\nit forget, then?"), confirm = Strings.source("Oh, make it forget\n{STRBUF}?"), forgot = Strings.source("Done! Your #MON\nforgot the move."), } -- ChooseMoveToDelete is its own screen (engine/pokemon/mon_menu.asm), not a -- textbox, so it goes through World like SelectMonFromParty does. A mon with -- one move never reaches it: `.onlyonemove` is checked first, exactly as the -- asm checks `ld a, [hl] / and a / jr z, .onlyonemove` before the farcall. local function chooseMoveToDelete(vm, mon) local h = hooks(vm) if not h.chooseMoveToDelete then return nil end return Specials.block(vm, function(done) h.chooseMoveToDelete(mon, done) end) end H.MoveDeletion = function(vm) -- engine/events/move_deleter.asm:2-4, PrintText then `call YesNoBox`. showRawHeld(vm, Strings(MOVE_DELETER_TEXT.intro)) local wantsToDelete = coroutine.yield({ kind = "yesorno" }) if not wantsToDelete then vm:showRaw(Strings(MOVE_DELETER_TEXT.declined)) return end vm:showRaw(Strings(MOVE_DELETER_TEXT.whichMon)) local _, mon = selectMon(vm, "choose") if not mon then vm:showRaw(Strings(MOVE_DELETER_TEXT.declined)) return end -- `ld a, [wCurPartySpecies] / cp EGG`: the port marks an egg slot with -- `isEgg` instead of overwriting the species (src/core/gen2/Breeding.lua), -- so that is the flag this reads. if mon.isEgg then vm:showRaw(Strings(MOVE_DELETER_TEXT.egg)) return end if #(mon.moves or {}) <= 1 then vm:showRaw(Strings(MOVE_DELETER_TEXT.onlyOneMove)) return end vm:showRaw(Strings(MOVE_DELETER_TEXT.whichMove)) local index = chooseMoveToDelete(vm, mon) if not index then vm:showRaw(Strings(MOVE_DELETER_TEXT.declined)) return end local entry = mon.moves[index] local d = data(vm) local def = entry and d and d.moves and d.moves[entry.id] vm:setStringBuffer((def and def.name) or (entry and entry.id) or "?") -- engine/events/move_deleter.asm:33-35, the same PrintText / YesNoBox pair. showRawHeld(vm, Strings(MOVE_DELETER_TEXT.confirm)) local reallyDelete = coroutine.yield({ kind = "yesorno" }) if not reallyDelete then vm:showRaw(Strings(MOVE_DELETER_TEXT.declined)) return end -- .DeleteMove: shifts every later move (and its PP) down one slot and -- clears the last one, which is exactly what removing the array entry does -- here -- `mon` is the live save.party reference selectMon handed back, not -- a copy. table.remove(mon.moves, index) -- `call WaitSFX / ld de, SFX_MOVE_DELETED / call PlaySFX / call WaitSFX`: -- wait out whatever the YES/NO click left playing, then the deletion jingle, -- then wait that out too before the last line prints. coroutine.yield({ kind = "waitsfx" }) local h = hooks(vm) if h.playSfxNamed then h.playSfxNamed("Sfx_MoveDeleted", 97) end coroutine.yield({ kind = "waitsfx" }) vm:showRaw(Strings(MOVE_DELETER_TEXT.forgot)) end -- ---- the Goldenrod NAME RATER ---------------------------------------------- -- -- NameRater (engine/events/name_rater.asm). Like MoveDeletion above, the -- caller's own script is just `special NameRater` inside an already-open -- textbox, so every line here PrintTexts into that box and the handler never -- opens or closes one. The asm never writes wScriptVar either, so vm.scriptVar -- is left exactly as `special` found it. -- -- data/text/common_1.asm, transcribed the way MOVE_DELETER_TEXT transcribes -- its own bank -- this one IS reached by the extractor, but a map's own -- `special` byte only points at the routine, not at the text bank behind it. local NAME_RATER_TEXT = { hello = Strings.source( "Hello, hello! I'm\nthe NAME RATER.\n\nI rate the names\nof #MON.\n\n" .. "Would you like me\nto rate names?"), comeAgain = Strings.source("OK, then. Come\nagain sometime."), whichMon = Strings.source("Which #MON's\nnickname should I\nrate for you?"), egg = Strings.source("Whoa… That's just\nan EGG."), perfectName = Strings.source( "Hm… {STRBUF}?\nWhat a great name!\nIt's perfect.\n\nTreat {STRBUF}\n" .. "with loving care."), betterName = Strings.source( "Hm… {STRBUF}…\nThat's a fairly\ndecent name.\n\nBut, how about a\n" .. "slightly better\nnickname?\n\nWant me to give it\na better name?"), whatName = Strings.source("All right. What\nname should we\ngive it, then?"), finished = Strings.source("That's a better\nname than before!\n\nWell done!"), sameName = Strings.source( "It might look the\nsame as before,\n\nbut this new name\n" .. "is much better!\n\nWell done!"), named = Strings.source("All right. This\n#MON is now\nnamed {STRBUF}."), } -- IsNewNameEmpty: the typed name is empty if every character up to the -- terminator (or MON_NAME_LENGTH - 1) is a space -- a blank keyboard entry -- reads the same as a cancelled one. local function isBlankName(name) return not name or name:match("^%s*$") ~= nil end local function renameMon(vm, mon) local h = hooks(vm) if not h.renameMon then return nil end return Specials.block(vm, function(done) h.renameMon(mon, done) end) end -- CheckIfMonIsYourOT: the OT name AND the OT id both have to match, or the -- mon reads as traded. A mon that has never changed hands carries no `ot` / -- `otId` at all (Mon.new sets neither), which this treats as "yours" the -- same way H.FindPartyMonThatSpeciesYourTrainerID's `mon.otId == nil` arm -- does. local function isTradedMon(vm, mon) if not mon then return false end local record = save(vm) local player = record and record.player if not player then return false end if mon.ot ~= nil and mon.ot ~= player.name then return true end if mon.otId ~= nil and mon.otId ~= player.id then return true end return false end H.NameRater = function(vm) -- engine/events/name_rater.asm:3-5, PrintText then `call YesNoBox`. showRawHeld(vm, Strings(NAME_RATER_TEXT.hello)) local wantsToRate = coroutine.yield({ kind = "yesorno" }) if not wantsToRate then vm:showRaw(Strings(NAME_RATER_TEXT.comeAgain)) return end vm:showRaw(Strings(NAME_RATER_TEXT.whichMon)) local _, mon = selectMon(vm, "choose") if not mon then vm:showRaw(Strings(NAME_RATER_TEXT.comeAgain)) return end -- `cp EGG`, the same isEgg flag MoveDeletion checks above. if mon.isEgg then vm:showRaw(Strings(NAME_RATER_TEXT.egg)) return end -- GetCurNickname puts the current name where {STRBUF} finds it before -- either of the two texts below read it. local currentName = mon.nickname or mon.name or mon.species or "?" vm:setStringBuffer(currentName) if isTradedMon(vm, mon) then vm:showRaw(Strings(NAME_RATER_TEXT.perfectName)) return end -- engine/events/name_rater.asm:21-23, the same PrintText / YesNoBox pair. showRawHeld(vm, Strings(NAME_RATER_TEXT.betterName)) local wantsRename = coroutine.yield({ kind = "yesorno" }) if not wantsRename then vm:showRaw(Strings(NAME_RATER_TEXT.comeAgain)) return end vm:showRaw(Strings(NAME_RATER_TEXT.whatName)) local newName = renameMon(vm, mon) -- IsNewNameEmpty and CompareNewToOld both fall into `.samename`: an empty -- entry or a re-typed copy of the old name is treated as "unchanged", not -- as a second decline. local unchanged = isBlankName(newName) or newName == currentName local finalName = currentName if not unchanged then mon.nickname = newName finalName = newName end -- `.samename` re-runs GetCurNickname (now the new name, on the changed -- path) before NameRaterNamedText, then falls into whichever of -- FinishedText / SameNameText applies. vm:setStringBuffer(finalName) vm:showRaw(Strings(NAME_RATER_TEXT.named)) if unchanged then vm:showRaw(Strings(NAME_RATER_TEXT.sameName)) else vm:showRaw(Strings(NAME_RATER_TEXT.finished)) end end -- ---- 36 NameRival --------------------------------------------------------- -- engine/events/specials.asm NameRival: `farcall _NamingScreen` returns only -- when the keyboard closes, then InitName fills an empty wRivalName with the -- version default ("SILVER" on Gold). That default is the FALLBACK, not the -- seed: before this special runs, wRivalName holds InitializeNPCNames' "???" -- (engine/menus/intro_menu.asm), which is the name the Cherrygrove theft -- battle prints. The script's very next writetext is the -- officer's "OK! So " line, so the handler has to PARK on the screen: -- running on past it printed the old name with the keyboard still up. H.NameRival = function(vm) if not vm.nameRivalFn then return end Specials.block(vm, function(done) vm.nameRivalFn(done) end) end -- ---- 37, 108-110 the clock ------------------------------------------------ -- SetDayOfWeek (engine/rtc/timeset.asm:382): the "what day is it?" wheel Mom -- puts up with the POKeGEAR. It BLOCKS -- the special does not return until -- the player has picked a day and confirmed it -- which is why the script's -- `.SetDayOfWeek` label loops back here. The screen is -- src/ui/gen2/InitClock.lua's day mode; a run with no screen to push falls back -- to the host clock's own day, which is what the player would have picked. H.SetDayOfWeek = function(vm) local record = save(vm) if not record then return end local Clock = require("src.core.gen2.Clock") local h = hooks(vm) local picked if h.setDayOfWeek then picked = Specials.block(vm, function(done) h.setDayOfWeek(done) end) end if type(picked) ~= "number" then Clock.setWeekday(record, Clock.hostWeekday()) end record.rtc = record.rtc or {} record.rtc.day = tonumber(os.date("%j")) or record.rtc.day end -- InitialSetDSTFlag / InitialClearDSTFlag (engine/rtc/timeset.asm): one bit -- in wDST, asked once during Mom's clock ladder. Each routine also reprints -- the clock and puts its OWN confirmation into the open textbox -- the -- PrintHoursMins time in front of .DSTIsThatOKText / .TimeAskOkayText -- and -- the `yesorno` right after it in PlayersHouse1F's script reads THAT page; its -- iffalse loops back to `.SetDayOfWeek`. A handler that stays silent leaves -- the confirm prompt hanging on the previous question, which reads as Mom -- asking about DST over and over. local function dstConfirmTime(vm) -- PrintHoursMins reads hHours / hMinutes, which are the GAME clock -- the -- base InitClock and Mom's own wheel just wrote, not the host's. local Clock = require("src.core.gen2.Clock") local record = save(vm) local w = hooks(vm).world local hour = (w and w.hour and w:hour()) or Clock.hour(record) return string.format("%d:%02d", hour, Clock.minute(record)) end local DST_CONFIRM = Strings.source("%s DST,\nis that OK?") local TIME_CONFIRM = Strings.source("%s,\nis that OK?") H.InitialSetDSTFlag = function(vm) local record = save(vm) if record then record.rtc = record.rtc or {} record.rtc.dst = true end vm:showRaw(Strings(DST_CONFIRM, dstConfirmTime(vm))) end H.InitialClearDSTFlag = function(vm) local record = save(vm) if record then record.rtc = record.rtc or {} record.rtc.dst = false end vm:showRaw(Strings(TIME_CONFIRM, dstConfirmTime(vm))) end -- MrChrono prints the raw RTC registers into the text box: a debug readout the -- cart leaves reachable through the Goldenrod clock man. The numbers are put -- in the string buffer rather than laid out by hand, since nothing here owns a -- text box. H.MrChrono = function(vm) local record = save(vm) local rtc = (record and record.rtc) or {} vm:setStringBuffer(string.format("RT %d DF %d", rtc.day or 0, rtc.dst and 1 or 0)) end -- ---- 40 the wall radios ----------------------------------------------------- -- -- MapRadio (engine/events/specials.asm): `ld a, [wScriptVar] / ld e, a / -- farcall PlayRadio`. The setval before the special left a MAPRADIO_* station -- index in wScriptVar, and PlayRadio blocks with the joypad until A or B -- -- which is what the six in-house radios and the bedroom set after the starter -- all are (std_scripts.asm Radio1Script / Radio2Script). The screen is -- src/ui/gen2/MapRadio.lua; the push goes through hooks.pushScreen so a test -- can stub the seam. Neither the special nor PlayRadio writes wScriptVar -- back, so vm.scriptVar keeps the station index it arrived with. H.MapRadio = function(vm) local h = hooks(vm) if not h.pushScreen then return end local channel = vm.scriptVar or 0 Specials.block(vm, function(done) local ok = h.pushScreen("Gen2MapRadio", { channel = channel, onDone = function() done(true) end, }) if not ok then done(false) end end) end -- ---- 42-44 the Game Corner ------------------------------------------------ -- -- StartGameCornerGame is CheckCoinsAndCoinCase and then the machine. The -- check is transcribed here rather than left to the screen, because its two -- refusals are TEXT and the script has to see them before the machine opens: -- no coins at all, or no COIN_CASE to hold them. local COIN_CASE = 0x36 -- constants/item_constants.asm:62 -- _NoCoinsText / _NoCoinCaseText, data/text/common_1.asm. local NO_COINS_TEXT = "You have no coins." local NO_COIN_CASE_TEXT = Strings.source("You don't have a\nCOIN CASE.") local function gameCornerGame(vm, kind) local h = hooks(vm) local coins = (h.coins and h.coins()) or 0 if coins == 0 then vm:showRaw(Strings(NO_COINS_TEXT)) return end if h.hasItem and not h.hasItem(COIN_CASE) then vm:showRaw(Strings(NO_COIN_CASE_TEXT)) return end if not h.gameCornerGame then return end Specials.block(vm, function(done) h.gameCornerGame(kind, done) end) end H.SlotMachine = function(vm) gameCornerGame(vm, "slots") end H.CardFlip = function(vm) gameCornerGame(vm, "cardflip") end -- ---- the Ruins of Alph ---------------------------------------------------- -- -- UnownPuzzle: `call FadeToMenu / farcall _UnownPuzzle / ld a, -- [wSolvedUnownPuzzle] / ld [wScriptVar], a / call ExitAllMenus`. -- -- wScriptVar goes IN as well as out: LoadUnownPuzzlePiecesGFX reads it -- (`maskbits NUM_UNOWN_PUZZLES`) to pick which of the four pictures is being -- assembled, which is what the chamber's `setval UNOWNPUZZLE_KABUTO` in front -- of the special is for. So the id has to be read before the screen opens and -- the answer written after it closes, and a screen that cannot open answers 0 -- -- the same "backed out" arm a quit takes. H.UnownPuzzle = function(vm) local h = hooks(vm) local puzzle = (vm.scriptVar or 0) % 4 if not h.unownPuzzle then vm.scriptVar = 0 return end local solved = Specials.block(vm, function(done) h.unownPuzzle(puzzle, done) end) vm.scriptVar = solved and 1 or 0 end -- CountUnown has no row in SpecialsPointers and therefore no handler here: it -- is a plain routine, and its one caller is engine/overworld/variables.asm -- .UnownCaught, which reads the count out of b. That is VAR_UNOWNCOUNT, and -- World:readVar answers it from the same list (src/core/gen2/Unown.lua). -- UnownPrinter (engine/events/print_unown.asm _UnownPrinter), the research -- centre's ALPH RUINS STAMP machine. It is one of the printer specials and -- it is the one that is NOT stubbed, because only half of it needs the -- peripheral: the viewer -- the sheet of stamps LEFT and RIGHT walk through -- -- is drawn on the cartridge itself, and only the A press farcalls -- PrintUnownStamp. src/ui/gen2/UnownPrinter.lua is that viewer and takes the -- A press nowhere, which is what a cartridge with nothing in its link port -- does; PrintDiploma next door shows its page and prints nothing either. -- -- `ld a, [wUnownDex] / and a / ret z` is the gate: with no Unown caught the -- special returns before it draws anything. The routine never writes -- wScriptVar, so vm.scriptVar is left exactly as `special` found it -- the -- same contract H.PhotoStudio and H.MoveDeletion keep. H.UnownPrinter = function(vm) local h = hooks(vm) local file = save(vm) if file and #Unown.dex(file) == 0 then return end if not h.showUnownPrinter then return end Specials.block(vm, function(done) h.showUnownPrinter(function() done(true) end) end) end -- GameCornerPrizeMonCheckDex: a prize mon the player has never caught shows -- its #DEX page as it is handed over. The catch itself is the prize counter's -- job (src/ui/gen2/PrizeMenu.lua); this is the entry, and wScriptVar carries -- the species in and out untouched. H.GameCornerPrizeMonCheckDex = function(vm) local record = save(vm) local h = hooks(vm) local species = h.monName and h.monName(vm.scriptVar) if not (record and species) then return end record.pokedex = record.pokedex or { seen = {}, caught = {} } if record.pokedex.caught[species] then return end record.pokedex.seen[species] = true record.pokedex.caught[species] = true end -- UnusedSetSeenMon: SetSeenMon on wScriptVar - 1. Unreferenced in Gold, but -- one line and correct. H.UnusedSetSeenMon = function(vm) local record = save(vm) local h = hooks(vm) local species = h.monName and h.monName(vm.scriptVar) if not (record and species) then return end record.pokedex = record.pokedex or { seen = {}, caught = {} } record.pokedex.seen[species] = true end -- ---- 45-55 the presentation block ----------------------------------------- -- -- None of these is state. They are the fade, the palette reload and the -- sprite refresh a scripted cutscene brackets its set change with, and the -- port's single map image plus one people list is what stands in for the -- cart's VRAM shuffling. Every one is listed rather than folded together so -- that a reader looking for FadeOutToBlack finds it. local function fade(vm, kind) local h = hooks(vm) if h.fade then h.fade(kind) end end H.FadeOutToWhite = function(vm) fade(vm, "outWhite") end H.FadeOutToBlack = function(vm) fade(vm, "outBlack") end H.FadeInFromWhite = function(vm) fade(vm, "inWhite") end H.FadeInFromBlack = function(vm) fade(vm, "inBlack") end -- engine/tilesets/timeofday_pals.asm:130: FillWhiteBGColor, then the same -- c=$9 / b=4 time-pal walk FadeInFromWhite runs, stepped by hand. H.BattleTowerFade = function(vm) fade(vm, "inWhite") end -- ClearBGPalettes / ClearBGPalettesBufferScreen / ClearTilemap: the screen is -- blanked to the background colour under a fade that is already down. The -- port fades with a flat sheet, so the sheet IS the cleared screen. H.ClearBGPalettes = function(vm) fade(vm, "outBlack") end H.ClearBGPalettesBufferScreen = function(vm) fade(vm, "outBlack") end H.ClearTilemap = function(vm) fade(vm, "outBlack") end -- UpdateTimePals: re-resolve the clock's palette without touching anything -- else, which is exactly what World:applyPalettes plus a re-bake does. H.UpdateTimePals = function(vm) local h = hooks(vm) if h.reloadSprites then h.reloadSprites(true) end end -- UpdateSprites / ReloadSpritesNoPalettes: respawn the object list. The -- second one is the same walk with LoadMapPalettes skipped, which is why a -- scripted swap of an NPC's sprite does not restart the palette fade. H.UpdateSprites = function(vm) local h = hooks(vm) if h.reloadSprites then h.reloadSprites(true) end end H.ReloadSpritesNoPalettes = function(vm) local h = hooks(vm) if h.reloadSprites then h.reloadSprites(false) end end -- LoadUsedSpritesGFX: the VRAM pack for whichever sprites this map actually -- uses. The port loads a sheet per sprite on demand, so the whole routine is -- the rebuild that follows it. H.LoadUsedSpritesGFX = function(vm) local h = hooks(vm) if h.reloadSprites then h.reloadSprites(false) end end -- ../pokecrystal/engine/overworld/warp_connection.asm:311, `ld b, SCGB_MAPPALS / jp -- GetSGBLayout`: the map's own palette layout, reapplied and nothing else. H.LoadMapPalettes = function(vm) local h = hooks(vm) if h.reloadSprites then h.reloadSprites(true) end end -- engine/overworld/overworld.asm:40: the used-sprite list rebuilt and its -- VRAM pack reloaded, with no palette pass -- LoadUsedSpritesGFX's arm. H.RefreshSprites = function(vm) local h = hooks(vm) if h.reloadSprites then h.reloadSprites(false) end end -- UpdatePlayerSprite: the player's sheet is a pure function of wPlayerState -- (data/sprites/player_sprites.asm ChrisStateSprites), which is what makes -- getting on and off a Lapras a one-byte change rather than an animation. H.UpdatePlayerSprite = function(vm) local h = hooks(vm) if h.updatePlayerSprite then h.updatePlayerSprite() end end -- engine/events/specials.asm:21 -> engine/overworld/map_objects.asm:2515: -- bit 7 of wScriptVar gates the routine, bits 6-4 are the OBJ palette. H.SetPlayerPalette = function(vm) local value = (vm.scriptVar or 0) % 0x100 if value < 0x80 then return end vm.playerPalette = math.floor(value / 0x10) % 8 local h = hooks(vm) if h.setPlayerPalette then h.setPlayerPalette(vm.playerPalette) end end -- ---- 58-62 sound and the water -------------------------------------------- -- WaitSFX: hold until the sound effect that is playing finishes. The VM has -- the whole mechanism already (the `waitsfx` opcode), so this is that yield. H.WaitSFX = function(vm) coroutine.yield({ kind = "waitsfx" }) end H.PlayMapMusic = function(vm) local h = hooks(vm) if h.playMapMusic then h.playMapMusic() end end H.RestartMapMusic = function(vm) local h = hooks(vm) if h.restartMapMusic then h.restartMapMusic() end end -- FadeOutMusic: MUSIC_NONE into wMusicFadeID with a control of 2, i.e. a fast -- ramp to silence and nothing queued behind it. H.FadeOutMusic = function(vm) local h = hooks(vm) if h.fadeOutMusic then h.fadeOutMusic() end end -- SurfStartStep: the player goes onto the water. Script_UsedSurf calls this -- rather than doing it itself, so the script route and the party-menu route -- land in the same place. H.SurfStartStep = function(vm) local h = hooks(vm) if h.surfStartStep then h.surfStartStep(party(vm)[1]) end end -- PlayCurMonCry / PlaySlowCry: the cry of wCurPartySpecies, and the same cry -- at a lower pitch (the Lake of Rage Gyarados, the Snorlax). The port has no -- pitch control on a cry, so the slow one is the ordinary one -- which is a -- known, deliberate flattening rather than a missing call. local function currentCry(vm) local h = hooks(vm) local species = vm.scriptVar if species and species ~= 0 and vm.cryFn then vm.cryFn(species) return end local mon = party(vm)[1] local index = mon and h.monIndex and h.monIndex(mon.species) if index and vm.cryFn then vm.cryFn(index) end end H.PlayCurMonCry = currentCry H.PlaySlowCry = currentCry -- ---- 63-66 the party searches --------------------------------------------- -- -- Four routines that share FoundOne / FoundNone: wScriptVar goes IN as the -- thing looked for and comes back TRUE or FALSE. The port's party is a list -- of records, so each predicate is one line. local function findPartyMon(vm, predicate) local wanted = vm.scriptVar or 0 for _, mon in ipairs(party(vm)) do if predicate(mon, wanted) then answer(vm, TRUE) return end end answer(vm, FALSE) end H.FindPartyMonAboveLevel = function(vm) findPartyMon(vm, function(mon, level) return (mon.level or 0) >= level end) end H.FindPartyMonAtLeastThatHappy = function(vm) findPartyMon(vm, function(mon, want) return (mon.happiness or 0) >= want end) end H.FindPartyMonThatSpecies = function(vm) local h = hooks(vm) findPartyMon(vm, function(mon, wanted) return h.monIndex and h.monIndex(mon.species) == wanted end) end -- _FindPartyMonThatSpeciesYourTrainerID additionally requires the mon to be -- YOURS: it is the check that stops a traded Pokemon from counting, which is -- the whole point of the routine (the Goldenrod bike shop, the Trainer House). H.FindPartyMonThatSpeciesYourTrainerID = function(vm) local h = hooks(vm) local record = save(vm) local myId = record and record.player and record.player.id findPartyMon(vm, function(mon, wanted) if not (h.monIndex and h.monIndex(mon.species) == wanted) then return false end return myId == nil or mon.otId == nil or mon.otId == myId end) end -- UnusedCheckUnusedTwoDayTimer: a timer nothing else in the ROM reads. Kept -- so the name resolves, answering the 0 an untouched wUnusedTwoDayTimer holds. H.UnusedCheckUnusedTwoDayTimer = function(vm) answer(vm, 0) end -- ---- 70-71 the swarms and the contestants --------------------------------- -- SelectRandomBugContestContestants: five of the ten contestant flags are set, -- and a SET flag is what keeps that trainer OFF the contest map -- and, at -- judging time, out of ComputeAIContestantScores. The rejection roll is -- BugContest.pickContestants, which stores the choice as a SET keyed by slot: -- a list of indices would make `absent[1]` the first PICK rather than the first -- slot's flag, and every judging would then skip contestant 1. -- -- The flag table itself is data/events/bug_contest_flags.asm, which the -- extractor writes as eventTables.bugContestFlags; applyContestantFlags is the -- `.loop1` that RESETS all ten before the five are set, so a second contest -- does not inherit the first one's absentees and empty the park. -- -- `vm.events` and `vm.eventTables` rather than a specials hook: both are -- already on the VM (Vm.new takes them), setevent and clearevent write through -- the same object, and onFlagsChanged is the deferred rebuild those two use so -- a flag written mid-script does not pop an NPC out from under the player. Specials.NUM_BUG_CONTESTANTS = BugContest.NUM_CONTESTANTS Specials.BUG_CONTESTANTS_PICKED = BugContest.CONTESTANTS_PICKED H.SelectRandomBugContestContestants = function(vm) local chosen = BugContest.pickContestants(contestSave(vm)) BugContest.applyContestantFlags(vm.events, chosen, vm.eventTables) if vm.events and vm.onFlagsChanged then vm.onFlagsChanged() end end -- ActivateFishingSwarm: wFishingSwarmFlag takes wScriptVar, and the routine -- FALLS THROUGH into SetSwarmFlag -- so the map pair and DAILYFLAGS1_SWARM are -- both live afterwards. A port that set only the flag would leave the Qwilfish -- swarm on for good. H.ActivateFishingSwarm = function(vm) local record = save(vm) if not record then return end record.dailyFlags = record.dailyFlags or {} record.dailyFlags.fishingSwarm = vm.scriptVar or 0 record.dailyFlags.swarm = true end -- ---- 74-77 gifts and health ----------------------------------------------- -- GiveShuckle: a level 15 SHUCKLE holding a BERRY, with Mania's own OT and -- trainer ID, nicknamed SHUCKIE. Those four facts are what ReturnShuckie -- checks before it will take the thing back, so none of them is decoration. Specials.MANIA_OT_ID = 518 Specials.MANIA_OT = "MANIA" Specials.SHUCKIE_NICKNAME = "SHUCKIE" Specials.SHUCKIE_LEVEL = 15 H.GiveShuckle = function(vm) local Breeding = require("src.core.gen2.Breeding") local Mon = require("src.battle.gen2.Mon") local list = party(vm) if #list >= Breeding.PARTY_SIZE then answer(vm, FALSE) return end local mon = Mon.new(data(vm), SHUCKLE, Specials.SHUCKIE_LEVEL, { nickname = Specials.SHUCKIE_NICKNAME, item = "BERRY", }) if not mon then answer(vm, FALSE) return end mon.ot = Specials.MANIA_OT mon.otId = Specials.MANIA_OT_ID list[#list + 1] = mon -- TryAddMonToParty's .registerpokedex (engine/pokemon/move_mon.asm:188-196) #1719 local record = save(vm) if record then record.pokedex = record.pokedex or { seen = {}, caught = {} } record.pokedex.seen[mon.species] = true record.pokedex.caught[mon.species] = true end answer(vm, TRUE) end -- ReturnShuckie: the mon has to BE the Shuckie -- species, Mania's trainer ID -- and Mania's OT name, all three -- and it has to be conscious. wScriptVar's -- five answers are the five arms of the routine. Ported to match the asm's -- own numbering (constants/script_constants.asm) rather than Lua's usual -- "0 is the sane default" habit, since ManiasHouse.asm's `ifequal` chain -- checks these values by number. Specials.SHUCKIE_WRONG_MON = 0 Specials.SHUCKIE_REFUSED = 1 Specials.SHUCKIE_RETURNED = 2 Specials.SHUCKIE_HAPPY = 3 Specials.SHUCKIE_FAINTED = 4 -- .HappyToStayWithYou's threshold: 150 happiness or better and Mania lets -- you keep Shuckie instead of taking it back. Specials.SHUCKIE_HAPPY_THRESHOLD = 150 H.ReturnShuckie = function(vm) local index, mon = selectMon(vm, "choose") if not mon then answer(vm, Specials.SHUCKIE_REFUSED) return end if mon.species ~= SHUCKLE or mon.otId ~= Specials.MANIA_OT_ID or mon.ot ~= Specials.MANIA_OT then answer(vm, Specials.SHUCKIE_WRONG_MON) return end if (mon.hp or 0) <= 0 then answer(vm, Specials.SHUCKIE_FAINTED) return end -- The happiness the mon comes back with is what Mania comments on, so it is -- read before the slot might get emptied. vm.shuckieHappiness = mon.happiness or 0 if (mon.happiness or 0) >= Specials.SHUCKIE_HAPPY_THRESHOLD then -- Shuckie stays with the player: the party slot is untouched. answer(vm, Specials.SHUCKIE_HAPPY) return end table.remove(party(vm), index) answer(vm, Specials.SHUCKIE_RETURNED) end -- BillsGrandfather: pick a mon and hand him its species. wScriptVar is the -- species index he then names, 0 for a B press. H.BillsGrandfather = function(vm) local h = hooks(vm) local _, mon = selectMon(vm, "choose") if not mon then answer(vm, 0) return end local index = h.monIndex and h.monIndex(mon.species) answer(vm, index or 0) nameMon(vm, index or mon.species) end -- CheckPokerus -> _CheckPokerus -> ScriptReturnCarry: 1 when any party member -- carries the virus. `and $0f` is the whole test -- an ACTIVE infection only, -- so a party of cured mons (high nybble set, counter zero) answers FALSE and -- the nurse says nothing. H.CheckPokerus = function(vm) answer(vm, Pokerus.inParty(party(vm)) and TRUE or FALSE) end -- ---- 78-80 the money boxes ------------------------------------------------ -- -- engine/menus/menu_2.asm. The three are DIFFERENT boxes, not one box with -- three names -- DisplayMoneyAndCoinBalance prints both fields in a 13x3 box of -- its own -- so the world hook is told which, and a hook that ignores the -- argument still gets the money box it got before. H.DisplayCoinCaseBalance = function(vm) if vm.showCoinsFn then vm.showCoinsFn() end end H.DisplayMoneyAndCoinBalance = function(vm) if vm.showMoneyFn then vm.showMoneyFn("moneycoins") end end H.PlaceMoneyTopRight = function(vm) if vm.showMoneyFn then vm.showMoneyFn("money") end end -- ---- 81-84 the Lucky Number Show ------------------------------------------ -- -- The radio show that gives out the MASTER BALL. Its whole rule is the number -- of TRAILING digits a mon's trainer ID shares with the day's five-digit lucky -- number, over the party AND every box: -- -- 5 digits first prize wScriptVar 1 -- 3 or 4 second prize wScriptVar 2 -- 2 third prize wScriptVar 3 -- fewer nothing wScriptVar 0 -- -- The BEST match wins, which is why the comparison keeps the LOWER wScriptVar -- (`cp b / jr c, .nomatch`), and a match found in a BOX rather than in the -- party changes only which of two lines is printed. local function trailingDigitsShared(a, b) local left = string.format("%05d", (a or 0) % 100000) local right = string.format("%05d", (b or 0) % 100000) local shared = 0 for i = 5, 1, -1 do if left:sub(i, i) ~= right:sub(i, i) then break end shared = shared + 1 end return shared end Specials.trailingDigitsShared = trailingDigitsShared -- pokegold/constants/pokemon_data_constants.asm:122-123 local NUM_BOXES, NUM_BOXES_JP = 14, 9 -- pokegold/engine/events/lucky_number.asm:22-102: sBox (the OPEN box) is walked -- before .BoxesLoop skips it, and .BoxesLoop stops at NUM_BOXES_JP not NUM_BOXES. local function luckyNumberBoxOrder(record) local current = math.floor(tonumber(record and record.currentBox) or 1) local last = GameVersion.fixes().luckyNumberBoxes and NUM_BOXES or NUM_BOXES_JP local order = { current } for index = 1, last do if index ~= current then order[#order + 1] = index end end return order end Specials.luckyNumberBoxOrder = luckyNumberBoxOrder local function luckyPrizeFor(shared) if shared >= 5 then return 1 end if shared >= 3 then return 2 end if shared >= 2 then return 3 end return 0 end Specials.luckyPrizeFor = luckyPrizeFor -- engine/overworld/time.asm's RestartLuckyNumberCountdown: the days from -- `weekday` until the NEXT Friday, where Friday itself is a full week away -- rather than zero (`sub c / jr z, .friday_saturday` before the `add 7`). -- GetWeekday counts SUNDAY 0 .. SATURDAY 6, same as BugContest.weekday. local function daysUntilFriday(weekday) return ((BugContest.FRIDAY - (weekday or 0) - 1) % 7) + 1 end Specials.daysUntilFriday = daysUntilFriday -- save.luckyNumberReset stands in for wLuckyNumberDayTimer: { remaining, day } -- the same shape Apricorns.startDailyResetTimer uses, just armed for a week -- instead of a day. A save that has never armed it (day == nil) reads as -- already expired, the same way a freshly zeroed SRAM byte does -- which is -- what makes the FIRST visit to the Lucky Number Man always reset and roll a -- number rather than reading a stale zero. local function luckyNumberTimer(record) if type(record) ~= "table" then return nil end record.luckyNumberReset = record.luckyNumberReset or { remaining = 0 } return record.luckyNumberReset end -- _CheckLuckyNumberShowFlag: CheckDayDependentEventHL over wLuckyNumberDayTimer. -- Advances the stored day as it measures (CalcDaysSince's side effect), then -- clamps the remaining count at zero, same as Apricorns.checkDailyResetTimer's -- one-day version. local function checkLuckyNumberTimer(record, now) local timer = luckyNumberTimer(record) if not timer then return false end if timer.day == nil then return true end now = now or BugContest.now() local stamp = { day = timer.day } local since = BugContest.elapsedSince(stamp, now, "day") timer.day = stamp.day local left = (timer.remaining or 0) - since.days if left < 0 then left = 0 end timer.remaining = left return left <= 0 end H.CheckForLuckyNumberWinners = function(vm) local record = save(vm) local number = record and record.luckyNumber answer(vm, 0) if not number then return end local best, bestMon, inBox = 0, nil, false local function consider(mon, fromBox) local Breeding = require("src.core.gen2.Breeding") if Breeding.isEgg(mon) then return end local prize = luckyPrizeFor(trailingDigitsShared(mon.otId, number)) if prize == 0 then return end if best == 0 or prize < best then best, bestMon, inBox = prize, mon, fromBox end end for _, mon in ipairs(party(vm)) do consider(mon, false) end local boxes = (record and record.boxes) or {} for _, index in ipairs(luckyNumberBoxOrder(record)) do for _, mon in ipairs(boxes[index] or {}) do consider(mon, true) end end answer(vm, best) if bestMon then nameMon(vm, bestMon.species) vm.luckyNumberInBox = inBox end end -- _CheckLuckyNumberShowFlag -> ScriptReturnCarry: TRUE once wLuckyNumberDayTimer -- has counted down past the coming Friday, i.e. a new week has started since -- the show was last reset. This is the WEEKLY gate; RadioTower1FLuckyNumberManScript -- only calls ResetLuckyNumberShowFlag (which rolls a fresh number and clears -- the "already won" bit) when this comes back TRUE, so visiting twice in the -- same week keeps last week's number and last week's win on record. H.CheckLuckyNumberShowFlag = function(vm) answer(vm, checkLuckyNumberTimer(save(vm)) and TRUE or FALSE) end -- ResetLuckyNumberShowFlag: RestartLuckyNumberCountdown re-arms the weekly -- timer for the days until the NEXT Friday, `res LUCKYNUMBERSHOW_GAME_OVER_F` -- clears the SAME storage `checkflag`/`setflag ENGINE_LUCKY_NUMBER_SHOW` read -- and write (data/events/engine_flags.asm's `engine_flag wLuckyNumberShowFlag, -- LUCKYNUMBERSHOW_GAME_OVER_F`), and LoadOrRegenerateLuckyIDNumber rolls a -- fresh five-digit number. Because this only ever runs right after -- CheckLuckyNumberShowFlag has confirmed the week turned over, "reroll every -- time this fires" is the same as the cart's "reroll when the stored day -- differs from today". H.ResetLuckyNumberShowFlag = function(vm) local record = save(vm) if not record then return end local now = BugContest.now() local timer = luckyNumberTimer(record) timer.remaining = daysUntilFriday(BugContest.weekday(now)) timer.day = now.day local h = hooks(vm) if h.setEngineFlag then h.setEngineFlag("ENGINE_LUCKY_NUMBER_SHOW", nil) end record.luckyNumber = Specials.random(0, 99999) end -- PrintTodaysLuckyNumber: five digits with leading zeros, into the buffer the -- following writetext reads. H.PrintTodaysLuckyNumber = function(vm) local record = save(vm) vm:setStringBuffer(string.format("%05d", (record and record.luckyNumber or 0) % 100000)) end -- ---- 85 Kurt and the apricorns -------------------------------------------- -- The CANCEL row Kurt_SelectApricorn's .Name draws itself (`db "CANCEL@"`) -- rather than taking from the item names, because item 0 has no name. local CANCEL = Strings.source("CANCEL") -- .MenuHeader: MENU_BACKUP_TILES, `menu_coords 0, 0, 14, 17`, .MenuData and a -- default option of 1; .MenuData's own byte is STATICMENU_CURSOR | -- STATICMENU_WRAP (constants/menu_constants.asm bits 7 and 5). The wrap is -- the one thing src/ui/gen2/ScriptMenu.lua does not honour yet, so the cursor -- stops at the ends instead of rolling over; everything else is the header. local KURT_MENU_FLAGS = 0x80 + 0x20 -- SelectApricornForKurt (engine/events/specials.asm) is two routines deep, and -- the second one is the half a reader of maps/KurtsHouse.asm cannot see: -- -- farcall Kurt_SelectApricorn / ld a, c / ld [wScriptVar], a / and a / ret z -- ld [wCurItem], a / ld a, 1 / ld [wItemQuantityChange], a / TossItem -- -- so wScriptVar is the ITEM id of the apricorn chosen -- .AskApricorn's ladder -- is `ifequal BLU_APRICORN` and friends, item constants, not menu rows -- and -- the apricorn leaves the pack HERE, before the script's setevent runs. -- -- Kurt_SelectApricorn (engine/menus/menu_2.asm) is FindApricornsInBag plus a -- DoNthMenu over the list it builds, which is why the rows come out in -- ApricornBalls order rather than pack order and the last row is always -- CANCEL. Both refusals -- FindApricornsInBag's `scf` for a pack with no -- apricorn in it at all, and the `jr c, .nope` for pressing B -- answer -- `xor a`, which is the FALSE `.Cancel` waits on. -- -- ../pokecrystal/engine/events/kurt.asm:19-45 is the same routine with a -- quantity menu bolted on, and its `xor a / ld [wKurtApricornQuantity], a` -- (:24) and `ld a, [wItemQuantityChange] / ld [...], a` (:45) are what -- ../pokecrystal/maps/KurtsHouse.asm:197's `verbosegiveitemvar LEVEL_BALL, -- VAR_KURT_APRICORNS` counts out. Kurt_SelectQuantity is not ported, so the -- count written here is the one apricorn Apricorns.takeApricorn tosses. H.SelectApricornForKurt = function(vm) local h = hooks(vm) local record = save(vm) if h.setKurtApricornQuantity then h.setKurtApricornQuantity(0) end local list = Apricorns.bagList(record and record.inventory) if list.empty then return answer(vm, FALSE) end local rows = {} for index, apricorn in ipairs(list) do rows[index] = (h.itemName and h.itemName(apricorn)) or apricorn end rows[list.cancel] = Strings(CANCEL) local choice = Specials.block(vm, function(done) if not h.scriptMenu then return done(0) end h.scriptMenu({ items = rows, left = 0, top = 0, right = 14, bottom = 17, dataFlags = KURT_MENU_FLAGS, cursor = 1 }, done) end) local apricorn = Apricorns.select(record and record.inventory, tonumber(choice)) if not apricorn then return answer(vm, FALSE) end -- No item id, no toss: an answer the `ifequal` ladder cannot match would -- fall through to .Red and hand Kurt an apricorn the player never lost. local item = h.itemIndex and h.itemIndex(apricorn) if not item or item == 0 then return answer(vm, FALSE) end answer(vm, item) if Apricorns.takeApricorn(record, apricorn) and h.setKurtApricornQuantity then h.setKurtApricornQuantity(1) end end -- ---- 88-89 the first party slot ------------------------------------------- -- GetFirstPokemonHappiness: the happiness of the first NON-EGG party member -- -- the loop skips eggs, which is why a party led by an egg still gets an answer -- from the mon behind it. H.GetFirstPokemonHappiness = function(vm) local Breeding = require("src.core.gen2.Breeding") for _, mon in ipairs(party(vm)) do if not Breeding.isEgg(mon) then answer(vm, mon.happiness or 0) nameMon(vm, mon.species) return end end answer(vm, 0) end -- CheckFirstMonIsEgg: TRUE when slot 1 holds an egg, and the name goes in the -- buffer either way (`call GetPokemonName` is past the branch). H.CheckFirstMonIsEgg = function(vm) local Breeding = require("src.core.gen2.Breeding") local mon = party(vm)[1] answer(vm, Breeding.isEgg(mon) and TRUE or FALSE) if mon then nameMon(vm, mon.species) end end -- ---- 90 the rare-mon phone call ------------------------------------------- -- -- RandomUnseenWildMon: pick one of the three RAREST slots on the caller's map, -- and if it is not also one of the four commonest AND has never been seen, the -- caller tells you about it (wScriptVar 0). Anything else is 1, "nothing to -- report". -- -- GetCallerLocation is a phone routine and the phone-call scripts live in an -- unextracted ROM bank, so the only map this can honestly read is the one the -- player is standing on. That is where the caller would be for every scripted -- use of it that exists today. H.RandomUnseenWildMon = function(vm) local h = hooks(vm) answer(vm, TRUE) if not h.rareWildMon then return end local species = h.rareWildMon() if not species then return end local record = save(vm) local seen = record and record.pokedex and record.pokedex.seen if seen and seen[species] then return end nameMon(vm, species) answer(vm, FALSE) end -- ---- 91, 92 the phone chatter name-drops ----------------------------------- -- -- Both open on GetCallerLocation (engine/phone/phone.asm), which reads the -- contact on the line out of wCurCaller; the port parks that id on -- vm.curPhoneCaller when a call's script starts (World:receivePhoneCall for -- an incoming ring, Game2:runPokegearCall for an outgoing one). -- Neither routine writes wScriptVar: both end on CopyBytes into -- wStringBuffer4, which is the {STRBUF} the chat line after them reads. -- trainers.lua and encounters.lua both store species IDS, not dex indexes, -- so this is nameMon's sibling for a handler already holding the id. local function nameSpecies(vm, species) local defs = data(vm) local def = defs and defs.pokemon and defs.pokemon[species] vm:setStringBuffer((def and def.name) or species) end -- RandomPhoneWildMon (engine/overworld/wildmons.asm): one of the FOUR -- commonest grass slots (`call Random / and %11`) on the CALLER'S map, read -- at the current time of day, named into the buffer. A caller whose map has -- no grass table leaves the buffer alone, as the cart's unmatched -- LookUpWildmonsForMapDE walk would read whatever sat past the last row. H.RandomPhoneWildMon = function(vm) local contact = Phone.CONTACTS[vm.curPhoneCaller or -1] local w = hooks(vm).world local grass = w and w.encounters and w.encounters.grass local entry = contact and contact.map and grass and grass[contact.map] local slots = entry and entry.slots if not slots then return end -- wTimeOfDay, not the palette pin (wildmons.asm:861) local daytime = (w and (w.tod or w.daytime)) or "DAY" if daytime == "DARK" then daytime = "NITE" end local slot = (slots[daytime] or slots.DAY or {})[Specials.random(4)] if slot and slot.species then nameSpecies(vm, slot.species) end end -- RandomPhoneMon: a mon out of the calling trainer's OWN party, uniform over -- its length (`call Random / maskbits PARTY_LENGTH / cp e / jr nc` rerolls). -- The cart walks TrainerGroups by the contact row's class and member bytes; -- trainers.lua is that table, and the contact stores the same pair as the -- class id and the member's own id string. H.RandomPhoneMon = function(vm) local contact = Phone.CONTACTS[vm.curPhoneCaller or -1] if not (contact and contact.class) then return end local defs = data(vm) local class = defs and defs.trainers and defs.trainers.classes and defs.trainers.classes[contact.class] local mons for _, row in ipairs((class and class.trainers) or {}) do if row.id == contact.member then mons = row.party break end end if not (mons and #mons > 0) then return end local mon = mons[Specials.random(#mons)] if mon and mon.species then nameSpecies(vm, mon.species) end end -- ---- 95 Snorlax ----------------------------------------------------------- -- -- SnorlaxAwake: TRUE only when the POKe FLUTE channel is the music that is -- playing AND the player is on one of five cells beside the Snorlax. Both -- halves matter -- the flute wakes it from next to it, not from across -- Vermilion. The coordinates are the routine's own .ProximityCoords. Specials.SNORLAX_PROXIMITY = { { 33, 8 }, { 34, 10 }, { 35, 10 }, { 36, 8 }, { 36, 9 }, } Specials.POKE_FLUTE_SONG = "Music_PokeFluteChannel" H.SnorlaxAwake = function(vm) local h = hooks(vm) answer(vm, FALSE) local song = h.currentMusic and h.currentMusic() if song ~= Specials.POKE_FLUTE_SONG then return end local x, y = 0, 0 if h.playerCell then x, y = h.playerCell() end for _, cell in ipairs(Specials.SNORLAX_PROXIMITY) do if cell[1] == x and cell[2] == y then answer(vm, TRUE) return end end end -- ---- 96-98 the haircut brothers and Daisy --------------------------------- -- -- One routine (HaircutOrGrooming) with three happiness tables in front of it. -- Each table is a weighted roll: a random byte walks the rows subtracting each -- row's weight, and the row it lands on carries the wScriptVar the script -- branches on (which of three lines the barber says) and the HAPPINESS_* action -- applied to the mon. -- -- The three tables are data/events/happiness_probabilities.asm, transcribed -- because the extractor never reaches them. Rows are -- { weight, scriptVar, happinessChange } where `weight` is the macro's own -- `N percent` (`* $ff / 100`, integer) and -1 is 255, the catch-all last row: -- -- Older 30% -> 2, 50%+1 -> 3, rest -> 4 -- Younger 60%+1 -> 2, 30% -> 3, rest -> 4 -- Daisy always -> 2 -- -- The wScriptVar values really are 2, 3 and 4 -- not 0-based -- because the -- barber's script branches on them with `ifequal`. Specials.HAIRCUT_TABLES = { older = { { 76, 2, "OLDERCUT1" }, -- 30 percent { 128, 3, "OLDERCUT2" }, -- 50 percent + 1 { 255, 4, "OLDERCUT3" }, -- -1 }, younger = { { 154, 2, "YOUNGCUT1" }, -- 60 percent + 1 { 76, 3, "YOUNGCUT2" }, -- 30 percent { 255, 4, "YOUNGCUT3" }, -- -1 }, daisy = { { 255, 2, "GROOMING" }, }, } -- HappinessChanges (data/events/happiness_changes.asm) is transcribed ONCE, in -- src/core/gen2/Happiness.lua, alongside the tier pick and the two clamps. -- The seven rows the barbers and the groomer reach are a window onto that -- table, not a second copy: the enum is `const_def 1`, so a row that drifted -- here would move HAPPINESS_GROOMING off the end of the other one and the two -- callers of the same cart routine would disagree about the same haircut. Specials.HAPPINESS_CHANGES = {} for _, action in ipairs({ "OLDERCUT1", "OLDERCUT2", "OLDERCUT3", "YOUNGCUT1", "YOUNGCUT2", "YOUNGCUT3", "GROOMING" }) do Specials.HAPPINESS_CHANGES[action] = Happiness.CHANGES[Happiness.EVENT[action]] end -- ChangeHappiness itself, which is Happiness.change: the band pick, the $ff -- and 0 carry clamps, and the `cp EGG / ret z` that this wrapper used to be -- missing. The egg case cannot be reached from here today (haircut below -- refuses one before it ever gets this far, the way `.egg` does), but the -- guard belongs to the routine rather than to one of its callers. function Specials.changeHappiness(mon, action) Happiness.change(mon, action) end local function haircut(vm, which) local _, mon = selectMon(vm, "choose") if not mon then answer(vm, 0) return end local Breeding = require("src.core.gen2.Breeding") -- `cp EGG / jr z, .egg`: an egg cannot be groomed, and `.egg` leaves -- wScriptVar at 0 rather than answering one of the three rows. if Breeding.isEgg(mon) then answer(vm, 0) return end nameMon(vm, mon.nickname or mon.species) local rows = Specials.HAIRCUT_TABLES[which] or Specials.HAIRCUT_TABLES.daisy -- `call Random / .loop: sub [hl] / jr c, .ok`: subtract each row's weight -- from the rolled byte until it borrows. local roll = Specials.random(0, 255) local row = rows[#rows] for _, candidate in ipairs(rows) do if roll < candidate[1] then row = candidate break end roll = roll - candidate[1] end answer(vm, row[2]) Specials.changeHappiness(mon, row[3]) end H.OlderHaircutBrother = function(vm) haircut(vm, "older") end H.YoungerHaircutBrother = function(vm) haircut(vm, "younger") end H.DaisysGrooming = function(vm) haircut(vm, "daisy") end -- ---- 100 PROF.OAK's PC #DEX rating ----------------------------------------- -- -- ProfOaksPCBoot (engine/events/prof_oaks_pc.asm). OaksLab's own script is -- `writetext OakLabDexCheckText / waitbutton / special ProfOaksPCBoot`, so -- like MoveDeletion and NameRater above every line here prints straight into -- an already-open box and never opens or closes one. The asm never writes -- wScriptVar either (no branch reads it anywhere), so this leaves -- vm.scriptVar exactly as `special` found it. -- -- ProfOaksPC, the outer wrapper with the "want your #DEX rated?" yes/no gate -- and the "link closed" shutdown line, is what the Pokemon Center's OaksPC -- menu item farcalls; the whose-PC menu runs that flow inside its own screen -- (src/ui/gen2/CenterPcMenu.lua oakRate) off the exports below. -- ProfOaksPCBoot is the only label the cache's specialOrder ever names -- (OaksLab's dex-completeness check goes straight to it, skipping the -- yes/no), and it is the one this builds. -- -- data/text/common_2.asm _OakPCText2/_OakPCText3, transcribed the way -- MOVE_DELETER_TEXT above transcribes its own bank. The seen/owned counts -- are formatted straight into the text with %d instead of through {STRBUF}: -- the cart puts them in two DIFFERENT buffers (wStringBuffer3, -- wStringBuffer4) in the one textbox, and the VM's {STRBUF} substitution -- only ever carries one value. local OAK_PC_TEXT = { completion = Strings.source("Current #DEX\ncompletion level:"), counts = Strings.source( "%d #MON seen\n%d #MON owned\n\nPROF.OAK's\nRating:"), } -- OakRatings (data/events/pokedex_ratings.asm). Each row is (cap, sfx, -- text); FindOakRating walks the table with `cp c / jr nc, .match` against -- ascending caps, which is "the first row whose cap covers the caught -- count" -- exactly what findOakRating below does. sfx is the Gold sfx -- table's own label (audio/sfx_pointers.asm dba lines), not a pokered -- fanfare name. local OAK_RATINGS = { { max = 9, sfx = "Sfx_DexFanfareLessThan20", text = Strings.source( "Look for #MON\nin grassy areas!") }, { max = 19, sfx = "Sfx_DexFanfareLessThan20", text = Strings.source( "Good. I see you\nunderstand how to\nuse # BALLS.") }, { max = 34, sfx = "Sfx_DexFanfare2049", text = Strings.source( "You're getting\ngood at this.\n\nBut you have a\nlong way to go.") }, { max = 49, sfx = "Sfx_DexFanfare2049", text = Strings.source( "You need to fill\nup the #DEX.\n\nCatch different\nkinds of #MON!") }, { max = 64, sfx = "Sfx_DexFanfare5079", text = Strings.source( "You're trying--I\ncan see that.\n\nYour #DEX is\ncoming together.") }, { max = 79, sfx = "Sfx_DexFanfare5079", text = Strings.source( "To evolve, some\n#MON grow,\n\nothers use the\neffects of STONES.") }, { max = 94, sfx = "Sfx_DexFanfare80109", text = Strings.source( "Have you gotten a\nfishing ROD? You\n\ncan catch #MON\nby fishing.") }, { max = 109, sfx = "Sfx_DexFanfare80109", text = Strings.source( "Excellent! You\nseem to like col-\nlecting things!") }, { max = 124, sfx = "Sfx_CaughtMon", text = Strings.source( "Some #MON only\nappear during\n\ncertain times of\nthe day.") }, { max = 139, sfx = "Sfx_CaughtMon", text = Strings.source( "Your #DEX is\nfilling up. Keep\nup the good work!") }, { max = 154, sfx = "Sfx_DexFanfare140169", text = Strings.source( "I'm impressed.\nYou're evolving\n\n#MON, not just\ncatching them.") }, { max = 169, sfx = "Sfx_DexFanfare140169", text = Strings.source( "Have you met KURT?\nHis custom #\nBALLS should help.") }, { max = 184, sfx = "Sfx_DexFanfare170199", text = Strings.source( "Wow. You've found\nmore #MON than\n\nthe last #DEX\nresearch project.") }, { max = 199, sfx = "Sfx_DexFanfare170199", text = Strings.source( "Are you trading\nyour #MON?\n\nIt's tough to do\nthis alone!") }, { max = 214, sfx = "Sfx_DexFanfare200229", text = Strings.source( "Wow! You've hit\n200! Your #DEX\nis looking great!") }, { max = 229, sfx = "Sfx_DexFanfare200229", text = Strings.source( "You've found so\nmany #MON!\n\nYou've really\nhelped my studies!") }, { max = 239, sfx = "Sfx_DexFanfare230Plus", text = Strings.source( "Magnificent! You\ncould become a\n\n#MON professor\nright now!") }, { max = 248, sfx = "Sfx_DexFanfare230Plus", text = Strings.source( "Your #DEX is\namazing! You're\n\nready to turn\nprofessional!") }, -- The top band (251 real species, the table's cap of 255 covers it): this -- is the ONLY place the ROM checks "has the player finished the #DEX", and -- it does it here rather than handing off to anything. The actual diploma -- is not this special's business -- GameFreakGameDesignerScript in -- Celadon Mansion 3F reads VAR_DEXCAUGHT for itself and is what runs -- `special Diploma` (H.Diploma below, src/ui/gen2/Diploma.lua) and -- then sets EVENT_ENABLE_DIPLOMA_PRINTING for the Graphic Artist's -- `special PrintDiploma` (H.PrintDiploma, specials/crystal_extras.lua). -- A finished #DEX here just means every rating after this one is this -- same line. { max = 255, sfx = "Sfx_DexFanfare230Plus", text = Strings.source( "Whoa! A perfect\n#DEX! I've\n\ndreamt about this!\nCongratulations!") }, } -- CountSetBits over wPokedexSeen/wPokedexCaught. The port's dex is a -- species-keyed bool map (src/core/gen2/Save.lua), not a bitfield, so this -- counts `true` entries the same way Save.summary counts wPokedexCaught for -- the CONTINUE panel. local function dexCounts(record) local dex = record and record.pokedex local seen, caught = 0, 0 for _, has in pairs((dex and dex.seen) or {}) do if has then seen = seen + 1 end end for _, has in pairs((dex and dex.caught) or {}) do if has then caught = caught + 1 end end return seen, caught end -- FindOakRating. NUM_POKEMON is 251, so `caught` never exceeds the table's -- own top cap of 255 and this never falls off the end. local function findOakRating(caught) for _, row in ipairs(OAK_RATINGS) do if caught <= row.max then return row end end return OAK_RATINGS[#OAK_RATINGS] end H.ProfOaksPCBoot = function(vm) vm:showRaw(Strings(OAK_PC_TEXT.completion)) local seen, caught = dexCounts(save(vm)) vm:showRaw(Strings(OAK_PC_TEXT.counts, seen, caught)) local rating = findOakRating(caught) local h = hooks(vm) -- pokegold engine/events/prof_oaks_pc.asm:18-20 PlaySFX / JoyWaitAorB / WaitSFX drainSfx() if h.playSfxNamed then h.playSfxNamed(rating.sfx) end vm:showRaw(Strings(rating.text), nil, nil, true) end -- The whose-PC menu's PROF.OAK's PC row (src/ui/gen2/CenterPcMenu.lua) runs -- ProfOaksPC's rating flow inside a screen rather than a script, so the -- counts, the rating pick and the two OakPC texts are exported here rather -- than transcribed a second time. Specials.dexCounts = dexCounts Specials.findOakRating = findOakRating Specials.OAK_PC_TEXT = OAK_PC_TEXT -- ---- 101-102 the console and the Trainer House ---------------------------- -- GameboyCheck: GBCHECK_GB 0 / GBCHECK_SGB 1 / GBCHECK_CGB 2. Gold is a Game -- Boy Color game and the port renders its GBC palettes, so the honest answer -- follows the COLOR option: the deliberate step down to a grey Game Boy is a -- real answer to this question, and the Goldenrod console kid's line changes -- with it, which is the only place it is asked. H.GameboyCheck = function(vm) local GbcPalette = require("src.render.GbcPalette") if GbcPalette.mode == "gbc" then answer(vm, GBCHECK_CGB) elseif GbcPalette.mode == "classic" then answer(vm, GBCHECK_GB) else answer(vm, GBCHECK_SGB) end end -- TrainerHouse: sMysteryGiftTrainerHouseFlag, the byte a Mystery Gift trade -- leaves behind so the Viridian Trainer House has somebody to fight. Mystery -- Gift is out of scope, so the flag is permanently 0 and the house holds its -- default opponent -- which is what an unlinked cartridge does. -- -- The byte has three readers (this, ReadTrainerParty and GetTrainerName) and -- src/world/gen2/TrainerHouse.lua is the one that owns it, so the answer here -- cannot drift from the party the battle then loads. Required inside the -- handler the way H.GameboyCheck requires GbcPalette: the script layer does -- not otherwise depend on the world layer. H.TrainerHouse = function(vm) local TrainerHouse = require("src.world.gen2.TrainerHouse") answer(vm, TrainerHouse.hasCustomTrainer(save(vm)) and TRUE or FALSE) end -- ---- 104 the roamers ------------------------------------------------------ -- -- InitRoamMons (engine/overworld/wildmons.asm): the three legendary beasts are -- written into the wRoamMon structs on their starting routes. RoamMon_1 is -- Raikou on ROUTE 42, 2 Entei on ROUTE 37, 3 Suicune on ROUTE 38, all at level -- 40, and each HP byte is zeroed under the asm's own comment "generate new -- stats" -- a roamer has no rolled stats until it is first met. -- -- The roster and the walk both live in src/core/gen2/Roamers.lua, which is the -- ONE writer of save.roamers: this file used to carry a second copy of the -- three rows, and two transcriptions of one table is how a renumbered slot -- sends Suicune's damage to Raikou's byte. Specials.ROAMERS is kept as an -- alias of that table so a reader landing here still sees what the routine -- writes. -- -- `force` because the asm stores unconditionally. Roamers.init's default is -- the port's own re-entry guard, which is the right default for a caller that -- is not the cart's own command; the command itself has to be the cart. Specials.ROAMERS = Roamers.SPECIES H.InitRoamMons = function(vm) local record = save(vm) if not record then return end Roamers.init(record, { force = true, data = data(vm) }) end -- ---- the #DEX-completion diploma ------------------------------------------- -- -- _Diploma (engine/events/diploma.asm): PlaceDiplomaOnScreen then -- WaitPressAorB_BlinkCursor. Called by Celadon Mansion 3F's -- GameFreakGameDesignerScript once VAR_DEXCAUGHT hits 251 (see the OAK_RATINGS -- comment above); the caller wraps the farcall in FadeToMenu/ExitAllMenus -- (engine/events/specials.asm Diploma), and _Diploma itself never touches -- wScriptVar, so this leaves vm.scriptVar untouched the same way H.MoveDeletion -- and H.NameRater do. The screen is src/ui/gen2/Diploma.lua; World:showDiploma -- is the push, kept behind World the way every other screen-opening special is. H.Diploma = function(vm) local h = hooks(vm) if not h.showDiploma then return end Specials.block(vm, function(done) h.showDiploma(function() done(true) end) end) end -- ---- Mom's savings ---------------------------------------------------------- -- -- BankOfMom (engine/events/mom.asm), reached from PlayersHouse1F's own -- `MomScript` (`special BankOfMom`, run inside a caller-opened textbox the -- same way H.MoveDeletion and H.NameRater's callers open theirs). The asm is -- a nine-state jumptable; this ports its shape rather than its byte, with two -- of its own behaviours kept on purpose: -- -- * StoreMoney/TakeMoney's insufficient-funds arms `ret` WITHOUT advancing -- wJumptableIndex, so `.loop` re-enters the SAME state and asks again -- -- the `while true do` loops below stand in for that. -- * GiveMoney does not refuse an over-the-cap deposit, it CLAMPS to -- MAX_MONEY and reports carry; the caller sees that as "no room" but the -- clamp already landed, so a deposit that overflows Mom's account still -- tops her out at 999999 without touching the wallet (`giveMoneyClamped` -- below is exactly that GiveMoney, not a rejecting one). -- -- Not ported: the `.nope` arm of IsThisAboutYourMoney calls DSTChecks, which -- nudges wStartHour/wStartDay to flip Daylight Saving on or off and reprints -- the clock. This port has no wStartHour to nudge -- World:hour reads the -- host clock directly rather than keeping an offset from it (SetDayOfWeek's -- own comment above says the same for the day wheel) -- so there is nothing -- for a yes/no here to change, and the conversation falls straight through to -- MomJustDoWhatYouCanText the way it does once DSTChecks itself returns. -- -- data/text/common_1.asm, transcribed the way MOVE_DELETER_TEXT above -- transcribes its own bank, and with the cart's own page structure: `para` is -- a page break (`\f` -- the box clears and WAITS for A), `cont` is the -- scrolled third line (`\v`, which also waits). Folding paras into plain -- `\n`s let the whole of MomLeavingText1 type itself out as one page with no -- button waits at all, straight through to the savings prompt. local MOM_TEXT = { leaving1 = Strings.source( "Wow, that's a cute\n#MON.\fWhere did you get\nit?\f…\f" .. "So, you're leaving\non an adventure…\fOK!\nI'll help too.\f" .. "But what can I do\nfor you?\fI know! I'll save\nmoney for you.\f" .. "On a long journey,\nmoney's important.\fDo you want me to\n" .. "save your money?"), leaving2 = Strings.source("OK, I'll take care\nof your money.\f…"), leaving3 = Strings.source( "Be careful.\f#MON are your\nfriends. You need\vto work as a team.\f" .. "Now, go on!"), isThisAboutMoney = Strings.source( "Hi! Welcome home!\nYou're trying very\vhard, I see.\f" .. "I've kept your\nroom tidy.\fOr is this about\nyour money?"), whatDoYouWantToDo = Strings.source("What do you want\nto do?"), storeMoney = Strings.source("How much do you\nwant to save?"), takeMoney = Strings.source("How much do you\nwant to take?"), saveMoney = Strings.source("Do you want to\nsave some money?"), haventSavedThatMuch = Strings.source("You haven't saved\nthat much."), notEnoughRoomInWallet = Strings.source("You can't take\nthat much."), insufficientFundsInWallet = Strings.source("You don't have\nthat much."), notEnoughRoomInBank = Strings.source("You can't save\nthat much."), startSavingMoney = Strings.source( "OK, I'll save your\nmoney. Trust me!\f{PLAYER}, stick\nwith it!"), storedMoney = Strings.source("Your money's safe\nhere! Get going!"), takenMoney = Strings.source("{PLAYER}, don't\ngive up!"), justDoWhatYouCan = Strings.source("Just do what\nyou can."), } -- constants/script_constants.asm. local YOUR_MONEY, MOMS_MONEY = 0, 1 local MOM_MAX_MONEY = 999999 -- BankOfMom_MenuHeader: `menu_coords 0, 0, 10, 10`, STATICMENU_CURSOR, four -- items, cursor starting on GET. Answers through the same "menu" yield -- Script_verticalmenu uses, so Gen2ScriptMenu draws it with no new screen. local BANK_MENU_HEADER = { left = 0, top = 0, right = 10, bottom = 10, dataFlags = 0x80, -- STATICMENU_CURSOR items = { "GET", "SAVE", "CHANGE", "CANCEL" }, cursor = 1, } local function bankMoney(vm, account) local h = hooks(vm) return (h.money and h.money(account)) or 0 end local function setBankMoney(vm, account, value) local h = hooks(vm) if h.setMoney then h.setMoney(account, math.max(0, math.min(value, MOM_MAX_MONEY))) end end -- GiveMoney (engine/events/money.asm): adds, clamps at MAX_MONEY, and reports -- (via the second return) whether the clamp fired -- the caller's cue to show -- the "no room" line even though the account already sits at the cap. local function giveMoneyClamped(vm, account, amount) local have = bankMoney(vm, account) local total = have + amount if total > MOM_MAX_MONEY then setBankMoney(vm, account, MOM_MAX_MONEY) return MOM_MAX_MONEY, true end setBankMoney(vm, account, total) return total, false end -- TakeMoney: subtracts, floors at 0 rather than borrowing. local function takeMoneyFloored(vm, account, amount) local have = bankMoney(vm, account) if amount > have then setBankMoney(vm, account, 0) return 0 end setBankMoney(vm, account, have - amount) return have - amount end -- Mom_SetUpWithdrawMenu / Mom_SetUpDepositMenu's six-digit keypad -- (src/ui/gen2/BankOfMom.lua). `kind` is "deposit" or "withdraw", only for -- the screen's own label; the amount it hands back is unvalidated, exactly -- the way wStringBuffer2 is before StoreMoney/TakeMoney check it against the -- other account. local function bankOfMomAmount(vm, kind) local h = hooks(vm) if not h.bankOfMomAmount then return nil end local saved, held = bankMoney(vm, MOMS_MONEY), bankMoney(vm, YOUR_MONEY) return Specials.block(vm, function(done) h.bankOfMomAmount(kind, saved, held, done) end) end local function transactionSfx(vm) coroutine.yield({ kind = "waitsfx" }) local h = hooks(vm) if h.playSfxNamed then h.playSfxNamed("Sfx_Transaction", 22) end coroutine.yield({ kind = "waitsfx" }) end H.BankOfMom = function(vm) local record = save(vm) if not record then return end record.mom = record.mom or {} local mom = record.mom local function justDoWhatYouCan() vm:showRaw(Strings(MOM_TEXT.justDoWhatYouCan)) end -- .CheckIfBankInitialized / .InitializeBank: the very first visit, before -- MOM_ACTIVE_F is ever set. Skips IsThisAboutYourMoney entirely. if not mom.active then -- engine/events/mom.asm:50-53, PrintText then `call YesNoBox`. showRawHeld(vm, Strings(MOM_TEXT.leaving1)) local wantsToSave = coroutine.yield({ kind = "yesorno" }) mom.active = true if wantsToSave then mom.savingMoney = true vm:showRaw(Strings(MOM_TEXT.leaving2)) end vm:showRaw(Strings(MOM_TEXT.leaving3)) return end -- .IsThisAboutYourMoney -- engine/events/mom.asm:71-74, the same PrintText / YesNoBox pair. showRawHeld(vm, Strings(MOM_TEXT.isThisAboutMoney)) local aboutMoney = coroutine.yield({ kind = "yesorno" }) if not aboutMoney then -- .nope: DSTChecks does not apply here; see the header note above. justDoWhatYouCan() return end -- .AccessBankOfMom vm:showRaw(Strings(MOM_TEXT.whatDoYouWantToDo)) local choice = coroutine.yield({ kind = "menu", style = "vertical", header = BANK_MENU_HEADER }) if choice == 1 then -- .withdraw -> .TakeMoney while true do vm:showRaw(Strings(MOM_TEXT.takeMoney)) local amount = bankOfMomAmount(vm, "withdraw") if not amount or amount == 0 then justDoWhatYouCan() return end if amount > bankMoney(vm, MOMS_MONEY) then vm:showRaw(Strings(MOM_TEXT.haventSavedThatMuch)) else local _, overflowed = giveMoneyClamped(vm, YOUR_MONEY, amount) if overflowed then vm:showRaw(Strings(MOM_TEXT.notEnoughRoomInWallet)) else takeMoneyFloored(vm, MOMS_MONEY, amount) transactionSfx(vm) vm:showRaw(Strings(MOM_TEXT.takenMoney)) return end end end elseif choice == 2 then -- .deposit -> .StoreMoney while true do vm:showRaw(Strings(MOM_TEXT.storeMoney)) local amount = bankOfMomAmount(vm, "deposit") if not amount or amount == 0 then justDoWhatYouCan() return end if amount > bankMoney(vm, YOUR_MONEY) then vm:showRaw(Strings(MOM_TEXT.insufficientFundsInWallet)) else local _, overflowed = giveMoneyClamped(vm, MOMS_MONEY, amount) if overflowed then vm:showRaw(Strings(MOM_TEXT.notEnoughRoomInBank)) else takeMoneyFloored(vm, YOUR_MONEY, amount) transactionSfx(vm) vm:showRaw(Strings(MOM_TEXT.storedMoney)) return end end end elseif choice == 3 then -- .stopsaving -> .StopOrStartSavingMoney -- engine/events/mom.asm:255-258, the same PrintText / YesNoBox pair. showRawHeld(vm, Strings(MOM_TEXT.saveMoney)) local wantsToSave = coroutine.yield({ kind = "yesorno" }) if wantsToSave then mom.savingMoney = true vm:showRaw(Strings(MOM_TEXT.startSavingMoney)) else mom.savingMoney = false justDoWhatYouCan() end else -- .cancel: CANCEL itself, or B. justDoWhatYouCan() end end -- ---- the Magnet Train ------------------------------------------------------ -- -- MagnetTrain (engine/events/magnet_train.asm), the Goldenrod <-> Saffron ride -- both station officers run once EVENT_RESTORED_POWER_TO_KANTO is set and the -- PASS is in the bag. The routine READS wScriptVar and never writes it: -- -- ld a, [wScriptVar] -- and a -- jr nz, .ToGoldenrod -- -- so the `setval FALSE` in front of the Goldenrod call and the `setval TRUE` in -- front of the Saffron one are what pick the direction, and vm.scriptVar has to -- come back out of here exactly as it went in -- the `warpcheck` that follows -- reads nothing, but a handler that clobbered it would still be lying about -- what the routine does. -- -- The ride itself is src/core/gen2/MagnetTrain.lua and its screen is -- src/ui/gen2/MagnetTrainRide.lua; World:magnetTrain is the push. With no hook -- the special is a no-op that leaves the script to warp on its own, which is -- what a headless run wants. H.MagnetTrain = function(vm) local h = hooks(vm) if not h.magnetTrain then return end local toGoldenrod = (vm.scriptVar or 0) ~= 0 Specials.block(vm, function(done) h.magnetTrain(toGoldenrod, function() done(true) end) end) end -- ---- the Cianwood photo studio --------------------------------------------- -- -- PhotoStudio (engine/events/print_photo.asm). CianwoodPhotoStudio's own -- script (CianwoodPhotoStudioFishingGuruScript) is `faceplayer / opentext / -- writetext .Question / yesorno / iffalse .Refused / writetext .Yes / -- waitbutton / special PhotoStudio / waitbutton / closetext` -- the yes/no -- gate and the surrounding textbox both belong to the map script (generic -- VM opcodes; nothing to hand-port there), and PhotoStudio itself only ever -- runs after the player has said yes. Like H.MoveDeletion and H.NameRater, -- it never writes wScriptVar (no `ld [wScriptVar], a` anywhere in the -- routine), so this leaves vm.scriptVar exactly as `special` found it. -- -- data/text/common_1.asm _WhichMonPhotoText/_HoldStillText/ -- _PrestoAllDoneText/_NoPhotoText/_EggPhotoText, transcribed the way -- MOVE_DELETER_TEXT above transcribes its own bank. -- -- farcall PrintPartymon (engine/printer/printer.asm) is the actual camera: -- it draws the portrait card (src/ui/gen2/PhotoStudio.lua transcribes that -- layout, PrintPartyMonPage1) and then SendScreenToPrinter walks it out the -- serial port to a physical Game Boy Printer. There is no peripheral for it -- to reach here -- the same reason H.UnownPrinter's A press goes nowhere and -- PrintDiploma's own print goes nowhere -- so `ldh a, [hPrinter] / and a / jr nz, -- .cancel` is -- hardwired to the nz arm below: the portrait shows, then the print always -- comes back as though the printer errored, which is the honest answer for -- a cartridge with nothing plugged into its link port. local PHOTO_STUDIO_TEXT = { whichMon = Strings.source("Which #MON\nshould I photo-\ngraph?"), holdStill = Strings.source("All righty. Hold\nstill for a bit."), noPhoto = Strings.source("Oh, no picture?\nCome again, OK?"), eggPhoto = Strings.source("An EGG? My talent\nis worth more…"), } local function showPhotoStudio(vm, mon) local h = hooks(vm) if not h.showPhotoStudio then return end Specials.block(vm, function(done) h.showPhotoStudio(mon, function() done(true) end) end) end H.PhotoStudio = function(vm) vm:showRaw(Strings(PHOTO_STUDIO_TEXT.whichMon)) local _, mon = selectMon(vm, "choose") if not mon then vm:showRaw(Strings(PHOTO_STUDIO_TEXT.noPhoto)) return end -- `ld a, [wCurPartySpecies] / cp EGG`: an egg slot is marked with `isEgg` -- in this port (src/core/gen2/Breeding.lua), same test H.MoveDeletion and -- H.NameRater make. if mon.isEgg then vm:showRaw(Strings(PHOTO_STUDIO_TEXT.eggPhoto)) return end vm:showRaw(Strings(PHOTO_STUDIO_TEXT.holdStill)) showPhotoStudio(vm, mon) -- hPrinter reads as an error unconditionally; see the header comment. vm:showRaw(Strings(PHOTO_STUDIO_TEXT.noPhoto)) end -- ---- 21 the quick save ----------------------------------------------------- -- -- TryQuickSave (engine/link/link.asm:2356) is filed with the cable club but is -- not a link routine: it is `farcall Link_SaveGame`, TRUE on carry clear and -- FALSE on carry, then `ld c, 30 / call DelayFrames`. Link_SaveGame -- (engine/menus/save.asm:63) is AskOverwriteSaveFile (:169) plus the ordinary -- write, so the FALSE arm is a refusal at the overwrite prompt -- which is -- what ../pokecrystal/maps/BattleTower1F.asm:84-85 backs a challenge out on, -- and what the four PokeCenter2F cable rows share. -- -- Two differences from the cart, neither observable in the answer. -- AskOverwriteSaveFile's mismatched-ID arm runs ErasePreviousSave (:333) before -- saving; Save.save replaces the whole file anyway. And SFX_SAVE rings at the -- write here, where src/ui/gen2/SaveMenu.lua:107 already puts it, rather than -- after the saved page has typed. -- data/text/common_2.asm:1279-1299. local SAVE_TEXT = { already = Strings.source( "There is already a\nsave file. Is it\vOK to overwrite?"), another = Strings.source( "There is another\nsave file. Is it\vOK to overwrite?"), saving = Strings.source("SAVING… DON'T TURN\nOFF THE POWER."), saved = Strings.source("%s saved\nthe game."), } -- engine/menus/save.asm:247 the 16 frames under SAVING and :251 the 32 the -- write is followed by; :269 the 30 after the saved page, and link.asm:2367 the -- 30 TryQuickSave adds on top. Both pages are `hold`s rather than `wait`s -- because the world does not tick while a box owns the stack (Vm:showRaw). local SAVING_HOLD = 16 + 32 local SAVED_HOLD = 30 + 30 H.TryQuickSave = function(vm) local h = hooks(vm) -- `ld a, [wSaveFileExists] / and a / jr z, .erase`, then -- CompareLoadedAndSavedPlayerID (:212) picking which question is asked. local exists, sameId = false, false if h.saveFileState then exists, sameId = h.saveFileState() end if exists then showRawHeld(vm, Strings(sameId and SAVE_TEXT.already or SAVE_TEXT.another)) if not coroutine.yield({ kind = "yesorno" }) then return answer(vm, FALSE) end end vm:showRaw(Strings(SAVE_TEXT.saving), true, SAVING_HOLD) -- _SaveGameData (:273). A veto from the save.write mod hook is the one way -- this port can refuse a write the cart always completes, and a refusal is -- the same FALSE the overwrite prompt's NO gives. if not (h.writeSave and h.writeSave() ~= false) then return answer(vm, FALSE) end if h.playSfxNamed then h.playSfxNamed("Sfx_Save") end local record = save(vm) local name = (record and record.player and record.player.name) or "" vm:showRaw(Strings(SAVE_TEXT.saved, name), true, SAVED_HOLD) answer(vm, TRUE) end -- ---- 111 the dummy -------------------------------------------------------- -- UnusedDummySpecial is a bare `ret`. Listed so the name resolves to a -- handler rather than to the unimplemented ledger. H.UnusedDummySpecial = function() end -- ---- 109-165 the Crystal rows --------------------------------------------- -- data/events/special_pointers.asm:124-181, the rows only Crystal has. -- ../pokecrystal/engine/pokemon/search_owned.asm:48 CheckOwnMonAnywhere: party then boxes, -- matching species, OT id and OT name; `ld a, [wPartyCount] / and a / ret z`. local function ownsMonAnywhere(vm, wanted) local h = hooks(vm) local list = party(vm) if #list == 0 then return false end local record = save(vm) local player = record and record.player local function owns(mon) if not mon then return false end if not (h.monIndex and h.monIndex(mon.species) == wanted) then return false end if player and player.id and mon.otId and mon.otId ~= player.id then return false end if player and player.name and mon.ot and mon.ot ~= player.name then return false end return true end for _, mon in ipairs(list) do if owns(mon) then return true end end for _, box in pairs((record and record.boxes) or {}) do for _, mon in ipairs(box or {}) do if owns(mon) then return true end end end return false end -- ../pokecrystal/engine/pokemon/search_owned.asm:31 H.MonCheck = function(vm) answer(vm, ownsMonAnywhere(vm, vm.scriptVar) and TRUE or FALSE) end -- home/init.asm:1 falls into Init (home/init.asm:35) and on to the copyright -- splash, which is Game2:softReset rather than Game2:returnToTitle. H.Reset = function(vm) local h = hooks(vm) if h.softReset then h.softReset() end end -- ../pokecrystal/mobile/mobile_41.asm:320 is a bare `ret` with its SRAM counter left behind -- it as dead code, so a no-op is the whole routine. H.StubbedTrainerRankings_Healings = function() end -- ../pokecrystal/mobile/mobile_41.asm:792: the international ROM answers 0 outright, which -- is what sends every Pokecenter 2F mobile branch down its cable arm. H.CheckMobileAdapterStatusSpecial = function(vm) answer(vm, FALSE) end -- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:187 and :1580, both bare `ret`. H.UnusedBattleTowerDummySpecial1 = function() end H.UnusedBattleTowerDummySpecial2 = function() end -- ../pokecrystal/engine/overworld/time.asm:136 SampleKenjiBreakCountdown: -- `call Random / and %11 / add 3`, three to six days into wKenjiBreakTimer, -- which ../pokecrystal/maps/Route45.asm:50 reads back through VAR_KENJI_BREAK. H.SampleKenjiBreakCountdown = function(vm) local h = hooks(vm) if h.setKenjiBreak then h.setKenjiBreak(Specials.random(4) - 1 + 3) end end -------------------------------------------------------------------------- -- The deliberate stubs -------------------------------------------------------------------------- -- -- Each carries the reason it is out of scope and the value it leaves in -- wScriptVar. A stub is NOT the same thing as a missing handler: a special -- that falls through leaves a STALE wScriptVar behind, and the `iffalse` two -- commands later then takes whatever branch the last special happened to -- leave -- which is the exact failure this whole module exists to stop. -- -- `value = nil` means the routine genuinely does not write wScriptVar. local STUB_ROWS = { -- Everything link cable. Two Game Boys and a cable; the port has link play -- for Gen 1 only (src/link/), and none of the Gen 2 cable-club protocol is -- ported. The values are the "no partner turned up" arm of each routine, -- which is what an unplugged cartridge sees. { "SetBitsForLinkTradeRequest", nil, "link cable: no Gen 2 cable club" }, { "WaitForLinkedFriend", 0, "link cable: nobody ever connects" }, { "CheckLinkTimeout_Receptionist", 1, "link cable: always times out" }, { "CheckBothSelectedSameRoom", 0, "link cable: no second player" }, { "FailedLinkToPast", 1, "link cable: the Time Capsule is not ported" }, { "CloseLink", nil, "link cable: nothing to close" }, { "WaitForOtherPlayerToExit", nil, "link cable: nobody to wait for" }, { "SetBitsForBattleRequest", nil, "link cable: no Gen 2 cable club" }, { "SetBitsForTimeCapsuleRequest", nil, "link cable: no Time Capsule" }, -- maps/PokeCenter2F.asm:200-203: 2 is .MonMoveTooNew; 0 falls through to -- WaitForLinkedFriend and lands on .FriendNotReady { "CheckTimeCapsuleCompatibility", 0, "link cable: no Gen 1 partner" }, { "EnterTimeCapsule", nil, "link cable: no Time Capsule" }, { "TradeCenter", nil, "link cable: no trade room" }, { "Colosseum", nil, "link cable: no battle room" }, { "TimeCapsule", nil, "link cable: no Time Capsule" }, { "CableClubCheckWhichChris", 0, "link cable: only one player exists" }, { "DisplayLinkRecord", nil, "link cable: no link record is kept" }, -- Mystery Gift. Infrared between two carts; nothing in the port has an IR -- port, and sMysteryGiftItem is therefore permanently empty. { "CheckMysteryGift", 0, "Mystery Gift: no infrared, so no gift is waiting" }, { "GetMysteryGiftItem", 0, "Mystery Gift: nothing to hand over" }, { "UnlockMysteryGift", nil, "Mystery Gift: nothing to unlock" }, -- The Game Boy Printer. A second peripheral again, and none of the three -- specials that want it is stubbed any more: PhotoStudio ports the -- conversation and the portrait screen (H.PhotoStudio above), UnownPrinter -- ports the stamp viewer (H.UnownPrinter above) and PrintDiploma opens -- PlaceDiplomaOnScreen (specials/crystal_extras.lua), with only the print -- itself going nowhere inside each. The row below is superseded by that -- handler and survives only as the reason. { "PrintDiploma", nil, "printer: no Game Boy Printer" }, -- OverworldTownMap's row is superseded by H.OverworldTownMap -- (specials/crystal_extras.lua) and survives only as the reason. -- ../pokecrystal/data/events/special_pointers.asm:58 and pokegold's :63 both -- carry `add_special UnusedMemoryGame ; unused`, and no script names it. { "OverworldTownMap", nil, "needs the POKeGEAR map card in view mode" }, { "UnusedMemoryGame", nil, "unused on both carts; no script reaches _MemoryGame" }, -- data/events/special_pointers.asm:124-181, the rows only Crystal has. { "BattleTowerRoomMenu", 10, "Battle Tower: $a is the menu's back-out arm" }, { "BattleTowerBattle", nil, "Battle Tower: no tower battle to run" }, { "BattleTowerAction", 0, "Battle Tower: 0 is sGSBallFlag clear" }, { "CheckForBattleTowerRules", 0, "Battle Tower: no challenge in progress" }, { "Menu_ChallengeExplanationCancel", 0, "Battle Tower: 0 ends the talk" }, { "LoadOpponentTrainerAndPokemonWithOTSprite", 0, "Battle Tower: no roster" }, { "BattleTowerMobileError", nil, "Battle Tower: no mobile error to report" }, { "Function1700ba", nil, "Battle Tower: mobile challenge setup" }, { "Function170114", nil, "Battle Tower: mobile challenge setup" }, { "Function1704e1", nil, "Battle Tower: mobile challenge setup" }, { "AskMobileOrCable", 0, "Mobile System GB: 0 is a B press off the menu" }, { "Mobile_SelectThreeMons", 0, "Mobile System GB: no three mons picked" }, { "Function1011f1", nil, "Mobile System GB: enters LINK_MOBILE" }, { "Function101220", nil, "Mobile System GB: leaves LINK_MOBILE" }, { "Function101225", 0, "Mobile System GB: mobile trade room teardown" }, { "Function101231", 0, "Mobile System GB: mobile battle room teardown" }, { "Function102142", nil, "Mobile System GB: mobile news feed" }, { "Function103780", 0, "Mobile System GB: the mobile save never happens" }, { "Function1037c2", 0, "Mobile System GB: no rematch on same settings" }, { "Function1037eb", 0, "Mobile System GB: no battle time is left" }, { "Function10383c", 0, "Mobile System GB: the three-mon pick cancels" }, { "Function10387b", nil, "Mobile System GB: adapter status readback" }, { "TradeCornerHoldMon", nil, "Mobile System GB: no mobile trade corner" }, { "Function11ac3e", nil, "Mobile System GB: trade corner submenu" }, { "Function11b5e8", nil, "Mobile System GB: trade corner submenu" }, { "Function11b7e5", nil, "Mobile System GB: trade corner submenu" }, { "Function11b879", 0, "Mobile System GB: trade corner submenu" }, { "Function11b920", nil, "Mobile System GB: trade corner submenu" }, { "Function11b93b", nil, "Mobile System GB: trade corner submenu" }, { "Function11ba38", 0, "Mobile System GB: trade corner submenu" }, { "Function17d2b6", nil, "Mobile System GB: mobile menu chrome" }, { "Function17d2ce", 0, "Mobile System GB: mobile menu chrome" }, { "Function11c1ab", nil, "Mobile System GB: the fixed-word entry screen" }, { "UnusedFindItemInPCOrBag", 0, "Mobile System GB: unreferenced" }, { "GiveOddEgg", nil, "the Odd Egg roster is not ported; Route 34 is later" }, { "DisplayUnownWords", nil, "needs the Unown wall word box" }, { "HoOhChamber", nil, "the Ruins of Alph secret chambers are not ported" }, { "OmanyteChamber", nil, "the Ruins of Alph secret chambers are not ported" }, { "PokeSeer", nil, "needs the Seer's caught-data page" }, { "BeastsCheck", 0, "the three beasts cannot all be owned this early" }, { "BuenasPassword", 0, "Buena's show is not ported; 0 is a wrong guess" }, { "BuenaPrize", nil, "Buena's prize counter is not ported" }, { "AskRememberPassword", 0, "Buena's show is not ported; 0 declines" }, { "CelebiShrineEvent", nil, "the GS Ball event needs the mobile stadium" }, { "CheckCaughtCelebi", 0, "the GS Ball event never runs, so Celebi is free" }, { "GiveDratini", nil, "the Dragon Shrine moveset swap is past Phase 1" }, { "MoveTutor", 255, "the tutor is past Phase 1; -1 is its cancel arm" }, } Specials.HANDLERS = H -- data/events/special_pointers.asm:124-181, the "; Crystal only" block: one -- module per owner under src/script/gen2/specials/, merged into HANDLERS here. Specials.MODULES = { "crystal_story", "battle_tower", "crystal_extras", "unown_words", } Specials.HANDLER_SOURCE = {} for name in pairs(H) do Specials.HANDLER_SOURCE[name] = "Specials.lua" end Specials.STUBS = {} Specials.STUB_REASONS = {} Specials.SUPERSEDED_STUBS = {} -- The dispatch table Vm.SPECIALS is. Built rather than written out so the two -- sets cannot drift, and so a name that ends up in both is a hard error here -- rather than a silent shadow at runtime. Specials.ALL = {} local function clear(t) for key in pairs(t) do t[key] = nil end end local function rebuild() clear(Specials.STUBS) clear(Specials.STUB_REASONS) clear(Specials.SUPERSEDED_STUBS) clear(Specials.ALL) for _, row in ipairs(STUB_ROWS) do local name, value, reason = row[1], row[2], row[3] if H[name] then if Specials.HANDLER_SOURCE[name] == "Specials.lua" then error("gen2 special '" .. name .. "' is both implemented and stubbed", 0) end Specials.SUPERSEDED_STUBS[name] = reason else Specials.STUB_REASONS[name] = reason Specials.STUBS[name] = function(vm) if value ~= nil then vm.scriptVar = value end end end end for name, fn in pairs(H) do Specials.ALL[name] = fn end for name, fn in pairs(Specials.STUBS) do Specials.ALL[name] = fn end end function Specials.merge(handlers, source) if type(handlers) ~= "table" then error("gen2 specials module '" .. tostring(source) .. "' returned " .. type(handlers) .. ", expected a table of name -> function", 0) end for name, fn in pairs(handlers) do if type(name) ~= "string" or type(fn) ~= "function" then error("gen2 specials module '" .. tostring(source) .. "' entry [" .. tostring(name) .. "] is not name -> function", 0) end local owner = Specials.HANDLER_SOURCE[name] if owner then error("gen2 special '" .. name .. "' is defined twice: " .. owner .. " and " .. tostring(source), 0) end H[name] = fn Specials.HANDLER_SOURCE[name] = source end rebuild() return handlers end package.loaded["src.script.gen2.Specials"] = Specials for _, name in ipairs(Specials.MODULES) do Specials.merge(require("src.script.gen2.specials." .. name), "specials/" .. name .. ".lua") end rebuild() return Specials