mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-28 09:54:43 +02:00
2773 lines
115 KiB
Lua
2773 lines
115 KiB
Lua
-- 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 / <PLAYER>'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 <RIVAL>" 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
|