Add Pokemon Crystal as a sixth supported version

Crystal boots from a user-supplied ROM, imports a full cache and is playable:
copyright, the Crystal intro movie, the animated title, gender select, Oak,
and out into Johto. 122 of the cart's 169 script specials are implemented.

Import and data
- tools/make_crystal_manifest.py derives the manifest by importing
  make_gold_manifest as a library, with three additive keyword seams. Gold and
  Silver still regenerate byte-identical, which is the standing requirement for
  touching that generator.
- crystal_symbol_deltas.py and crystal_movie_symbols.py carry the symbol delta:
  Crystal renames the credits mons, splits the trainer card, Pokegear and
  pack-pal blocks by gender, and replaces the intro and title outright.
- Crystal-only manifest keys: engineFlagOrder (162 flags to Gold's 93, so the
  badge block sits one higher) and unownCharmap (the main charmap parser stops
  at the first newcharmap so the two cannot contaminate each other).

Extractor
- RomExtractorGen2 becomes three-edition. Crystal corrections: PAL_MAP_BANK
  0x13, a flat PICS_FIX pic bank, audio bank 0x5e, the mapSongs id-100 hole,
  seven NPC trades, a TradeTexts stride of 8, the five Crystal tileset anim
  steps with per-row degrade, and the column-major trainer card portraits.
- New: animated front sprites (frames, bitmasks, play and idle scripts), the
  Battle Tower roster, Kris assets, Mobile System GB art, and the Crystal
  intro and title via src/import/CrystalMovie.lua.

Engine
- GameVersion gains engine(id) and fixes(id). Gold and Silver keep their
  original bugs where the bug is not hardware dependent; Crystal gets the fixes
  Crystal shipped: Lucky Number boxes 10-14, surfing onto an NPC, and the
  Reflect and Light Screen defence overflow.
- Crystal story: Suicune and Eusine, Celebi behind the GS Ball flag, the Ruins
  of Alph chambers, Buena, the Move Tutor, the Poke Seer, and the Battle Tower
  including the wInBattleTowerBattle badge-boost guard.
- Kris and the gender flag, animated fronts in battle and the summary screen,
  and mon caught data.

Verification
- Every extracted asset is pixel-compared against pret's own source PNGs.
- Gold caches are byte-identical before and after, file for file.
- New Crystal suites plus a T2 Gen 2 tier; the full suite passes.
This commit is contained in:
bryanthaboi
2026-08-23 12:10:28 -04:00
parent e88f2ef060
commit ca4d3d283c
141 changed files with 43306 additions and 583 deletions
+129 -5
View File
@@ -173,6 +173,113 @@ local Opcodes = {
[0xa1] = { name = "warpfacing", size = 5 },
}
-- pokegold/macros/scripts/events.asm:1015 DEF NUM_EVENT_COMMANDS EQU $a2
Opcodes.NUM_EVENT_COMMANDS = 0xa2
-- pokecrystal/macros/scripts/events.asm:1-539 is identical to pokegold's, so
-- $00..$51 is copied; :541 inserts farjumptext and shifts everything after it.
local CRYSTAL = {}
for byte = 0x00, 0x51 do
local row = Opcodes[byte]
CRYSTAL[byte] = { name = row.name, size = row.size }
end
-- pokecrystal/macros/scripts/events.asm:541-1066, cross-checked against
-- ScriptCommandTable at pokecrystal/engine/overworld/scripting.asm:64-237.
CRYSTAL[0x52] = { name = "farjumptext", size = 3 }
CRYSTAL[0x53] = { name = "jumptext", size = 2 }
CRYSTAL[0x54] = { name = "waitbutton", size = 0 }
CRYSTAL[0x55] = { name = "promptbutton", size = 0 }
CRYSTAL[0x56] = { name = "pokepic", size = 1 }
CRYSTAL[0x57] = { name = "closepokepic", size = 0 }
CRYSTAL[0x58] = { name = "_2dmenu", size = 0 }
CRYSTAL[0x59] = { name = "verticalmenu", size = 0 }
CRYSTAL[0x5a] = { name = "loadpikachudata", size = 0 }
CRYSTAL[0x5b] = { name = "randomwildmon", size = 0 }
CRYSTAL[0x5c] = { name = "loadtemptrainer", size = 0 }
CRYSTAL[0x5d] = { name = "loadwildmon", size = 2 }
CRYSTAL[0x5e] = { name = "loadtrainer", size = 2 }
CRYSTAL[0x5f] = { name = "startbattle", size = 0 }
CRYSTAL[0x60] = { name = "reloadmapafterbattle", size = 0 }
CRYSTAL[0x61] = { name = "catchtutorial", size = 1 }
CRYSTAL[0x62] = { name = "trainertext", size = 1 }
CRYSTAL[0x63] = { name = "trainerflagaction", size = 1 }
CRYSTAL[0x64] = { name = "winlosstext", size = 4 }
CRYSTAL[0x65] = { name = "scripttalkafter", size = 0 }
CRYSTAL[0x66] = { name = "endifjustbattled", size = 0 }
CRYSTAL[0x67] = { name = "checkjustbattled", size = 0 }
CRYSTAL[0x68] = { name = "setlasttalked", size = 1 }
CRYSTAL[0x69] = { name = "applymovement", size = 3 }
CRYSTAL[0x6a] = { name = "applymovementlasttalked", size = 2 }
CRYSTAL[0x6b] = { name = "faceplayer", size = 0 }
CRYSTAL[0x6c] = { name = "faceobject", size = 2 }
CRYSTAL[0x6d] = { name = "variablesprite", size = 2 }
CRYSTAL[0x6e] = { name = "disappear", size = 1 }
CRYSTAL[0x6f] = { name = "appear", size = 1 }
CRYSTAL[0x70] = { name = "follow", size = 2 }
CRYSTAL[0x71] = { name = "stopfollow", size = 0 }
CRYSTAL[0x72] = { name = "moveobject", size = 3 }
CRYSTAL[0x73] = { name = "writeobjectxy", size = 1 }
CRYSTAL[0x74] = { name = "loademote", size = 1 }
CRYSTAL[0x75] = { name = "showemote", size = 3 }
CRYSTAL[0x76] = { name = "turnobject", size = 2 }
CRYSTAL[0x77] = { name = "follownotexact", size = 2 }
CRYSTAL[0x78] = { name = "earthquake", size = 1 }
CRYSTAL[0x79] = { name = "changemapblocks", size = 3 }
CRYSTAL[0x7a] = { name = "changeblock", size = 3 }
CRYSTAL[0x7b] = { name = "reloadmap", size = 0 }
CRYSTAL[0x7c] = { name = "refreshmap", size = 0 }
CRYSTAL[0x7d] = { name = "writecmdqueue", size = 2 }
CRYSTAL[0x7e] = { name = "delcmdqueue", size = 1 }
CRYSTAL[0x7f] = { name = "playmusic", size = 2 }
CRYSTAL[0x80] = { name = "encountermusic", size = 0 }
CRYSTAL[0x81] = { name = "musicfadeout", size = 3 }
CRYSTAL[0x82] = { name = "playmapmusic", size = 0 }
CRYSTAL[0x83] = { name = "dontrestartmapmusic", size = 0 }
CRYSTAL[0x84] = { name = "cry", size = 2 }
CRYSTAL[0x85] = { name = "playsound", size = 2 }
CRYSTAL[0x86] = { name = "waitsfx", size = 0 }
CRYSTAL[0x87] = { name = "warpsound", size = 0 }
CRYSTAL[0x88] = { name = "specialsound", size = 0 }
CRYSTAL[0x89] = { name = "autoinput", size = 3 }
CRYSTAL[0x8a] = { name = "newloadmap", size = 1 }
CRYSTAL[0x8b] = { name = "pause", size = 1 }
CRYSTAL[0x8c] = { name = "deactivatefacing", size = 1 }
CRYSTAL[0x8d] = { name = "sdefer", size = 2 }
CRYSTAL[0x8e] = { name = "warpcheck", size = 0 }
CRYSTAL[0x8f] = { name = "stopandsjump", size = 2 }
CRYSTAL[0x90] = { name = "endcallback", size = 0 }
CRYSTAL[0x91] = { name = "end", size = 0 }
CRYSTAL[0x92] = { name = "reloadend", size = 1 }
CRYSTAL[0x93] = { name = "endall", size = 0 }
CRYSTAL[0x94] = { name = "pokemart", size = 3 }
CRYSTAL[0x95] = { name = "elevator", size = 2 }
CRYSTAL[0x96] = { name = "trade", size = 1 }
CRYSTAL[0x97] = { name = "askforphonenumber", size = 1 }
CRYSTAL[0x98] = { name = "phonecall", size = 2 }
CRYSTAL[0x99] = { name = "hangup", size = 0 }
CRYSTAL[0x9a] = { name = "describedecoration", size = 1 }
CRYSTAL[0x9b] = { name = "fruittree", size = 1 }
CRYSTAL[0x9c] = { name = "specialphonecall", size = 2 }
CRYSTAL[0x9d] = { name = "checkphonecall", size = 0 }
CRYSTAL[0x9e] = { name = "verbosegiveitem", size = 2 }
CRYSTAL[0x9f] = { name = "verbosegiveitemvar", size = 2 }
-- events.asm:1003-1008: Crystal's swarm is `db flag` then `map_id`, not a bare
-- map_id; Script_swarm makes three GetScriptByte calls (scripting.asm:654-662).
CRYSTAL[0xa0] = { name = "swarm", size = 3 }
CRYSTAL[0xa1] = { name = "halloffame", size = 0 }
CRYSTAL[0xa2] = { name = "credits", size = 0 }
CRYSTAL[0xa3] = { name = "warpfacing", size = 5 }
CRYSTAL[0xa4] = { name = "battletowertext", size = 1 }
CRYSTAL[0xa5] = { name = "getlandmarkname", size = 2 }
CRYSTAL[0xa6] = { name = "gettrainerclassname", size = 2 }
CRYSTAL[0xa7] = { name = "getname", size = 3 }
CRYSTAL[0xa8] = { name = "wait", size = 1 }
CRYSTAL[0xa9] = { name = "checksave", size = 0 }
-- pokecrystal/macros/scripts/events.asm:1068 DEF NUM_EVENT_COMMANDS EQU $aa
CRYSTAL.NUM_EVENT_COMMANDS = 0xaa
-- Commands that end the current linear path (jumps transfer control).
--
-- `fruittree` and `describedecoration` are ScriptJumps (Script_fruittree does
@@ -185,9 +292,13 @@ local Opcodes = {
-- these four regardless. `catchtutorial` is deliberately NOT here: it ends on
-- `jp Script_reloadmap` and the script really does continue afterwards, the
-- same way `reloadmap` itself does.
--
-- `farjumptext` is Crystal-only and joins them for the same reason `jumptext`
-- is here: pokecrystal/engine/overworld/scripting.asm:318-327 ends on
-- `jp ScriptJump` into JumpTextScript.
Opcodes.TERMINATORS = {
sjump = true, farsjump = true, memjump = true, jumpstd = true,
jumptext = true, jumptextfaceplayer = true,
jumptext = true, farjumptext = true, jumptextfaceplayer = true,
stopandsjump = true, ["end"] = true, endall = true, endcallback = true,
reloadend = true,
fruittree = true, describedecoration = true,
@@ -198,10 +309,12 @@ Opcodes.TERMINATORS = {
-- behind it. Every row above is keyed by the opcode byte the cart carries, and
-- src/import/RomExtractorGen2.lua resolves a command as Opcodes[byte]: a byte
-- that is not in that table breaks the pointer walk into an `unknown` row. Give
-- a mod verb one of the free bytes ($a2..$ff) and ROM data that happens to start
-- with it would decode as a mod call instead of ending the walk, so the free
-- space stays free and the extension op is reachable only by NAME -- which is to
-- say, only from a row a mod wrote, never from one the extractor did.
-- a mod verb one of the free bytes and ROM data that happens to start with it
-- would decode as a mod call instead of ending the walk, so the free space stays
-- free and the extension op is reachable only by NAME -- which is to say, only
-- from a row a mod wrote, never from one the extractor did. The free space is
-- per dialect and the intersection is what matters: Gold leaves $a2..$ff
-- (events.asm:1015), Crystal only $aa..$ff (events.asm:1068).
--
-- src/script/gen2/Vm.lua:runModCommand is the only reader; the contract for the
-- row shapes and the verb table is documented there.
@@ -211,4 +324,15 @@ function Opcodes.key(bank, address)
return string.format("%02x:%04x", bank, address)
end
CRYSTAL.TERMINATORS = Opcodes.TERMINATORS
CRYSTAL.MOD_COMMAND = Opcodes.MOD_COMMAND
CRYSTAL.key = Opcodes.key
-- Gold and Silver share one dialect (pokegold/macros/scripts/events.asm:540);
-- Crystal renumbers from $52 (pokecrystal/macros/scripts/events.asm:541).
function Opcodes.forEdition(edition)
if edition == "crystal" then return CRYSTAL end
return Opcodes
end
return Opcodes
+339 -39
View File
@@ -1,7 +1,7 @@
-- 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 112
-- 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.
--
@@ -9,9 +9,9 @@
-- 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. This cache's
-- order has 112 rows -- the asm file's 113 `add_special` matches include the
-- MACRO line itself.
-- 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:
--
@@ -52,6 +52,7 @@
-- 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")
@@ -175,6 +176,20 @@ local function selectMon(vm, prompt)
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)
--------------------------------------------------------------------------
@@ -1058,7 +1073,7 @@ end
-- -- 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 has no viewer half at all and stays a stub.
-- 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
@@ -1117,6 +1132,10 @@ 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.
@@ -1152,6 +1171,20 @@ H.LoadUsedSpritesGFX = function(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.
@@ -1160,6 +1193,16 @@ H.UpdatePlayerSprite = function(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
@@ -1445,6 +1488,22 @@ local function trailingDigitsShared(a, b)
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
@@ -1508,8 +1567,9 @@ H.CheckForLuckyNumberWinners = function(vm)
end
end
for _, mon in ipairs(party(vm)) do consider(mon, false) end
for _, box in pairs((record and record.boxes) or {}) do
for _, mon in ipairs(box or {}) do consider(mon, true) 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
@@ -1586,9 +1646,17 @@ local KURT_MENU_FLAGS = 0x80 + 0x20
-- 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
@@ -1612,7 +1680,10 @@ H.SelectApricornForKurt = function(vm)
local item = h.itemIndex and h.itemIndex(apricorn)
if not item or item == 0 then return answer(vm, FALSE) end
answer(vm, item)
Apricorns.takeApricorn(record, apricorn)
if Apricorns.takeApricorn(record, apricorn)
and h.setKurtApricornQuantity then
h.setKurtApricornQuantity(1)
end
end
-- ---- 88-89 the first party slot -------------------------------------------
@@ -1917,7 +1988,7 @@ local OAK_RATINGS = {
-- 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` (also stubbed: "printer: no Game Boy Printer").
-- `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(
@@ -2027,7 +2098,7 @@ Specials.ROAMERS = Roamers.SPECIES
H.InitRoamMons = function(vm)
local record = save(vm)
if not record then return end
Roamers.init(record, { force = true })
Roamers.init(record, { force = true, data = data(vm) })
end
-- ---- the #DEX-completion diploma -------------------------------------------
@@ -2328,7 +2399,7 @@ end
-- 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 stays stubbed below -- so `ldh a, [hPrinter] / and a / jr nz,
-- 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
@@ -2370,11 +2441,137 @@ H.PhotoStudio = function(vm)
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
--------------------------------------------------------------------------
@@ -2394,7 +2591,6 @@ local STUB_ROWS = {
{ "SetBitsForLinkTradeRequest", nil, "link cable: no Gen 2 cable club" },
{ "WaitForLinkedFriend", 0, "link cable: nobody ever connects" },
{ "CheckLinkTimeout_Receptionist", 1, "link cable: always times out" },
{ "TryQuickSave", 0, "link cable: the cable club's own save is not ported" },
{ "CheckBothSelectedSameRoom", 0, "link cable: no second player" },
{ "FailedLinkToPast", 1, "link cable: the Time Capsule is not ported" },
{ "CloseLink", nil, "link cable: nothing to close" },
@@ -2415,45 +2611,149 @@ local STUB_ROWS = {
{ "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 two of the three
-- specials that want it are not in this table: PhotoStudio ports the
-- conversation and the portrait screen (H.PhotoStudio above) and
-- UnownPrinter ports the stamp viewer (H.UnownPrinter above), with only the
-- print itself stubbed inside each. PrintDiploma is the one that is
-- nothing BUT the print -- the diploma's own page is `special Diploma`, a
-- separate row -- so it is all that is left here.
-- 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" },
-- Screens the port has not built. Each names what it needs; the value is
-- the arm a cancel takes, so the script backs out rather than proceeding
-- through a transaction that never happened.
-- 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 in Gold; needs the memory game screen" },
{ "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 = {}
for _, row in ipairs(STUB_ROWS) do
local name, value, reason = row[1], row[2], row[3]
Specials.STUB_REASONS[name] = reason
Specials.STUBS[name] = function(vm)
if value ~= nil then vm.scriptVar = value end
end
end
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 = {}
for name, fn in pairs(Specials.HANDLERS) do
Specials.ALL[name] = fn
end
for name, fn in pairs(Specials.STUBS) do
if Specials.ALL[name] then
error("gen2 special '" .. name .. "' is both implemented and stubbed", 0)
end
Specials.ALL[name] = fn
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
+84 -6
View File
@@ -49,6 +49,14 @@ local MAX_MONEY, MAX_COINS = 999999, 9999
local SFX_ITEM, SFX_HANG_UP = 0x01, 0x6b
-- constants/script_constants.asm: EMOTE_FROM_MEM is -1, i.e. the byte $ff.
local EMOTE_FROM_MEM = 0xff
-- constants/item_constants.asm:300 DEF ITEM_FROM_MEM EQU $ff
local ITEM_FROM_MEM = 0xff
-- pokecrystal/constants/script_constants.asm:254-257 StoreSwarmMapIndices args.
local SWARM_DUNSPARCE = 0
-- pokecrystal/constants/text_constants.asm:13-19 wNamedObjectType values.
local NAMED_MON, NAMED_ITEM, NAMED_TRAINER = 1, 4, 7
-- pokecrystal/engine/overworld/scripting.asm:2336-2347 Script_wait
local WAIT_FRAMES_PER_UNIT = 6
-- constants/misc_constants.asm GS_VERSION: 0 Gold, 1 Silver.
local GS_VERSION_GOLD = 0
-- engine/overworld/variables.asm .VarActionTable rows for wMapGroup and
@@ -204,7 +212,9 @@ local function runCmd(self, cmd, op)
local value = self.callAsmFn(cmd.label, arg1(cmd) or 0, wordArg(cmd, 2))
if value ~= nil then self.scriptVar = value % 256 end
end
elseif op == "jumptext" then
elseif op == "jumptext" or op == "farjumptext" then
-- pokecrystal/engine/overworld/scripting.asm:318-327 Script_farjumptext:
-- Script_jumptext with a `dba` for the `dw`, same JumpTextScript.
self:emitFace(false)
self:showText(cmd.text)
return "end"
@@ -519,6 +529,36 @@ local function runCmd(self, cmd, op)
-- town map draws them two rows deep).
local name = self.getLandmarkNameFn and self.getLandmarkNameFn()
if name then self:setStringBuffer(name) end
elseif op == "getlandmarkname" then
-- pokecrystal/engine/overworld/scripting.asm:1615-1623: the landmark id
-- comes off the script, then ConvertLandmarkToText and a buffer byte.
local id = cmd.landmark or arg1(cmd) or 0
local name = self.getLandmarkNameFn and self.getLandmarkNameFn(id)
if name then self:setStringBuffer(name) end
elseif op == "gettrainerclassname" then
-- pokecrystal/engine/overworld/scripting.asm:1644-1647: TRAINER_NAME is
-- preset, so the one id byte ContinueToGetName reads is a trainer group.
if self.getTrainerClassNameFn then
local name = self.getTrainerClassNameFn(cmd.class or arg1(cmd) or 0)
if name then self:setStringBuffer(name) end
end
elseif op == "getname" then
-- pokecrystal/engine/overworld/scripting.asm:1633-1641: a
-- wNamedObjectType byte, an id byte, then GetStringBuffer's buffer byte.
local args = cmd.args or {}
local kind = cmd.kind or args[1] or 0
local id = cmd.id or args[2] or 0
local name
if kind == NAMED_MON and self.getMonNameFn then
name = self.getMonNameFn(id)
elseif kind == NAMED_ITEM and self.getItemNameFn then
name = self.getItemNameFn(id)
elseif kind == NAMED_TRAINER and self.getTrainerClassNameFn then
name = self.getTrainerClassNameFn(id)
elseif self.getNameFn then
name = self.getNameFn(kind, id)
end
if name then self:setStringBuffer(name) end
elseif op == "getnum" then
-- Script_getnum: PrintNum of wScriptVar (PRINTNUM_LEFTALIGN | 1 byte,
-- 3 chars) into wStringBuffer1, then GetStringBuffer copies that into
@@ -599,9 +639,17 @@ local function runCmd(self, cmd, op)
else
self.scriptVar = POKEMAIL_REFUSED
end
elseif op == "giveitem" or op == "verbosegiveitem" then
elseif op == "giveitem" or op == "verbosegiveitem"
or op == "verbosegiveitemvar" then
local item = cmd.item or arg1(cmd) or 0
local qty = cmd.quantity or (cmd.args and cmd.args[2]) or 1
if op == "verbosegiveitemvar" then
-- pokecrystal/engine/overworld/scripting.asm:486-510: ITEM_FROM_MEM
-- takes the item from wScriptVar, and byte two is a VAR_* id.
if item == ITEM_FROM_MEM then item = (self.scriptVar or 0) % 256 end
local varId = cmd.var or (cmd.args and cmd.args[2]) or 0
qty = self.readVarFn and self.readVarFn(varId) or 0
end
-- Script_giveitem's own `ld [wCurItem], a` (scripting.asm:1612). It is
-- what the standalone `specialsound` inside GiveItemScript reads back:
-- CheckItemPocket runs on wCurItem, not on anything the opcode carries.
@@ -611,7 +659,7 @@ local function runCmd(self, cmd, op)
ok = self.giveItemFn(item, qty) ~= false
end
self.scriptVar = ok and 1 or 0
if op == "verbosegiveitem" then
if op == "verbosegiveitem" or op == "verbosegiveitemvar" then
local name = self.getItemNameFn and self.getItemNameFn(item) or "?"
self:setStringBuffer(name)
-- GiveItemScript (engine/overworld/scripting.asm:441-449), command for
@@ -1048,10 +1096,20 @@ local function runCmd(self, cmd, op)
-- SetSwarmFlag -> DAILYFLAGS1_SWARM. Both halves matter: CheckSwarmFlag
-- is what makes the swarm expire, so a port that only stores the map
-- leaves the Dunsparce call permanently live.
--
-- pokecrystal/macros/scripts/events.asm:1003-1008 adds a leading flag byte
-- and specials.asm:290-298 picks the index pair off it, so three operand
-- bytes means the map_id has moved along one.
local args = cmd.args or {}
local group = cmd.group or args[1]
local mapNum = cmd.map or args[2]
if self.setSwarmFn then self.setSwarmFn(group, mapNum) end
local kind, group, mapNum
if #args >= 3 then
kind, group, mapNum = args[1], args[2], args[3]
else
kind = SWARM_DUNSPARCE
group = cmd.group or args[1]
mapNum = cmd.map or args[2]
end
if self.setSwarmFn then self.setSwarmFn(group, mapNum, kind) end
elseif op == "reloadmapafterbattle" or op == "reloadmap"
or op == "refreshmap" then
-- Losing ENDS the script. Script_reloadmapafterbattle reads wBattleResult
@@ -1617,6 +1675,21 @@ local function runCmd(self, cmd, op)
coroutine.yield({ kind = "credits" })
end
return "end"
-- ---- Crystal-only verbs ------------------------------------------------
elseif op == "wait" then
-- pokecrystal/engine/overworld/scripting.asm:2336-2347 Script_wait: SIX
-- frames of DelayFrames per operand unit, not Script_pause's two.
self:waitFrames((cmd.frames or arg1(cmd) or 0) * WAIT_FRAMES_PER_UNIT)
elseif op == "checksave" then
-- pokecrystal/engine/overworld/scripting.asm:2349-2353 writes CheckSave's
-- c: 1 when both sCheckValue bytes match (events/checksave.asm:1-20).
local ok = true
if self.checkSaveFn then ok = self.checkSaveFn() and true or false end
self.scriptVar = ok and 1 or 0
elseif op == "battletowertext" then
-- pokecrystal/engine/overworld/scripting.asm:447-452 BattleTowerText.
-- Unported: the table consumes the operand and the verb warns once.
self:noteUnknownOp(op)
-- ---- commands with no engine behind them yet ---------------------------
elseif op == "deactivatefacing" then
-- Script_deactivatefacing: wScriptDelay = the byte (left ALONE when the
@@ -1960,6 +2033,11 @@ function Vm.new(scripts, text, events, hooks)
givePokeMailFn = hooks.givePokeMail,
checkPokeMailFn = hooks.checkPokeMail,
getLandmarkNameFn = hooks.getLandmarkName,
-- pokecrystal/engine/overworld/scripting.asm:1633-1647, and 2349-2353.
-- Absent on a Gold boot, where no opcode reaches them.
getTrainerClassNameFn = hooks.getTrainerClassName,
getNameFn = hooks.getName,
checkSaveFn = hooks.checkSave,
-- loadmenu stashes a header for the verticalmenu / _2dmenu that follows;
-- openMenu is the blocking half, modelled on yesorno.
openMenuFn = hooks.openMenu,
+397
View File
@@ -0,0 +1,397 @@
-- The Battle Tower specials: ../pokecrystal/data/events/special_pointers.asm:132-140 and
-- :150 BattleTowerAction, :152 Menu_ChallengeExplanationCancel.
local Specials = require("src.script.gen2.Specials")
local S = Specials.shared
local Bag = require("src.inventory.Bag")
local BattleTower = require("src.core.gen2.BattleTower")
local RomText = require("src.core.RomText")
local Save = require("src.core.gen2.Save")
local Strings = require("src.core.Strings")
local M = {}
local TRUE, FALSE = S.TRUE, S.FALSE
local A = BattleTower.ACTIONS
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:190-196
-- InitBattleTowerChallengeRAM. wNrOfBeatenBattleTowerTrainers is the byte the
-- room script `readmem`s, so it is zeroed in the VM's own memory store.
local function initChallengeRam(vm)
vm.btBattleEnded = 0
vm.btBeaten = 0
if vm.mem then vm.mem[BattleTower.WRAM_NR_BEATEN] = 0 end
end
-- ../pokecrystal/engine/events/battle_tower/rules.asm:57-92, the far labels
-- data/generated/rom_text.lua carries.
local RULE_FALLBACKS = {
_ExcuseMeYoureNotReadyText =
Strings.source("Excuse me.\nYou're not ready."),
_OnlyThreeMonMayBeEnteredText =
Strings.source("Only three POKéMON\nmay be entered."),
_TheMonMustAllBeDifferentKindsText =
Strings.source("The {STRBUF} POKéMON\nmust all be different kinds."),
_TheMonMustNotHoldTheSameItemsText =
Strings.source("The {STRBUF} POKéMON\nmust not hold the same items."),
_YouCantTakeAnEggText = Strings.source("You can't take an\nEGG!"),
_BattleTowerReturnWhenReadyText =
Strings.source("Please return when\nyou're ready."),
}
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1157-1171
-- BattleTower_CheckSaveFileExistsAndIsYours; the running game IS the loaded
-- save here, so CompareLoadedAndSavedPlayerID can only match.
local function saveFileIsYours(vm)
local record = S.save(vm)
if not (record and record.version) then return FALSE end
return Save.exists(record.version) and TRUE or FALSE
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1024-1127 and
-- :1187-1242, :1314-1447: SRAM bank 5 and WRAM bank 3 belong to the Mobile
-- System GB adapter. A cartridge that never linked reads them as zero, and
-- these are the values that zero produces.
local MOBILE_ARMS = {
[A.ACTION_05] = { value = 0, why = "s5_be46 is 0 until a mobile challenge" },
[A.ACTION_06] = { why = "clears the mobile challenge bytes" },
[A.ACTION_0C] = { why = "stamps the mobile challenge day" },
[A.ACTION_0D] = { value = FALSE, why = "s5_aa47 is 0, so `and a / ret z`" },
[A.ACTION_0F] = { value = 0, why = "w3_d090 is the adapter's status byte" },
[A.ACTION_10] = { value = FALSE, why = "s5_a800 is 0, the .NoAction row" },
[A.ACTION_16] = { why = "stamps the mobile news day" },
[A.ACTION_17] = { value = FALSE, why = "s5_b2f9 is 0, so `and a / ret z`" },
[A.LEVEL_CHECK] = { value = 0, why = "s5_b2fb is the stadium's max level" },
[A.UBERS_CHECK] = { value = 0, why = "s5_b2fb is the stadium's max level" },
}
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:951-953
-- BattleTower_SaveOptions writes options.lua, which no `special` hook reaches.
local UNHOOKED_ARMS = {
[A.SAVEOPTIONS] = "needs a saveOptions hook in World:specialHooks",
}
local ACTIONS = {}
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:978-990
ACTIONS[A.CHECK_EXPLANATION_READ] = function(vm, tower, record)
local yours = saveFileIsYours(vm)
S.answer(vm, yours)
if yours == 0 then return end
S.answer(vm, BattleTower.saveFileFlag(record,
BattleTower.SAVEFILE_EXPLANATION))
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1001-1008
ACTIONS[A.SET_EXPLANATION_READ] = function(_vm, _tower, record)
BattleTower.setSaveFileFlag(record, BattleTower.SAVEFILE_EXPLANATION)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:992-999
ACTIONS[A.GET_CHALLENGE_STATE] = function(vm, tower)
S.answer(vm, tower.challenge)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1010-1012
ACTIONS[A.SAVE_AND_QUIT] = function(_vm, _tower, record)
BattleTower.setChallengeState(record, BattleTower.SAVED_AND_LEFT)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1014-1022
ACTIONS[A.CHALLENGECANCELED] = function(_vm, _tower, record)
BattleTower.setChallengeState(record, BattleTower.NO_CHALLENGE)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1129-1141
ACTIONS[A.SAVELEVELGROUP] = function(vm, tower)
tower.levelGroup = math.max(0, math.floor(tonumber(vm.btLevelGroup) or 0))
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1143-1155
ACTIONS[A.LOADLEVELGROUP] = function(vm, tower)
vm.btLevelGroup = tower.levelGroup
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1157-1171
ACTIONS[A.CHECKSAVEFILEISYOURS] = function(vm)
S.answer(vm, saveFileIsYours(vm))
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1173-1177: the
-- pending fade is dropped and the volume goes back to full, which at both
-- call sites follows a `musicfadeout MUSIC_NONE`.
ACTIONS[A.ACTION_0A] = function(vm)
local h = S.hooks(vm)
if h.stopMusic then h.stopMusic() end
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1179-1185:
-- sGSBallFlag reads back as GS_BALL_AVAILABLE once the ball has been offered,
-- and the Goldenrod scene gates itself on its own event flag afterwards.
ACTIONS[A.GSBALL] = function(vm, _tower, record)
local crystal = record and Save.crystalState(record)
local flag = crystal and crystal.gsBall
S.answer(vm, Save.GS_BALL_STATES[flag] and BattleTower.GS_BALL_AVAILABLE or 0)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1311-1312
-- String_MysteryJP, the OT the Mobile Stadium stamps on an Odd Egg.
local MYSTERY_OT = "\227\129\170\227\129\158\227\131\138\227\131\142"
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1244-1309: the
-- EGG_TICKET is spent only on a party egg carrying String_MysteryJP.
ACTIONS[A.EGGTICKET] = function(vm, _tower, record)
S.answer(vm, FALSE)
local held = record and record.inventory and record.inventory.EGG_TICKET
if not held or held <= 0 then return end
for _, mon in ipairs(S.party(vm)) do
if mon.isEgg and mon.otName == MYSTERY_OT then
mon.otName = ""
Bag.remove(record, "EGG_TICKET", 1)
return S.answer(vm, TRUE)
end
end
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1449-1461
ACTIONS[A.ACTION_11] = function(_vm, tower) tower.reentry = false end
ACTIONS[A.ACTION_12] = function(_vm, tower) tower.reentry = true end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1463-1469
ACTIONS[A.ACTION_13] = function(vm, tower)
S.answer(vm, tower.reentry and TRUE or FALSE)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1471-1483
ACTIONS[A.ACTION_14] = function(vm, _tower, record)
local yours = saveFileIsYours(vm)
S.answer(vm, yours)
if yours == 0 then return end
S.answer(vm, BattleTower.saveFileFlag(record,
BattleTower.SAVEFILE_REGISTERED))
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1485-1492
ACTIONS[A.ACTION_15] = function(_vm, _tower, record)
BattleTower.setSaveFileFlag(record, BattleTower.SAVEFILE_REGISTERED)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:890-904
ACTIONS[A.RESETDATA] = function(_vm, _tower, record)
BattleTower.resetTrainers(record)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:906-933
ACTIONS[A.GIVEREWARD] = function(vm, tower, record)
local h = S.hooks(vm)
local reward = tower.reward or BattleTower.FALLBACK_REWARD
local data = S.data(vm)
local fits = false
if record then
record.inventory = record.inventory or {}
fits = BattleTower.rewardFits(Bag.slots(record, data, "ITEM"),
Bag.capacity(data, "ITEM"), record.inventory[reward])
end
if not fits then reward = BattleTower.FALLBACK_REWARD end
S.answer(vm, (h.itemIndex and h.itemIndex(reward)) or 0)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:935-949
ACTIONS[A.ACTION_1C] = function(_vm, _tower, record)
BattleTower.setChallengeState(record, BattleTower.WON_CHALLENGE)
end
ACTIONS[A.ACTION_1D] = function(_vm, _tower, record)
BattleTower.setChallengeState(record, BattleTower.RECEIVED_REWARD)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:955-976.
-- Specials.random answers 1..n, and `% mask` turns that into the 0..mask-1
-- byte `maskbits` leaves in a.
ACTIONS[A.CHOOSEREWARD] = function(vm, tower)
local data = S.data(vm)
local order = data and data.gen2Constants and data.gen2Constants.itemOrder
tower.reward = BattleTower.rollReward(order, Specials.random)
or BattleTower.FALLBACK_REWARD
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:852-887, the
-- jumptable `special BattleTowerAction` dispatches on wScriptVar.
M.BattleTowerAction = function(vm)
local id = math.floor(tonumber(vm.scriptVar) or 0)
-- ../pokecrystal/ram/sram.asm:147-172, read as the zeroes a fresh cart holds.
local record = S.save(vm) or {}
local tower = BattleTower.state(record)
local run = ACTIONS[id]
if run then return run(vm, tower, record) end
local mobile = MOBILE_ARMS[id]
if mobile then
if mobile.value ~= nil then S.answer(vm, mobile.value) end
return
end
if UNHOOKED_ARMS[id] then return end
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1583-1594: the
-- carry _CheckForBattleTowerRules returns means the party FAILED, and TRUE is
-- what the script's `ifnotequal FALSE` reads as "stop here".
M.CheckForBattleTowerRules = function(vm)
local lines, failed = BattleTower.checkRules(S.party(vm))
local data = S.data(vm)
-- ../pokecrystal/engine/events/battle_tower/rules.asm:28-31 wStringBuffer2
vm:setStringBuffer(BattleTower.RULE_PARTY_COUNT_TEXT)
for _, label in ipairs(lines) do
vm:showRaw(RomText(data, label, RULE_FALLBACKS[label] or label))
end
S.answer(vm, failed and TRUE or FALSE)
end
-- ../pokecrystal/mobile/mobile_5f.asm:425-468 and its MenuData at :490-495.
-- wScriptVar comes in TRUE for the English rows and leaves holding the row
-- number, or 4 for a B press.
local CHALLENGE_MENU_ROWS = {
Strings.source("Challenge"),
Strings.source("Explanation"),
Strings.source("Cancel"),
}
local CHALLENGE_MENU_CANCEL = 4
-- ../pokecrystal/mobile/mobile_5f.asm:484-488 MenuHeader, `menu_coords 0, 0,
-- 14, 7` with STATICMENU_CURSOR | STATICMENU_WRAP.
local CHALLENGE_MENU_FLAGS = 0x80 + 0x20
M.Menu_ChallengeExplanationCancel = function(vm)
local h = S.hooks(vm)
local choice = Specials.block(vm, function(done)
if not h.scriptMenu then return done(0) end
h.scriptMenu({ items = CHALLENGE_MENU_ROWS, left = 0, top = 0,
right = 14, bottom = 7, dataFlags = CHALLENGE_MENU_FLAGS, cursor = 1 },
done)
end)
choice = math.floor(tonumber(choice) or 0)
if choice < 1 or choice > #CHALLENGE_MENU_ROWS then
return S.answer(vm, CHALLENGE_MENU_CANCEL)
end
S.answer(vm, choice)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1534-1550, whose
-- first act is `farcall LoadOpponentTrainerAndPokemon`: the trainer row, the
-- three mons and the two SRAM tables that stop either repeating. wScriptVar
-- comes in holding the object the opponent walks in as and is NOT written.
M.LoadOpponentTrainerAndPokemonWithOTSprite = function(vm)
local record = S.save(vm)
if not record then return end
-- wBT_OTTrainer is WRAM bank 3, so the drawn opponent rides the VM and not
-- the save; only the sBTTrainers slot and the two previous teams persist.
local opponent = BattleTower.drawOpponent(
S.data(vm), record, Specials.random)
vm.btOpponent = opponent
if not opponent then return end
-- :1552-1575: BTTrainerClassSprites[class - 1] goes into the map object
-- wScriptVar names, and GetUsedSprite loads the sheet.
local h = S.hooks(vm)
if h.setObjectSprite and opponent.sprite then
h.setObjectSprite(math.floor(tonumber(vm.scriptVar) or 0), opponent.sprite)
end
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:181-185 and the
-- RunBattleTowerTrainer arm its jumptable runs at :214-259.
--
-- The order there is: SET battle mode forced on, wInBattleTowerBattle set,
-- HealParty, ReadBTTrainerParty (which is what steps the streak counter and
-- arms the challenge), StartBattle, HealParty again, and wBattleResult into
-- wScriptVar. A win then copies sNrOfBeatenBattleTowerTrainers into
-- wNrOfBeatenBattleTowerTrainers -- the byte the room script `readmem`s to
-- decide whether all seven are done -- and leaves `count + 1` as the digit
-- Text_NextUpOpponentNo prints.
M.BattleTowerBattle = function(vm)
vm.btBattleEnded = 0
local record = S.save(vm)
local data = S.data(vm)
local h = S.hooks(vm)
local opponent = vm.btOpponent
if not opponent and record then
opponent = BattleTower.drawOpponent(data, record, Specials.random)
vm.btOpponent = opponent
end
-- A cache with no `battleTower` block on trainers.lua has nobody to send
-- out. LOSE is the room script's own back-out arm
-- (../pokecrystal/maps/BattleTowerBattleRoom.asm:34-37), so the challenge
-- ends rather than looping on an opponent that never appears.
if not (opponent and record) then
vm.btBattleEnded = 1
return S.answer(vm, 1)
end
-- :229 ReadBTTrainerParty -> CopyBTTrainer_FromBT_OT_TowBT_OTTemp (:549-570)
local streak = BattleTower.beginBattle(record)
-- :228 farcall HealParty, before the send-out.
if h.healParty then h.healParty() end
local classes = data and data.trainers and data.trainers.classes
local class = classes and classes[opponent.classId]
local className = (class and class.name) or opponent.classId
local party = BattleTower.battleParty(data, opponent.rows)
local outcome = Specials.block(vm, function(done)
if not h.startTowerBattle then return done("lose") end
local started = h.startTowerBattle({
class = opponent.class,
classId = opponent.classId,
className = className,
-- PlaceEnemysName prints the class then the trainer's own name, the
-- same pair World:startScriptedBattle builds for an overworld trainer.
name = className and (className .. " " .. opponent.name) or opponent.name,
trainerName = opponent.name,
party = party,
-- wOtherTrainerClass is a real class here, so the AI weights, the two
-- item slots and the payout all come off its own attributes row.
attributes = class and class.attributes,
baseMoney = class and class.baseMoney,
items = class and class.items,
}, done)
if not started then done("lose") end
end)
-- :235 farcall HealParty, on the way out whichever way it went.
if h.healParty then h.healParty() end
-- :236-237 wBattleResult: WIN is 0.
local won = outcome ~= "lose"
S.answer(vm, won and 0 or 1)
if won and vm.mem then
-- :240-250
vm.mem[BattleTower.WRAM_NR_BEATEN] = streak % 256
vm:setStringBuffer(tostring(streak + 1))
end
-- :257-258 wBattleTowerBattleEnded = TRUE, which ends _BattleTowerBattle.
vm.btBattleEnded = 1
vm.btOpponent = nil
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1-5, which is
-- InitBattleTowerChallengeRAM plus _BattleTowerRoomMenu
-- (../pokecrystal/mobile/mobile_46.asm:137-177). wScriptVar leaves 0 for a
-- chosen room and $a for the cancel the desk loops back on.
M.BattleTowerRoomMenu = function(vm)
initChallengeRam(vm)
local h = S.hooks(vm)
local record = S.save(vm)
local result = Specials.block(vm, function(done)
if not h.pushScreen then return done(nil) end
local ok = h.pushScreen("Gen2BattleTowerMenu", {
save = record,
party = S.party(vm),
rows = BattleTower.levelGroupRows(record),
monName = h.monName,
onDone = done,
})
if not ok then done(nil) end
end)
local group = math.floor(tonumber(result) or 0)
if group < 1 or group > BattleTower.MAX_LEVEL_GROUP then
-- ../pokecrystal/mobile/mobile_46.asm:4609-4610
return S.answer(vm, 0x0a)
end
vm.btLevelGroup = group
S.answer(vm, 0)
end
return M
+508
View File
@@ -0,0 +1,508 @@
-- ../pokecrystal/data/events/special_pointers.asm:147 MoveTutor, :161 PokeSeer,
-- :162 BuenasPassword, :163 BuenaPrize, :179 AskRememberPassword,
-- :181 UnusedFindItemInPCOrBag, plus the two rows both carts share:
-- :57 OverworldTownMap and :126 PrintDiploma.
local Bag = require("src.inventory.Bag")
local BugContest = require("src.core.gen2.BugContest")
local Mon = require("src.battle.gen2.Mon")
local Nests = require("src.core.gen2.Nests")
local Save = require("src.core.gen2.Save")
local Specials = require("src.script.gen2.Specials")
local Strings = require("src.core.Strings")
local S = Specials.shared
local M = {}
-- engine/overworld/variables.asm:65 wBlueCardBalance, :66 wBuenasPassword.
local VAR_BLUECARDBALANCE = 0x18
local VAR_BUENASPASSWORD = 0x19
-- maps/RadioTower2F.asm:1 BLUE_CARD_POINT_CAP.
local BLUE_CARD_POINT_CAP = 30
local function hooks(vm)
return (vm and vm.specials) or {}
end
--------------------------------------------------------------------------
-- MoveTutor -- ../pokecrystal/engine/events/move_tutor.asm:1
--------------------------------------------------------------------------
-- .GetMoveTutorMove (../pokecrystal/engine/events/move_tutor.asm:36) maps
-- MOVETUTOR_FLAMETHROWER..ICE_BEAM onto MT01..MT03, i.e. pokemon.tutorMoves.
local function tutorMove(vm, index)
local d = S.data(vm)
local list = d and d.pokemon and d.pokemon.tutorMoves
if type(list) ~= "table" then return nil end
if index ~= 1 and index ~= 2 then index = 3 end
return list[index]
end
M.MoveTutor = function(vm)
local h = hooks(vm)
local moveId = tutorMove(vm, vm.scriptVar or 0)
-- ../pokecrystal/engine/events/move_tutor.asm:29 .cancel, which is
-- maps/GoldenrodCity.asm:72 .Incompatible.
if not (moveId and h.pushScreen) then
vm.scriptVar = 255
return
end
local d = S.data(vm)
local moveDef = d and d.moves and d.moves[moveId]
local learned = Specials.block(vm, function(done)
local ok = h.pushScreen("Gen2MoveTutor", {
move = moveId,
moveName = (moveDef and moveDef.name) or moveId,
onDone = function(taught) done(taught and true or false) end,
})
if not ok then done(false) end
end)
-- ../pokecrystal/engine/events/move_tutor.asm:25 `xor a ; FALSE` on the learn arm.
vm.scriptVar = learned and 0 or 255
end
--------------------------------------------------------------------------
-- Buena -- ../pokecrystal/engine/events/buena.asm:1, ../pokecrystal/engine/events/buena_menu.asm:1
--------------------------------------------------------------------------
-- data/radio/buenas_passwords.asm, in table order. `kind` is the BUENA_*
-- function of ../pokecrystal/constants/radio_constants.asm:128-131; `points` is both the
-- Blue Card value and the menu width ../pokecrystal/engine/events/buena.asm:9 adds.
local BUENA_PASSWORDS = {
{ kind = "mon", points = 10,
words = { "CYNDAQUIL", "TOTODILE", "CHIKORITA" } },
{ kind = "item", points = 12,
words = { "FRESH_WATER", "SODA_POP", "LEMONADE" } },
{ kind = "item", points = 12,
words = { "POTION", "ANTIDOTE", "PARLYZ_HEAL" } },
{ kind = "item", points = 12,
words = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL" } },
{ kind = "mon", points = 10,
words = { "PIKACHU", "RATTATA", "GEODUDE" } },
{ kind = "mon", points = 10,
words = { "HOOTHOOT", "SPINARAK", "DROWZEE" } },
{ kind = "string", points = 16,
words = { Strings.source("NEW BARK TOWN"), Strings.source("CHERRYGROVE CITY"),
Strings.source("AZALEA TOWN") } },
{ kind = "string", points = 6,
words = { Strings.source("FLYING"), Strings.source("BUG"),
Strings.source("GRASS") } },
{ kind = "move", points = 12,
words = { "TACKLE", "GROWL", "MUD_SLAP" } },
{ kind = "item", points = 12,
words = { "X_ATTACK", "X_DEFEND", "X_SPEED" } },
{ kind = "string", points = 13,
words = { Strings.source("#MON Talk"), Strings.source("#MON Music"),
Strings.source("Lucky Channel") } },
}
-- ../pokecrystal/constants/radio_constants.asm:120-121.
local NUM_PASSWORD_CATEGORIES = #BUENA_PASSWORDS
local NUM_PASSWORDS_PER_CATEGORY = 3
-- GetBuenasPassword.StringFunctionJumptable (../pokecrystal/engine/pokegear/radio.asm:1534).
local function passwordWord(vm, group, index)
local row = BUENA_PASSWORDS[group + 1]
if not row then return "?" end
local word = row.words[index + 1]
if not word then return "?" end
if row.kind == "string" then return Strings(word) end
local d = S.data(vm)
if row.kind == "mon" then
local def = d and d.pokemon and d.pokemon[word]
return (def and def.name) or word
end
if row.kind == "item" then
local def = d and d.items and d.items[word]
return (def and def.name) or word
end
local def = d and d.moves and d.moves[word]
return (def and def.name) or word
end
-- BuenasPassword4's two rejection rolls, packed group-high over word-low
-- (../pokecrystal/engine/pokegear/radio.asm:1470-1487).
local function rollPassword(save, day)
local buena = Save.crystalState(save).buenaPassword
buena.word = (Specials.random(NUM_PASSWORD_CATEGORIES) - 1) * 16
+ (Specials.random(NUM_PASSWORDS_PER_CATEGORY) - 1)
buena.day = day
return buena.word
end
-- DAILYFLAGS2_BUENAS_PASSWORD_F is what makes the roll once a day
-- (../pokecrystal/engine/pokegear/radio.asm:1467, :1489); the day stamp stands in for it.
local function currentPassword(vm)
local record = S.save(vm)
if not record then return 0 end
local buena = Save.crystalState(record).buenaPassword
local today = BugContest.now().day
if buena.word == nil or buena.day ~= today then rollPassword(record, today) end
if vm.writeVarFn then vm.writeVarFn(VAR_BUENASPASSWORD, buena.word % 256) end
return buena.word % 256
end
-- ../pokecrystal/engine/events/buena_menu.asm:1-9: carry (NO or B) is 0 and a YES is 1. The
-- question is the script's own writetext at maps/RadioTower2F.asm:119.
M.AskRememberPassword = function(vm)
local yes = coroutine.yield({ kind = "yesorno" })
S.answer(vm, yes and 1 or 0)
end
-- ../pokecrystal/engine/events/buena.asm:19-23 `ld a, [wBuenasPassword] / maskbits 3 / cp c`,
-- and :44-49 .PasswordIndices, which makes the menu's answer zero based.
M.BuenasPassword = function(vm)
local h = hooks(vm)
local packed = currentPassword(vm)
local group = math.floor(packed / 16) % 16
if group >= NUM_PASSWORD_CATEGORIES then group = 0 end
local answer = packed % 4
local words = {}
for row = 0, NUM_PASSWORDS_PER_CATEGORY - 1 do
words[row + 1] = passwordWord(vm, group, row)
end
if not h.pushScreen then
S.answer(vm, 0)
return
end
local picked = Specials.block(vm, function(done)
local ok = h.pushScreen("Gen2BuenaPassword", {
mode = "password",
words = words,
width = BUENA_PASSWORDS[group + 1].points,
onDone = function(index) done(index or -1) end,
})
if not ok then done(-1) end
end)
S.answer(vm, (picked == answer) and 1 or 0)
end
-- data/items/buena_prizes.asm.
local BUENA_PRIZES = {
{ item = "ULTRA_BALL", cost = 2 },
{ item = "FULL_RESTORE", cost = 2 },
{ item = "NUGGET", cost = 3 },
{ item = "RARE_CANDY", cost = 3 },
{ item = "PROTEIN", cost = 5 },
{ item = "IRON", cost = 5 },
{ item = "CARBOS", cost = 5 },
{ item = "CALCIUM", cost = 5 },
{ item = "HP_UP", cost = 5 },
}
-- ../pokecrystal/data/text/common_3.asm:1082-1116, the six pages BuenaPrintText cycles.
local PRIZE_TEXT = {
which = Strings.source("Which prize would\nyou like?"),
confirm = Strings.source("{STRBUF}?\nIs that right?"),
hereYouGo = Strings.source("Here you go!"),
notEnough = Strings.source("You don't have\nenough points."),
noRoom = Strings.source("You have no room\nfor it."),
comeAgain = Strings.source("Oh. Please come\nback again!"),
}
-- wBlueCardBalance is a RETVAR_ADDR_DE var, so the point the script awards at
-- maps/RadioTower2F.asm:144-146 and this counter read one store.
local function blueCardBalance(vm)
if not vm.readVarFn then return 0 end
local value = math.floor(tonumber(vm.readVarFn(VAR_BLUECARDBALANCE)) or 0)
if value < 0 then value = 0 end
return math.min(value, BLUE_CARD_POINT_CAP)
end
local function setBlueCardBalance(vm, value)
if vm.writeVarFn then
vm.writeVarFn(VAR_BLUECARDBALANCE, math.max(0, value) % 256)
end
end
-- ReceiveItem into wNumItems (../pokecrystal/engine/events/buena.asm:104-110).
local function receiveItem(vm, itemId)
local record = S.save(vm)
if not record then return false end
record.inventory = record.inventory or {}
return Bag.add(record, itemId, 1, S.data(vm)) and true or false
end
M.BuenaPrize = function(vm)
local h = hooks(vm)
if not h.pushScreen then return end
local d = S.data(vm)
local items = d and d.items
local rows = {}
for i, prize in ipairs(BUENA_PRIZES) do
local def = items and items[prize.item]
rows[i] = {
item = prize.item,
cost = prize.cost,
name = (def and def.name) or prize.item,
}
end
while true do
-- ../pokecrystal/engine/events/buena.asm:71-83: the page is printed and the menu opens
-- over it, so the box is held the way a `yesorno` page is held.
S.showRawHeld(vm, Strings(PRIZE_TEXT.which))
local pick = Specials.block(vm, function(done)
local ok = h.pushScreen("Gen2BuenaPassword", {
mode = "prize",
prizes = rows,
balance = blueCardBalance(vm),
onDone = function(index) done(index or 0) end,
})
if not ok then done(0) end
end)
-- ../pokecrystal/engine/events/buena.asm:84 `jr z, .done`: 0 is the B press.
if not (type(pick) == "number" and rows[pick]) then break end
local row = rows[pick]
vm:setStringBuffer(row.name)
S.showRawHeld(vm, Strings(PRIZE_TEXT.confirm))
local sure = coroutine.yield({ kind = "yesorno" })
if sure then
-- ../pokecrystal/engine/events/buena.asm:95-119: the cost is checked, then ReceiveItem,
-- and only a delivered item spends the points.
local balance = blueCardBalance(vm)
if balance < row.cost then
vm:showRaw(Strings(PRIZE_TEXT.notEnough))
else
if not receiveItem(vm, row.item) then
vm:showRaw(Strings(PRIZE_TEXT.noRoom))
else
setBlueCardBalance(vm, balance - row.cost)
if h.playSfxNamed then h.playSfxNamed("Sfx_Transaction") end
vm:showRaw(Strings(PRIZE_TEXT.hereYouGo))
end
end
end
end
-- ../pokecrystal/engine/events/buena.asm:138-145 .done.
vm:showRaw(Strings(PRIZE_TEXT.comeAgain))
end
--------------------------------------------------------------------------
-- PokeSeer -- ../pokecrystal/engine/events/poke_seer.asm:18
--------------------------------------------------------------------------
-- data/text/common_3.asm:281-445, SeerTexts (../pokecrystal/engine/events/poke_seer.asm:289)
-- in jumptable order, with each text_ram buffer spelled as a directive.
local SEER_TEXT = {
intro = Strings.source("I see all.\nI know all…\fCertainly, I know\nof your #MON!"),
cantTell = Strings.source(
"Whaaaat? I can't\ntell a thing!\fHow could I not\nknow of this?"),
nameLocation = Strings.source("Hm… I see you met\n%s here:\v%s!"),
timeLevel = Strings.source(
"The time was\n%s!\fIts level was %s!\fAm I good or what?"),
trade = Strings.source(
"Hm… %s\ncame from %s\vin a trade?\f%s\nwas where %s\vmet %s!"),
noLocation = Strings.source(
"What!? Incredible!\fI don't understand\nhow, but it is\fincredible!\n"
.. "You are special.\fI can't tell where\nyou met it, but it\v"
.. "was at level %s.\fAm I good or what?"),
egg = Strings.source("Hey!\fThat's an EGG!\fYou can't say that\nyou've met it yet…"),
doNothing = Strings.source("Fufufu! I saw that\nyou'd do nothing!"),
}
-- SeerAdviceTexts (../pokecrystal/engine/events/poke_seer.asm:357), `dbw level, text`. Field
-- three marks the rows that splice the nickname in.
local SEER_ADVICE = {
{ 9, Strings.source(
"Incidentally…\fIt would be wise\nto raise your\f#MON with a\nlittle more care.") },
{ 29, Strings.source(
"Incidentally…\fIt seems to have\ngrown a little.\f%s seems\nto be becoming\v"
.. "more confident."), true },
{ 59, Strings.source(
"Incidentally…\f%s has\ngrown. It's gained\vmuch strength."), true },
{ 89, Strings.source(
"Incidentally…\fIt certainly has\ngrown mighty!\fThis %s\nmust have come\f"
.. "through numerous\n#MON battles.\fIt looks brimming\nwith confidence."), true },
{ 100, Strings.source(
"Incidentally…\fI'm impressed by\nyour dedication.\fIt's been a long\n"
.. "time since I've\fseen a #MON as\nmighty as this\v%s.\fI'm sure that\n"
.. "seeing %s\fin battle would\nexcite anyone."), true },
{ 255, Strings.source(
"Incidentally…\fIt would be wise\nto raise your\f#MON with a\nlittle more care.") },
}
-- GetCaughtTime's .times (../pokecrystal/engine/events/poke_seer.asm:203) and
-- UnknownCaughtData's "Unknown@" (:215).
local SEER_TIMES = {
Strings.source("Morning"), Strings.source("Day"), Strings.source("Night"),
}
local SEER_UNKNOWN = Strings.source("Unknown")
local SEER_NO_LEVEL = Strings.source("???")
-- constants/pokemon_data_constants.asm:130 CAUGHT_EGG_LEVEL and
-- constants/battle_constants.asm:4 EGG_LEVEL.
local CAUGHT_EGG_LEVEL, EGG_LEVEL = 1, 5
-- GetCaughtLevel (../pokecrystal/engine/events/poke_seer.asm:148-179).
local function caughtLevel(byte0)
local level = byte0 % 0x40
if level == 0 then return nil, Strings(SEER_NO_LEVEL) end
if level == CAUGHT_EGG_LEVEL then level = EGG_LEVEL end
return level, tostring(level)
end
-- GetCaughtLocation (../pokecrystal/engine/events/poke_seer.asm:217-249); the second answer
-- is the SEERACTION_* its two sentinel arms override wSeerAction with.
local function caughtLocation(vm, byte1)
local landmark = byte1 % 0x80
if landmark == 0 then return Strings(SEER_UNKNOWN), nil end
if landmark == Mon.LANDMARK_EVENT then return nil, "level_only" end
if landmark == Mon.LANDMARK_GIFT then return nil, "cant_tell" end
local record = Nests.landmark(S.data(vm), landmark)
local name = record and record.name
if not name then return Strings(SEER_UNKNOWN), nil end
-- engine/overworld/landmarks.asm:16 GetLandmarkName copies the break byte
-- through; engine/pokegear/townmap_convertlinebreakcharacters.asm:1 is it.
return (tostring(name):gsub("\n", " ")), nil
end
-- SeerAdvice (../pokecrystal/engine/events/poke_seer.asm:331-355); `sub c` is one byte.
local function seerAdvice(vm, mon, level)
local diff = ((mon.level or 0) - (level or 0)) % 256
local name = Mon.displayName(mon)
for _, row in ipairs(SEER_ADVICE) do
if diff <= row[1] then
if row[3] then return vm:showRaw(Strings(row[2], name, name)) end
return vm:showRaw(Strings(row[2]))
end
end
end
M.PokeSeer = function(vm)
vm:showRaw(Strings(SEER_TEXT.intro))
local _, mon = S.selectMon(vm, "choose")
-- ../pokecrystal/engine/events/poke_seer.asm:38 .cancel.
if not mon then
vm:showRaw(Strings(SEER_TEXT.doNothing))
return
end
-- :28-29 `cp EGG / jr z, .egg`, plus the IsAPokemon test at :31.
if mon.isEgg then
vm:showRaw(Strings(SEER_TEXT.egg))
return
end
local byte0, byte1 = Mon.packCaughtData(mon)
-- ReadCaughtData's `.error` (../pokecrystal/engine/events/poke_seer.asm:104-105, :133).
if byte0 == 0 and byte1 == 0 then
vm:showRaw(Strings(SEER_TEXT.cantTell))
return
end
-- ../pokecrystal/engine/events/poke_seer.asm:110-119: the `cp [hl]` on the OT id's second
-- byte is commented out, so only the HIGH byte decides "traded".
local record = S.save(vm)
local playerId = (record and record.player and record.player.id) or 0
local traded = math.floor((mon.otId or 0) / 256) % 256
~= math.floor(playerId / 256) % 256
local level, levelText = caughtLevel(byte0)
local place, override = caughtLocation(vm, byte1)
local name = Mon.displayName(mon)
-- SeerAction2 / SeerAction3 (../pokecrystal/engine/events/poke_seer.asm:81-89).
if override == "cant_tell" then
vm:showRaw(Strings(SEER_TEXT.cantTell))
return
end
-- SeerAction4 (../pokecrystal/engine/events/poke_seer.asm:91-95).
if override == "level_only" then
vm:showRaw(Strings(SEER_TEXT.noLocation, levelText))
return seerAdvice(vm, mon, level)
end
local time = math.floor(byte0 / 0x40)
local timeText = (time > 0 and Strings(SEER_TIMES[time])) or Strings(SEER_UNKNOWN)
if traded then
-- SeerAction1 (../pokecrystal/engine/events/poke_seer.asm:72-79).
local ot = mon.otName or mon.ot or Strings(SEER_UNKNOWN)
vm:showRaw(Strings(SEER_TEXT.trade, name, ot, place, ot, name))
else
-- SeerAction0 (../pokecrystal/engine/events/poke_seer.asm:64-70).
vm:showRaw(Strings(SEER_TEXT.nameLocation, name, place))
end
vm:showRaw(Strings(SEER_TEXT.timeLevel, timeText, levelText))
seerAdvice(vm, mon, level)
end
--------------------------------------------------------------------------
-- UnusedFindItemInPCOrBag -- ../pokecrystal/mobile/mobile_12_2.asm:191
--------------------------------------------------------------------------
-- CheckItem against wNumPCItems, then wNumItems (../pokecrystal/mobile/mobile_12_2.asm:194,
-- :201); either hit is TRUE.
M.UnusedFindItemInPCOrBag = function(vm)
local h = hooks(vm)
local index = vm.scriptVar or 0
local record = S.save(vm)
local d = S.data(vm)
local id
for key, def in pairs((d and d.items) or {}) do
if type(def) == "table" and def.index == index then id = key break end
end
local pc = record and record.pcItems
if id and type(pc) == "table" and (tonumber(pc[id]) or 0) > 0 then
S.answer(vm, 1)
return
end
if h.hasItem and h.hasItem(index) then
S.answer(vm, 1)
return
end
S.answer(vm, 0)
end
--------------------------------------------------------------------------
-- The wall map -- engine/events/specials.asm:100
--------------------------------------------------------------------------
-- OverworldTownMap is `FadeToMenu / farcall _TownMap / ExitAllMenus`, so it
-- never writes wScriptVar; both callers (engine/events/std_scripts.asm:145
-- TownMapScript and engine/overworld/decorations.asm:1005
-- DecorationDesc_TownMapPoster) follow it with a bare `closetext`.
--
-- _TownMap (engine/pokegear/pokegear.asm:1709) draws the SAME region map the
-- POKeGEAR's MAP card draws -- it calls Pokegear_LoadGFX and PokegearMap for
-- it -- with no card strip, no ENGINE_MAP_CARD gate and no A press, which is
-- src/ui/gen2/Pokegear.lua's `townMap` mode.
M.OverworldTownMap = function(vm)
local h = S.hooks(vm)
if not h.pushScreen then return end
Specials.block(vm, function(done)
local ok = h.pushScreen("Gen2Pokegear", {
townMap = true,
onClose = function() done(true) end,
})
if not ok then done(false) end
end)
end
--------------------------------------------------------------------------
-- The printed diploma -- engine/events/specials.asm:448
--------------------------------------------------------------------------
-- _PrintDiploma (engine/printer/printer.asm:382) opens with the very page
-- `special Diploma` shows -- `farcall PlaceDiplomaOnScreen`,
-- engine/events/diploma.asm:12 -- and only then reaches for the serial port.
-- The second sheet is built with hBGMapMode zeroed and SafeLoadTempTilemapToTilemap
-- puts page 1 straight back, so PrintDiplomaPage2 never reaches the screen on
-- the cartridge either: page 1 IS the whole visible routine.
--
-- The two SendScreenToPrinter passes are the same missing peripheral
-- H.PhotoStudio and H.UnownPrinter degrade around, and .CancelPrinting
-- (maps/CeladonMansion3F.asm:60) is unreferenced, so the cart has no text for
-- a failed print and neither does this.
M.PrintDiploma = function(vm)
local h = S.hooks(vm)
if not h.showDiploma then return end
Specials.block(vm, function(done)
h.showDiploma(function() done(true) end)
end)
end
return M
+298
View File
@@ -0,0 +1,298 @@
-- ../pokecrystal/data/events/special_pointers.asm:141 GiveOddEgg, :148
-- OmanyteChamber, :157 HoOhChamber, :159 CelebiShrineEvent, :160
-- CheckCaughtCelebi, :164 GiveDratini, :166 BeastsCheck.
local Specials = require("src.script.gen2.Specials")
local Mon = require("src.battle.gen2.Mon")
local UnownWords = require("src.world.gen2.UnownWords")
local S = Specials.shared
local M = {}
-- ../pokecrystal/constants/script_constants.asm:51 VAR_BATTLETYPE.
local VAR_BATTLETYPE = 0x03
-- ../pokecrystal/constants/battle_constants.asm:102 BATTLETYPE_CELEBI.
local BATTLETYPE_CELEBI = 11
-- ../pokecrystal/engine/pokemon/search_owned.asm:6, :11, :16 -- the order the
-- routine writes into wScriptVar before each CheckOwnMonAnywhere.
local BEASTS = { "RAIKOU", "ENTEI", "SUICUNE" }
-- ../pokecrystal/engine/pokemon/search_owned.asm:1; MonCheck is
-- CheckOwnMonAnywhere (:48) with the answer already written.
M.BeastsCheck = function(vm)
local monCheck = Specials.HANDLERS.MonCheck
local h = S.hooks(vm)
for _, name in ipairs(BEASTS) do
vm.scriptVar = (h.monIndex and h.monIndex(name)) or name
monCheck(vm)
if vm.scriptVar ~= S.TRUE then
S.answer(vm, S.FALSE)
return
end
end
S.answer(vm, S.TRUE)
end
-- ../pokecrystal/engine/events/dratini.asm:72 .Moveset0, :79 .Moveset1.
local DRATINI = "DRATINI"
local DRATINI_MOVESETS = {
[0] = { "WRAP", "THUNDER_WAVE", "TWISTER", "EXTREMESPEED" },
[1] = { "WRAP", "LEER", "THUNDER_WAVE", "TWISTER" },
}
-- ../pokecrystal/engine/events/dratini.asm:1: `cp $2 / ret nc`, then :16
-- .CheckForDratini walks the party BACKWARDS from the last slot.
M.GiveDratini = function(vm)
local set = DRATINI_MOVESETS[vm.scriptVar or 0]
if not set then return end
local list = S.party(vm)
local target
for index = #list, 1, -1 do
local mon = list[index]
if mon and mon.species == DRATINI then
target = mon
break
end
end
if not target then return end
-- ../pokecrystal/engine/events/dratini.asm:54: each new move's PP comes from
-- Moves + MOVE_PP, so a PP Up on the replaced slot is dropped.
local defs = S.data(vm)
local moves = defs and defs.moves
target.moves = target.moves or {}
for index, id in ipairs(set) do
local pp = (moves and moves[id] and moves[id].pp) or 0
target.moves[index] = { id = id, pp = pp, maxPp = pp }
end
end
-- ../pokecrystal/data/events/odd_eggs.asm:14-33, the `odd_egg_prob` arguments in
-- order; the macro (:5) accumulates them and stores total * $ffff / 100.
local ODD_EGG_PERCENTS = { 8, 1, 16, 3, 16, 3, 14, 2, 10, 2, 12, 2, 10, 1 }
local ODD_EGG_PROBABILITIES = {}
do
local total = 0
for index, percent in ipairs(ODD_EGG_PERCENTS) do
total = total + percent
ODD_EGG_PROBABILITIES[index] = math.floor(total * 0xffff / 100)
end
end
-- ../pokecrystal/data/events/odd_eggs.asm:37 OddEggs, one row per
-- NICKNAMED_MON_STRUCT.
local ODD_EGGS = {
{ species = "PICHU", otId = 2048, experience = 125, level = 5, eggSteps = 20,
moves = { "THUNDERSHOCK", "CHARM", "DIZZY_PUNCH" },
pp = { 30, 20, 10 },
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 17, attack = 9, defense = 6, speed = 11,
specialAttack = 8, specialDefense = 8 } },
{ species = "PICHU", otId = 256, experience = 125, level = 5, eggSteps = 20,
moves = { "THUNDERSHOCK", "CHARM", "DIZZY_PUNCH" },
pp = { 30, 20, 10 },
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
stats = { hp = 17, attack = 9, defense = 7, speed = 12,
specialAttack = 9, specialDefense = 9 } },
{ species = "CLEFFA", otId = 4096, experience = 125, level = 5, eggSteps = 20,
moves = { "POUND", "CHARM", "DIZZY_PUNCH" },
pp = { 35, 20, 10 },
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 20, attack = 7, defense = 7, speed = 6,
specialAttack = 9, specialDefense = 10 } },
{ species = "CLEFFA", otId = 768, experience = 125, level = 5, eggSteps = 20,
moves = { "POUND", "CHARM", "DIZZY_PUNCH" },
pp = { 35, 20, 10 },
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
stats = { hp = 20, attack = 7, defense = 8, speed = 7,
specialAttack = 10, specialDefense = 11 } },
{ species = "IGGLYBUFF", otId = 4096, experience = 125, level = 5,
eggSteps = 20,
moves = { "SING", "CHARM", "DIZZY_PUNCH" },
pp = { 15, 20, 10 },
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 24, attack = 8, defense = 6, speed = 6,
specialAttack = 9, specialDefense = 7 } },
{ species = "IGGLYBUFF", otId = 768, experience = 125, level = 5,
eggSteps = 20,
moves = { "SING", "CHARM", "DIZZY_PUNCH" },
pp = { 15, 20, 10 },
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
stats = { hp = 24, attack = 8, defense = 7, speed = 7,
specialAttack = 10, specialDefense = 8 } },
{ species = "SMOOCHUM", otId = 3584, experience = 125, level = 5,
eggSteps = 20,
moves = { "POUND", "LICK", "DIZZY_PUNCH" },
pp = { 35, 30, 10 },
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 19, attack = 8, defense = 6, speed = 11,
specialAttack = 13, specialDefense = 11 } },
{ species = "SMOOCHUM", otId = 512, experience = 125, level = 5,
eggSteps = 20,
moves = { "POUND", "LICK", "DIZZY_PUNCH" },
pp = { 35, 30, 10 },
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
stats = { hp = 19, attack = 8, defense = 7, speed = 12,
specialAttack = 14, specialDefense = 12 } },
{ species = "MAGBY", otId = 2560, experience = 125, level = 5, eggSteps = 20,
moves = { "EMBER", "DIZZY_PUNCH" },
pp = { 25, 10 },
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 19, attack = 12, defense = 8, speed = 13,
specialAttack = 12, specialDefense = 10 } },
{ species = "MAGBY", otId = 512, experience = 125, level = 5, eggSteps = 20,
moves = { "EMBER", "DIZZY_PUNCH" },
pp = { 25, 10 },
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
stats = { hp = 19, attack = 12, defense = 9, speed = 14,
specialAttack = 13, specialDefense = 11 } },
{ species = "ELEKID", otId = 3072, experience = 125, level = 5, eggSteps = 20,
moves = { "QUICK_ATTACK", "LEER", "DIZZY_PUNCH" },
pp = { 30, 30, 10 },
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 19, attack = 11, defense = 8, speed = 14,
specialAttack = 11, specialDefense = 10 } },
{ species = "ELEKID", otId = 512, experience = 125, level = 5, eggSteps = 20,
moves = { "QUICK_ATTACK", "LEER", "DIZZY_PUNCH" },
pp = { 30, 30, 10 },
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
stats = { hp = 19, attack = 11, defense = 9, speed = 15,
specialAttack = 12, specialDefense = 11 } },
{ species = "TYROGUE", otId = 2560, experience = 125, level = 5,
eggSteps = 20,
moves = { "TACKLE", "DIZZY_PUNCH" },
pp = { 35, 10 },
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 18, attack = 8, defense = 8, speed = 8,
specialAttack = 8, specialDefense = 8 } },
{ species = "TYROGUE", otId = 256, experience = 125, level = 5, eggSteps = 20,
moves = { "TACKLE", "DIZZY_PUNCH" },
pp = { 35, 10 },
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
stats = { hp = 18, attack = 8, defense = 9, speed = 9,
specialAttack = 9, specialDefense = 9 } },
}
-- ../pokecrystal/engine/events/odd_egg.asm:93 `.Odd` is the OT NAME
-- (../pokecrystal/mobile/mobile_46.asm:7561); wOddEggName ("EGG") is the nickname.
local ODD_EGG_OT = "ODD"
local ODD_EGG_NICKNAME = "EGG"
local EGG_TICKET = "EGG_TICKET"
-- ../pokecrystal/engine/events/odd_egg.asm:5-38, one Random word against the
-- cumulative table; the $ffff break is :17.
local function oddEggIndex(roll)
for index = 1, #ODD_EGG_PROBABILITIES do
local probability = ODD_EGG_PROBABILITIES[index]
if probability >= 0xffff then return index end
if roll <= probability then return index end
end
return #ODD_EGG_PROBABILITIES
end
-- ../pokecrystal/engine/events/odd_egg.asm:40-48, NICKNAMED_MON_STRUCT_LENGTH
-- bytes copied verbatim.
local function buildOddEgg(data, row, save)
if not (data and row) then return nil end
local moves = {}
for index, id in ipairs(row.moves) do
local pp = row.pp[index] or 0
moves[index] = { id = id, pp = pp, maxPp = pp }
end
local dvs = {
attack = row.dvs.attack, defense = row.dvs.defense,
speed = row.dvs.speed, special = row.dvs.special,
}
local egg = Mon.new(data, row.species, row.level, {
dvs = dvs,
moves = moves,
-- ../pokecrystal/data/events/odd_eggs.asm:57 `bigdw 0 ; HP`
hp = 0,
nickname = ODD_EGG_NICKNAME,
})
if not egg then return nil end
egg.experience = row.experience
egg.stats = {
hp = row.stats.hp, attack = row.stats.attack,
defense = row.stats.defense, speed = row.stats.speed,
specialAttack = row.stats.specialAttack,
specialDefense = row.stats.specialDefense,
}
egg.maxHp = row.stats.hp
egg.hp = 0
-- ../pokecrystal/mobile/mobile_46.asm:7528 writes EGG into wPartySpecies while the struct
-- keeps the hatchling's own species.
egg.isEgg = true
-- ../pokecrystal/data/events/odd_eggs.asm:53 `db 20 ; Step cycles to hatch`,
-- the byte MON_HAPPINESS holds while the thing is an egg.
egg.eggSteps = row.eggSteps
egg.ot = ODD_EGG_OT
egg.otName = ODD_EGG_OT
egg.otId = row.otId
if save then Mon.stampOT(save, egg) end
return egg
end
-- ../pokecrystal/engine/events/odd_egg.asm:1. The party-full refusal is the
-- caller's (../pokecrystal/maps/DayCare.asm:31-32), not the routine's.
M.GiveOddEgg = function(vm)
local record = S.save(vm)
local data = S.data(vm)
if not (record and data) then return end
local party = record.party or {}
record.party = party
if #party >= Mon.PARTY_SIZE then return end
local row = ODD_EGGS[oddEggIndex(Specials.random(0x10000) - 1)]
local egg = buildOddEgg(data, row, record)
if not egg then return end
-- ../pokecrystal/engine/events/odd_egg.asm:50-57 TossItem on the EGG TICKET,
-- ahead of the party write.
local h = S.hooks(vm)
local ticket = h.itemIndex and h.itemIndex(EGG_TICKET)
if ticket and h.takeItem then h.takeItem(ticket, 1) end
party[#party + 1] = egg
end
-- ../pokecrystal/engine/events/unown_walls.asm:1, run by
-- ../pokecrystal/maps/RuinsOfAlphHoOhChamber.asm:10.
M.HoOhChamber = function(vm)
if not UnownWords.leadIsHoOh(S.party(vm)) then return end
UnownWords.openWall(vm.events, "HO_OH")
end
-- ../pokecrystal/engine/events/unown_walls.asm:13, run by
-- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:10; :25 CheckItem then :38
-- MON_ITEM.
M.OmanyteChamber = function(vm)
if UnownWords.wallOpened(vm.events, "OMANYTE") then return end
local h = S.hooks(vm)
local index = h.itemIndex and h.itemIndex(UnownWords.WATER_STONE)
local inPack = index and h.hasItem and h.hasItem(index)
if not inPack and not UnownWords.waterStoneSlot(S.party(vm)) then return end
UnownWords.openWall(vm.events, "OMANYTE")
end
-- ../pokecrystal/engine/events/celebi.asm:9; :296 CelebiEvent_SetBattleType is
-- the only state it leaves, and src/world/gen2/World.lua:6161 clears that.
M.CelebiShrineEvent = function(vm)
if vm.writeVarFn then vm.writeVarFn(VAR_BATTLETYPE, BATTLETYPE_CELEBI) end
vm.celebiArmed = true
end
-- ../pokecrystal/engine/events/celebi.asm:301 reads the bit
-- ../pokecrystal/engine/items/item_effects.asm:545 sets, gated on :542.
M.CheckCaughtCelebi = function(vm)
local caught = vm.celebiArmed == true and vm.battleOutcome == "caught"
vm.celebiArmed = nil
if caught then
local record = S.save(vm)
if record then
local Save = require("src.core.gen2.Save")
Save.crystalState(record).celebiCaught = true
end
end
S.answer(vm, caught and S.TRUE or S.FALSE)
end
return M
+29
View File
@@ -0,0 +1,29 @@
-- ../pokecrystal/data/events/special_pointers.asm:151 DisplayUnownWords, run by all four
-- chambers: ../pokecrystal/maps/RuinsOfAlphKabutoChamber.asm:123,
-- ../pokecrystal/maps/RuinsOfAlphHoOhChamber.asm:86, ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:86,
-- ../pokecrystal/maps/RuinsOfAlphAerodactylChamber.asm:85.
local Specials = require("src.script.gen2.Specials")
local UnownWords = require("src.world.gen2.UnownWords")
local S = Specials.shared
local M = {}
M.DisplayUnownWords = function(vm)
local h = S.hooks(vm)
local world = h.world
local game = world and world.game
if not (game and game.stack) then return end
local wall = UnownWords.wallFor(game.data, vm.scriptVar or 0)
if not wall then return end
S.block(vm, function(done)
local screen = UnownWords.new(game, {
wall = wall, world = world, onClose = function() done(true) end,
})
local ok = pcall(game.stack.push, game.stack, screen)
if not ok then done(false) end
end)
end
return M