G2 support

This commit is contained in:
bryanthaboi
2026-08-11 11:52:56 -04:00
parent 79ed37699e
commit ae6cac89e1
489 changed files with 226677 additions and 1798 deletions
+10 -3
View File
@@ -113,6 +113,8 @@ local function slimAudio(data)
bankOrder = audio.bankOrder,
waveBanks = audio.waveBanks,
noiseHeaders = audio.noiseHeaders,
generation = audio.generation,
drumkits = audio.drumkits,
}
end
@@ -435,13 +437,18 @@ end
-- a mono Source is spatialized by OpenAL at the listener position and spreads
-- over every output an interface has (#626). The siren itself is unchanged,
-- both channels carry the same sample.
-- PlayDanger (audio/engine.asm:531) counts one frame per call and resets with
-- `cp 30 / jr c, .noreset`, so the cycle is frames 0..29 and the buffer holds
-- exactly two of them. DangerSoundHigh goes in on the `and a / jr z, .begin`
-- frame 0 and DangerSoundLow on the `cp 16 / jr z, .halfway` frame 16, so the
-- high tone owns 0..15 and the low tone 16..29.
function ChipAudio.newLowHealthAlarm()
local samples = math.floor(SAMPLE_RATE * 62 / 60)
local samples = math.floor(SAMPLE_RATE * 60 / 60)
local data = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 2)
local phase = 0
for index = 0, samples - 1 do
local frame = math.floor(index * 60 / SAMPLE_RATE) % 31
local register = frame < 11 and 0x750 or 0x6EE
local frame = math.floor(index * 60 / SAMPLE_RATE) % 30
local register = frame < 16 and 0x750 or 0x6EE
local frequency = 131072 / (2048 - register)
phase = (phase + frequency / SAMPLE_RATE) % 1
local value = (phase < 0.5 and 1 or -1) * 0.25
+367 -3
View File
@@ -98,6 +98,15 @@ local PITCHES = {
0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23,
0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA,
}
-- Gen 2 FrequencyTable (audio/notes.asm): index 0 = rest, then C_..B_ twice
-- so transpose can walk into the next octave without an octave command.
local GEN2_FREQUENCY = {
0x0000,
0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23,
0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA,
0xFC16, 0xFC4E, 0xFC83, 0xFCB5, 0xFCE5, 0xFD11,
0xFD3B, 0xFD63, 0xFD89, 0xFDAC, 0xFDCD, 0xFDED,
}
-- LuaGB / DMG 8-step duty tables (index 0-3); stored on channels as that index
local WAVE_PATTERN_TABLES = {
[0] = {0, 0, 0, 0, 0, 0, 0, 1},
@@ -243,6 +252,9 @@ function Channel.new(engine, spec, options)
options = options or {}
local hardware = (spec.number - 1) % 4 + 1
local isSfxChannel = spec.number > 4
-- Default LR tracks match pokegold MonoTracks / StereoTracks ($11/$22/…).
local trackBit = bit.lshift(1, hardware - 1)
local tracks = bit.bor(bit.lshift(trackBit, 4), trackBit)
return setmetatable({
engine = engine,
bank = options.bank,
@@ -257,10 +269,18 @@ function Channel.new(engine, spec, options)
frequencyOffset = options.frequencyOffset or 0,
frameTicks = options.frameTicks or FRAME_TICKS,
speed = 12,
noteLength = 1, -- Gen 2 CHANNEL_NOTE_LENGTH (note_type)
durationModifier = 0, -- Gen 2 fractional-frame carry
volume = 12,
fade = 0,
duty = 2,
octave = 4,
transposition = 0, -- Gen 2: hi=octaves, lo=pitches
pitchOffset = 0, -- Gen 2 pitch_offset (signed word add to freq)
noiseKit = 0,
noiseSampling = false, -- Gen 2 toggle_noise
condition = 0, -- Gen 2 set_condition / sound_jump_if
tracks = tracks, -- Gen 2 CHANNEL_TRACKS (NR51 bits for this channel)
waveInstrument = 0,
waveLevel = 1,
perfectPitch = false,
@@ -299,6 +319,24 @@ function Channel:frequency(note, octave)
return bit.band(register + self.frequencyOffset, 0x7FF)
end
-- pokegold GetFrequency: FrequencyTable[pitch+transpose] with asr while
-- CHANNEL_OCTAVE (+ transpose hi) < 7, then optional pitch_offset.
function Channel:frequencyGen2(note, octave)
local trans = self.transposition or 0
local pitch = note + bit.band(trans, 0x0F)
local oct = (octave or self.octave) + bit.rshift(trans, 4)
local tableVal = GEN2_FREQUENCY[pitch + 1] or 0
local signed = tableVal - 0x10000
local shifts = 0
while oct < 7 do
shifts = shifts + 1
oct = oct + 1
end
local register = bit.band(bit.arshift(signed, shifts), 0x7FF)
register = bit.band(register + (self.pitchOffset or 0), 0x7FF)
return bit.band(register + self.frequencyOffset, 0x7FF)
end
function Channel:durationTicks(length)
local tempo = self.sfx and self.frameTicks or self.engine.tempo
local speed = self.sfx and (self.executeMusic and self.speed or 1)
@@ -306,6 +344,33 @@ function Channel:durationTicks(length)
return length * speed * tempo
end
-- Gen 2 SetNoteDuration (audio/engine.asm). Two eight-bit multiplies, and
-- BOTH of them throw the overflow away -- which is the whole character of the
-- routine and the reason it cannot be written as one product:
--
-- low = LOW((length + 1) * NoteLength) `ld a, l` after .Multiply
-- product = tempo * low + DurationModifier 16-bit, wraps
-- frames = HIGH(product) `ld [hl], d`, one byte
-- modifier= LOW(product) carries into the next note
--
-- Keeping the full product instead is what made a cry run for seconds: a cry
-- sets CHANNEL_TEMPO to its length word (up to 576), so tempo * low routinely
-- runs past 16 bits and the truncation is load bearing rather than incidental.
--
-- NoteLength defaults to 1 and tempo to $100 -- LoadChannel's own defaults --
-- so a channel that never issues note_type or tempo still times correctly.
-- After toggle_sfx (executeMusic), fanfares like Sfx_CaughtMon use the
-- channel's tempo command, not the SFX frameTicks seed.
function Channel:durationTicksGen2(length)
local tempo = (self.sfx and not self.executeMusic)
and self.frameTicks or self.engine.tempo
local low = bit.band((length + 1) * (self.noteLength or 1), 0xFF)
local product = bit.band(tempo * low + (self.durationModifier or 0), 0xFFFF)
self.durationModifier = bit.band(product, 0xFF)
local frames = math.floor(product / 256)
return frames * FRAME_TICKS
end
function Channel:timedEvent(event, ticks)
local first = snapTicks(self.timeTicks)
self.timeTicks = self.timeTicks + ticks
@@ -318,6 +383,11 @@ end
function Channel:pan()
local mask = bit.lshift(1, self.hardware - 1)
if self.engine.generation == 2 then
local tracks = self.tracks or 0xFF
return bit.band(bit.rshift(tracks, 4), mask) ~= 0,
bit.band(tracks, mask) ~= 0
end
return bit.band(bit.rshift(self.engine.pan, 4), mask) ~= 0,
bit.band(self.engine.pan, mask) ~= 0
end
@@ -365,9 +435,15 @@ end
function Channel:drumEvent(ticks, instrument)
local panLeft, panRight = self:pan()
local drum
if self.engine.generation == 2 then
drum = self.engine:drumInstrumentGen2(self.noiseKit or 0, instrument)
else
drum = self.engine:noiseInstrument(instrument)
end
return self:timedEvent({
noise = true,
drum = self.engine:noiseInstrument(instrument),
drum = drum,
panLeft = panLeft,
panRight = panRight,
}, ticks)
@@ -378,6 +454,9 @@ function Channel:silenceEvent(ticks)
end
function Channel:nextEvent()
if self.engine.generation == 2 then
return self:nextEventGen2()
end
if self.ended then return nil end
for _ = 1, 100000 do
local commandAddress = self.address
@@ -526,6 +605,222 @@ function Channel:nextEvent()
return nil
end
-- Gen 2 music bytecode (pokegold macros/scripts/audio.asm, FIRST_MUSIC_CMD=$d0).
-- Notes share the Gen 1 packing; rest is pitch 0. Call/loop opcodes are
-- swapped vs Gen 1 ($fe call, $fd loop) and $fc is sound_jump.
function Channel:nextEventGen2()
if self.ended then return nil end
for _ = 1, 100000 do
local commandAddress = self.address
local command = self:byte()
if command < 0xD0 and self.sfx and not self.executeMusic then
-- ParseSFXOrCry. On a channel carrying SOUND_SFX or SOUND_CRY a byte
-- under $d0 is not a packed note at all: it is a `square_note` /
-- `noise_note` row, and SetNoteDuration is handed the WHOLE byte rather
-- than its low nibble. What follows is the volume envelope and then
-- the raw frequency register -- two bytes on a tone channel, one on
-- noise, where it is the polynomial counter instead.
--
-- Parsing these as music notes is what made every Gold cry and sound
-- effect wrong: the envelope byte was read as a second note and the
-- frequency low byte ($d8 for 1752, say) as a note_type command that
-- then ate the next two bytes.
local ticks = self:durationTicksGen2(command)
local packed = self:byte()
local volume = bit.rshift(packed, 4)
local fade = fadeValue(bit.band(packed, 0x0F))
if self.noise then
local parameter = bit.band(self:byte() + self.frequencyOffset, 0xFF)
return self:noiseEvent(ticks, volume, fade, parameter)
end
-- CHANNEL_PITCH_OFFSET is wCryPitch for a cry and the SFX pitch
-- modifier otherwise; both land in frequencyOffset. The add is 16-bit
-- on hardware and only 11 bits reach the register, so a negative pitch
-- stored as its unsigned word still comes out right.
local register = bit.band(self:word() + self.frequencyOffset, 0x7FF)
return self:tone(ticks, register, volume, fade)
elseif command < 0xD0 then
local note = bit.rshift(command, 4)
local length = bit.band(command, 0x0F)
local ticks = self:durationTicksGen2(length)
if note == 0 then
return self:silenceEvent(ticks)
end
if self.noise and self.noiseSampling then
return self:drumEvent(ticks, note)
end
if self.noise then
return self:silenceEvent(ticks)
end
return self:tone(ticks, self:frequencyGen2(note))
elseif command >= 0xD0 and command <= 0xD7 then
-- octave 8 → $d0 (stored 0); octave 1 → $d7 (stored 7)
self.octave = bit.band(command, 7)
elseif command == 0xD8 then -- note_type / drum_speed
self.noteLength = self:byte()
if not self.noise then
local packed = self:byte()
if self.wave then
self.waveLevel = WAVE_LEVEL[bit.band(bit.rshift(packed, 4), 3)]
self.waveInstrument = bit.band(packed, 0x0F)
else
self.volume = bit.rshift(packed, 4)
self.fade = fadeValue(bit.band(packed, 0x0F))
end
end
elseif command == 0xD9 then -- transpose
self.transposition = self:byte()
elseif command == 0xDA then -- tempo (big-endian)
local high, low = self:byte(), self:byte()
if not self.engine.tempoLocked then
self.engine.tempo = high * 0x100 + low
end
self.durationModifier = 0
elseif command == 0xDB then -- duty_cycle
self.duty = bit.band(self:byte(), 3)
elseif command == 0xDC then -- volume_envelope
local packed = self:byte()
if self.wave then
self.waveLevel = WAVE_LEVEL[bit.band(bit.rshift(packed, 4), 3)]
self.waveInstrument = bit.band(packed, 0x0F)
else
self.volume = bit.rshift(packed, 4)
self.fade = fadeValue(bit.band(packed, 0x0F))
end
elseif command == 0xDD then -- pitch_sweep (SFX; keep for completeness)
local packed = self:byte()
self.sweep = {
pace = bit.band(bit.rshift(packed, 4), 7),
subtract = bit.band(packed, 8) ~= 0,
shift = bit.band(packed, 7),
}
elseif command == 0xDE then -- duty_cycle_pattern
local packed = self:byte()
self.duty = {
bit.band(bit.rshift(packed, 6), 3),
bit.band(bit.rshift(packed, 4), 3),
bit.band(bit.rshift(packed, 2), 3),
bit.band(packed, 3),
}
elseif command == 0xDF then -- toggle_sfx
self.executeMusic = not self.executeMusic
elseif command == 0xE0 then -- pitch_slide
local length, packed = self:byte(), self:byte()
local octave = bit.rshift(packed, 4)
self.pendingSlide = {
length = length,
target = self:frequencyGen2(bit.band(packed, 0x0F), octave),
}
elseif command == 0xE1 then -- vibrato
local delay, packed = self:byte(), self:byte()
local depth = bit.rshift(packed, 4)
if depth == 0 then
self.vibrato = nil
else
self.vibrato = {
delay = delay,
above = bit.rshift(depth, 1) + bit.band(depth, 1),
below = bit.rshift(depth, 1),
rate = bit.band(packed, 0x0F),
}
end
elseif command == 0xE2 then -- unknownmusic0xe2
self:byte()
elseif command == 0xE3 then -- toggle_noise
if self.noiseSampling then
self.noiseSampling = false
else
self.noiseSampling = true
self.noiseKit = self:byte()
end
elseif command == 0xE4 then -- force_stereo_panning
local packed = self:byte()
local mask = bit.lshift(1, self.hardware - 1)
local default = bit.bor(bit.lshift(mask, 4), mask)
self.tracks = bit.band(packed, default)
elseif command == 0xE5 then -- volume (global master; ignored for mix)
self:byte()
elseif command == 0xE6 then -- pitch_offset (big-endian)
local high, low = self:byte(), self:byte()
local value = high * 0x100 + low
if value >= 0x8000 then value = value - 0x10000 end
self.pitchOffset = value
elseif command == 0xE7 or command == 0xE8 then -- unused
self:byte()
elseif command == 0xE9 then -- tempo_relative
local adj = self:byte()
if adj >= 0x80 then adj = adj - 0x100 end
self.engine.tempo = bit.band(self.engine.tempo + adj, 0xFFFF)
elseif command == 0xEA then -- restart_channel
self.address = self:word()
elseif command == 0xEB then -- new_song (unused in music streams)
self:word()
elseif command == 0xEC or command == 0xED then -- sfx priority on/off
-- no-op for the PCM renderer
elseif command == 0xEE then -- unknownmusic0xee
self:word()
elseif command == 0xEF then -- stereo_panning (honor always; options.stereo)
local packed = self:byte()
local mask = bit.lshift(1, self.hardware - 1)
local default = bit.bor(bit.lshift(mask, 4), mask)
self.tracks = bit.band(packed, default)
elseif command == 0xF0 then -- sfx_toggle_noise
if self.noiseSampling then
self.noiseSampling = false
else
self.noiseSampling = true
self.noiseKit = self:byte()
end
elseif command >= 0xF1 and command <= 0xF9 then
-- music0xf1-f9 / unused: no params
elseif command == 0xFA then -- set_condition
self.condition = self:byte()
elseif command == 0xFB then -- sound_jump_if
local want, target = self:byte(), self:word()
if self.condition == want then self.address = target end
elseif command == 0xFC then -- sound_jump
self.address = self:word()
elseif command == 0xFD then -- sound_loop (Gen 2; Gen 1 used $fe)
local count, target = self:byte(), self:word()
if count == 0 then
if self.allowLoops then
self.address = target
else
self.ended = true
return nil
end
else
local remaining = self.loopCounts[commandAddress]
if remaining == nil then remaining = count end
remaining = remaining - 1
if remaining > 0 then
self.loopCounts[commandAddress] = remaining
self.address = target
else
self.loopCounts[commandAddress] = nil
end
end
elseif command == 0xFE then -- sound_call
self.callStack[#self.callStack + 1] = self.address + 2
self.address = self:word()
elseif command == 0xFF then -- sound_ret
local returnAddress = table.remove(self.callStack)
if returnAddress then
self.address = returnAddress
else
self.ended = true
return nil
end
else
self.ended = true
return nil
end
end
self.ended = true
return nil
end
local function envelopeVolume(volume, fade, elapsed)
if fade == 0 then return volume end
local steps = math.floor(elapsed / (math.abs(fade) / 64))
@@ -778,6 +1073,41 @@ function Engine:noiseInstrument(number)
return segments
end
-- Gen 2 Drumkits → kit pointer → instrument noise_note script (ReadNoiseSample).
function Engine:drumInstrumentGen2(kit, pitch)
local key = kit * 256 + pitch
local cached = self.noiseInstruments[key]
if cached then return cached end
local segments = {}
local spec = self.drumkits
if spec and pitch and pitch > 0 then
local kitAddr = romWord(self.banks, spec.bank, spec.address + kit * 2)
local instrAddr = romWord(self.banks, spec.bank, kitAddr + pitch * 2)
local address = instrAddr
local ticks = 0
for _ = 1, 64 do
local command = romByte(self.banks, spec.bank, address)
address = address + 1
if command == 0xFF then break end
local packed = romByte(self.banks, spec.bank, address)
local parameter = romByte(self.banks, spec.bank, address + 1)
address = address + 2
-- ReadNoiseSample: delay = (length & $f) + 1 frames
local duration = (bit.band(command, 0x0F) + 1) * FRAME_TICKS
segments[#segments + 1] = {
startSample = snapTicks(ticks),
endSample = snapTicks(ticks + duration),
volume = bit.rshift(packed, 4),
fade = fadeValue(bit.band(packed, 0x0F)),
parameter = parameter,
}
ticks = ticks + duration
end
end
self.noiseInstruments[key] = segments
return segments
end
local function readWaves(banks, audio, engineNumber)
local spec = audio.waveBanks[tostring(engineNumber)]
local waves = {}
@@ -802,6 +1132,24 @@ local function readWaves(banks, audio, engineNumber)
return waves
end
-- Gen 2 WaveSamples: 10 patterns × 16 bytes (instruments 0-9).
local function readWavesGen2(banks, audio)
local spec = audio.waveBanks and audio.waveBanks["1"]
if not spec then return {} end
local waves = {}
for wave = 0, 9 do
local values = {}
for byteIndex = 0, 15 do
local packed = romByte(
banks, spec.bank, spec.address + wave * 16 + byteIndex)
values[#values + 1] = (bit.rshift(packed, 4) - 8) / 8
values[#values + 1] = (bit.band(packed, 0x0F) - 8) / 8
end
waves[#waves + 1] = values
end
return waves
end
-- def-local waves are authored either as raw 0-15 nibbles (the ROM's own
-- units) or as the -1..1 samples readWaves produces; the synth wants the
-- latter (LuaGB: (nibble - 8) / 8)
@@ -828,10 +1176,18 @@ function Engine.new(data, header, options)
-- may supply its own waves/drums, falling back to a ROM engine's tables
local chip = header.chip
local banks = engineBanks(data, chip)
local engineNumber = chip and (chip.engine or 1) or header.engine
local generation = header.generation or audio.generation or 1
local engineNumber = chip and (chip.engine or 1) or header.engine or 1
local waves
if chip and chip.waves then
waves = normalizeWaves(chip.waves)
elseif generation == 2 then
if chip then
local ok, romWaves = pcall(readWavesGen2, banks, audio)
waves = ok and romWaves or {}
else
waves = readWavesGen2(banks, audio)
end
elseif chip then
local ok, romWaves = pcall(readWaves, banks, audio, engineNumber)
waves = ok and romWaves or {}
@@ -840,11 +1196,13 @@ function Engine.new(data, header, options)
end
local engine = setmetatable({
banks = banks,
generation = generation,
tempo = 0x100,
pan = 0xFF,
waves = waves,
noiseHeaders = audio.noiseHeaders
and audio.noiseHeaders[tostring(engineNumber)] or {},
drumkits = audio.drumkits,
customDrums = chip and chip.drums or nil,
noiseInstruments = {},
channels = {},
@@ -864,7 +1222,13 @@ function Engine.new(data, header, options)
if hardware == 4 then
frameTicks = FRAME_TICKS
elseif options.cryLength then
frameTicks = 0x80 + options.cryLength
-- Gen 1: Audio_SetSfxTempo builds a 9-bit tempo out of $80 plus the
-- cry's length BYTE. Gen 2: _PlayCry writes wCryLength -- a full word,
-- and its own comment says "Tempo is effectively length" -- straight
-- into CHANNEL_TEMPO, with no $80 base. Adding one anyway stretched
-- every Gold cry by a third on top of the parse bug above.
frameTicks = generation == 2 and options.cryLength
or (0x80 + options.cryLength)
end
engine.channels[#engine.channels + 1] = Channel.new(engine, spec, {
bank = chip and 0 or header.bank,
+1975
View File
File diff suppressed because it is too large Load Diff
+37 -7
View File
@@ -1,14 +1,14 @@
-- Which Gen-1 game this process is running: Red (the historical default),
-- Blue, or Yellow. One source of truth for everything that differs by
-- Which game this process is running: Red (the historical default), Blue,
-- Yellow, or Gold. One source of truth for everything that differs by
-- version -- the accepted ROM hash, the import manifest, where the
-- extracted cache lives, and the save-file suffix -- so the importer,
-- cache mount, SaveData, title screen and palette all agree.
--
-- Red keeps the un-suffixed save paths it always used (save.lua) so existing
-- saves are untouched, but its extracted cache lives under red/ like Blue and
-- Yellow (issue #899); a legacy root cache is moved into red/ once by
-- CacheFs.migrateLegacyRedCache. All three versions can be imported and
-- played side by side.
-- saves are untouched, but its extracted cache lives under red/ like Blue,
-- Yellow, and Gold (issue #899); a legacy root cache is moved into red/ once
-- by CacheFs.migrateLegacyRedCache. All supported versions can be imported
-- and selected side by side. Gold is Gen 2 (see docs/gold-phase1.md).
--
-- Zero requires, so it loads during love.conf and under plain Lua for tools
-- and tests. The active version is a process-global set once at boot from
@@ -47,10 +47,27 @@ GameVersion.VERSIONS = {
cachePrefix = "yellow/", -- yellow/data/generated, yellow/assets/generated
saveSuffix = "_yellow", -- save_yellow.lua / .bak / .tmp
},
-- Gen 2, Phase 1 (docs/gold-phase1.md): a 2 MiB cart, twice the size of
-- the Gen 1 ROMs above, imported through RomExtractorGen2 instead of
-- RomExtractor.
gold = {
id = "gold",
label = "Gold",
displayName = "Pokemon Gold",
-- Still Gen 2 Phase work; the launcher panel / Play button say Beta so
-- players do not treat it like the shipped Gen 1 columns.
launcherName = "Gold (Beta)",
sha1 = "d8b8a3600a465308c9953dfa04f0081c05bdcb94",
manifest = "tools/rom_manifest_gold.json",
cachePrefix = "gold/", -- gold/data/generated, gold/assets/generated
saveSuffix = "_gold", -- save_gold.lua / .bak / .tmp
-- The only row that carries one; absent reads as 1 (GameVersion.generation)
generation = 2,
},
}
-- Launcher column order.
GameVersion.ORDER = { "red", "blue", "yellow" }
GameVersion.ORDER = { "red", "blue", "yellow", "gold" }
GameVersion.current = "red"
@@ -71,6 +88,19 @@ function GameVersion.isYellow()
return GameVersion.current == "yellow"
end
function GameVersion.isGold()
return GameVersion.current == "gold"
end
-- 1 or 2. The mod API is shared across both (same hook names, same registry
-- names), so the pieces that must branch -- the manifest gen2compat gate, the
-- registry target routing, the mod.world arm -- ask this rather than each
-- spelling out its own isGold() test. A third generation adds a `generation`
-- to its VERSIONS row and nothing else changes shape.
function GameVersion.generation(id)
return GameVersion.info(id).generation or 1
end
-- Metadata for a version id, defaulting to the active one.
function GameVersion.info(id)
return GameVersion.VERSIONS[id or GameVersion.current]
+19
View File
@@ -32,6 +32,25 @@ local STICK_OFF = 0.3
-- Raw joystick defaults + NX overrides live in src/core/GamepadMap.lua
-- (see RAW_BUTTON_BINDINGS / NX_RAW_BUTTON_BINDINGS and #620 / #632).
--
-- SELECT ON A PLAYSTATION PAD, checked rather than assumed. There is no
-- button called "select" in SDL's game-controller vocabulary: the small
-- left-hand menu button is `back` on every family, and GamepadMap's
-- DEFAULT_GAMEPAD_BINDINGS maps it to GB SELECT. The DualSense's CREATE
-- button (and the DualShock 4's SHARE) is that button -- the controller
-- database LOVE 11.5 ships spells the DualSense row
-- "PS5 Controller,a:b1,b:b2,back:b8,...,misc1:b13,start:b9" -- so a
-- recognized pad delivers it here as gamepadpressed(_, "back") and needs no
-- entry of its own. What it also has, and what LOVE 11.x has no name for at
-- all, is the TOUCHPAD click (SDL_CONTROLLER_BUTTON_TOUCHPAD) and the mute
-- key (`misc1`): neither reaches love.gamepadpressed, so neither can be bound,
-- and a player reaching for the touchpad expecting SELECT will find nothing.
-- Not a mapping this file can add -- the event never arrives.
--
-- An unrecognized PlayStation pad falls to the raw path instead, where SHARE /
-- CREATE is generic-HID button 9 and RAW_BUTTON_BINDINGS[9] is already
-- "select". Both roads reach SELECT; the one road that did not was Gold's,
-- where src/core/Game2.lua used to answer `back` with love.event.quit().
local HAT_DIRECTIONS = {
u = { "up" }, d = { "down" }, l = { "left" }, r = { "right" },
+2
View File
@@ -2,6 +2,7 @@
--
-- love . --game=red -- boot Red
-- love . --game=yellow --slot=2 -- boot Yellow on save slot 2
-- love . --game=gold -- boot Gold (src/core/Game2.lua)
-- love . --game=red --launcher -- open the launcher anyway (a shortcut
-- the player wants to edit)
-- POKEPORT_GAME=blue love . -- same, for launchers that only pass env
@@ -37,6 +38,7 @@ local function normalizeVersion(v)
r = "red", red = "red",
b = "blue", blue = "blue",
y = "yellow", yellow = "yellow",
g = "gold", gold = "gold",
}
v = alias[v] or v
if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end
+20
View File
@@ -413,6 +413,18 @@ function Music.oneShotPlaying()
return state.pendingRestore == true
end
-- The label of the song that is current, or nil. A read-only window on the
-- state, for drivers and tests that need to assert what is playing.
function Music.current()
return state.current
end
-- The remembered map song (wMapMusic), the same read-only window: what a
-- battle's restore will replay. setMapSong below is the write half.
function Music.mapSong()
return state.mapSong
end
function Music.restoreMap(data)
state.current = nil
state.pendingRestore = nil
@@ -420,6 +432,14 @@ function Music.restoreMap(data)
if play then Music.play(data, play, nil, { reason = "map" }) end
end
-- Overwrite the remembered map song without playing anything: the wMapMusic
-- write in pokegold engine/pokegear/pokegear.asm RadioMusicRestartDE. A radio
-- station's song becomes the map music itself, so a battle's restoreMap brings
-- the STATION back and only the next playMap (a map change) replaces it.
function Music.setMapSong(song)
state.mapSong = song
end
-- 0-7 music volume (0 mutes), applied to the playing song and the
-- queued loop body as well as everything played later
function Music.setVolumeLevel(level)
+144 -1
View File
@@ -295,6 +295,15 @@ function SaveData.defaultOptions()
-- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default.
mods = {},
-- Mods the player forced past the target gate (Loader:_gateGeneration).
-- modsGen2[id][version] = true, one answer per game; a bare `true` is the
-- pre-per-game shape and means the Gen 2 games only (see modForced).
modsGen2 = {},
-- Per-game enablement: modsByVersion[version][id] answers for that game
-- only and falls through to the shared mods[id] above when absent, so an
-- options.lua written before this key keeps its exact meaning. Read and
-- written through SaveData.modEnabled / SaveData.setModEnabled.
modsByVersion = {},
-- Named setups the player can switch between (#593; src/mods/ModProfile.lua
-- owns the shape, src/mods/ManagerState.lua the UI): each row is
-- { name, enabled = {id=bool}, options = {id={k=v}}, slots = {version=slotId} }.
@@ -541,6 +550,127 @@ function SaveData.loadOptions(fs)
return SaveData.mergeOptions(data)
end
-- ------- per-game mod enablement
--
-- One installed mod, one id, one enable flag per game that wants to differ.
-- options.mods is the shared answer every version used to get; the overlay
-- only holds the games the player actually chose for, so a mod set can differ
-- between Red and Gold without either one owning the other's flags.
-- Whether a per-game answer is honoured at boot. The loader reads the enable
-- flags once, before any entry chunk (src/mods/Loader.lua _loadState), so this
-- flips on with that read and not before: until then every writer keeps to the
-- shared flag and no surface promises what the boot does not do.
SaveData.PER_VERSION_MODS = false
-- The version a write should be scoped to: the game asked for once per-game
-- flags are live, nil (the shared flag) while they are only a preview.
function SaveData.modScope(version)
if SaveData.PER_VERSION_MODS then return version end
return nil
end
-- true/false as chosen for `version`, else the shared flag, else nil -- the
-- caller owns the default (the loader enables, the launcher keeps
-- experimental mods off until asked).
function SaveData.modEnabled(options, id, version)
local byVersion = options and options.modsByVersion
local bucket = version and type(byVersion) == "table" and byVersion[version]
if type(bucket) == "table" and type(bucket[id]) == "boolean" then
return bucket[id]
end
local shared = options and options.mods
if type(shared) == "table" and type(shared[id]) == "boolean" then
return shared[id]
end
return nil
end
-- Write the choice for one game, or the shared flag when version is nil. A
-- per-game entry that agrees with the shared flag is dropped rather than
-- stored, so the overlay stays the list of deliberate differences.
function SaveData.setModEnabled(options, id, enabled, version)
if type(options) ~= "table" or type(id) ~= "string" or id == "" then
return options
end
enabled = enabled and true or false
if not version then
options.mods = options.mods or {}
options.mods[id] = enabled
return options
end
options.modsByVersion = options.modsByVersion or {}
local bucket = options.modsByVersion[version] or {}
options.modsByVersion[version] = bucket
-- no shared flag reads as enabled, the same default the loader applies to a
-- missing entry, so a fresh install never fills the overlay with agreement
local shared = options.mods and options.mods[id]
if type(shared) ~= "boolean" then shared = true end
if shared == enabled then
bucket[id] = nil
else
bucket[id] = enabled
end
return options
end
-- ------- the player's target override
--
-- The manifest's `games` is the AUTHOR's claim and the loader enforces it
-- (Loader:_gateGeneration); this is the player's per-game override of that
-- claim. Scoped by version, because "run it on Gold anyway" is not an answer
-- about Red: a version-blind flag forced a mod past a gate on a game its
-- owner was never asked about.
-- A pre-per-game `true` could only ever take effect on a Gen 2 boot (the gate
-- returned early on Gen 1), so that is exactly what it is read as here.
local function forcedGenerations(entry)
return entry == true and 2 or nil
end
function SaveData.modForced(options, id, version, generation)
local entry = type(options) == "table" and type(options.modsGen2) == "table"
and options.modsGen2[id]
if entry == nil or entry == false then return false end
local gen = generation or (version and GameVersion.generation(version))
local legacy = forcedGenerations(entry)
if legacy then return legacy == gen end
if type(entry) ~= "table" then return false end
if version then return entry[version] == true end
-- no version, only a generation: a harness seam, so ask whether ANY game of
-- that generation was forced rather than inventing a game
for id2, on in pairs(entry) do
if on == true and GameVersion.generation(id2) == gen then return true end
end
return false
end
-- Write the override for one game. Without a version there is no game to
-- answer for, so this writes nothing rather than guessing (the caller keeps
-- the choice in memory for this boot and says so).
function SaveData.setModForced(options, id, forced, version)
if type(options) ~= "table" or type(id) ~= "string" or id == "" then
return false
end
if not (version and GameVersion.VERSIONS[version]) then return false end
options.modsGen2 = options.modsGen2 or {}
local entry = options.modsGen2[id]
if type(entry) ~= "table" then
-- migrate the legacy flag in place, keeping the games it already covered
local expanded = {}
if forcedGenerations(entry) then
for _, other in ipairs(GameVersion.ORDER) do
if GameVersion.generation(other) == 2 then expanded[other] = true end
end
end
entry = expanded
options.modsGen2[id] = entry
end
entry[version] = forced and true or nil
if next(entry) == nil then options.modsGen2[id] = nil end
return true
end
-- ------- save slots
-- A version's playthroughs live in numbered slots under saves/<version>/;
@@ -677,7 +807,20 @@ function SaveData.slotSummary(save)
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do
dexCount = dexCount + 1
end
local t = math.floor(save.playTime or 0)
-- playTime is a plain seconds count in a Gen 1 save but a
-- { hours, minutes, seconds, frames } table in a Gen 2 (Gold) save, matching
-- the cart's wGameTime* bytes. The launcher calls slotSummary on EVERY
-- version's slot, so this has to read both shapes or the whole launcher
-- crashes the moment a Gold save exists (math.floor on the table).
local pt = save.playTime
local t
if type(pt) == "table" then
t = (tonumber(pt.hours) or 0) * 3600
+ (tonumber(pt.minutes) or 0) * 60
+ (tonumber(pt.seconds) or 0)
else
t = math.floor(tonumber(pt) or 0)
end
local timeText = ("%d:%02d"):format(math.floor(t / 3600),
math.floor(t / 60) % 60)
return name, {
+209 -9
View File
@@ -173,10 +173,64 @@ local function playPath(data, key, def, pitch, tempo)
return src
end
-- A Gen 2 sfx header declares how many of the four sfx channels it wants
-- (`channel_count N` in audio/sfx.asm), and sfx channel N takes hardware
-- channel N over from the music channel with the same number for as long as
-- it sounds. So a FOUR-channel sfx silences the song outright -- that is what
-- the cart does with every jingle: Sfx_RegisterPhoneNumber, Sfx_GetTm,
-- Sfx_GetBadge, Sfx_GetEgg, Sfx_Item, Sfx_CaughtMon, the eight dex fanfares.
-- The port's fanfare table was six hardcoded names, so the phone-number jingle
-- (and a dozen others) played OVER the music instead of replacing it.
--
-- Three-channel sfx are NOT ducked even though they too silence three quarters
-- of the song: most of them are battle move sounds (Psychic, Hyper Beam, Surf)
-- that fire several times a second, and pausing/resuming the song under each
-- one would stutter far worse than letting them overlay. The handful of
-- three-channel JINGLES are named below instead.
local GEN2_JINGLES = {
Sfx_Fanfare = true, Sfx_Fanfare2 = true,
Sfx_3rdPlace = true, Sfx_TrainArrived = true,
}
local FULL_BAND = 4
local channelCounts = {} -- per sfx name; the header read is not free
local function claimsEveryChannel(data, name, def)
if type(def) ~= "table" or not def.address then return false end
-- Gen 2 only. Gen 1's fanfare set is already listed by name and its sfx
-- headers count channels differently; widening the rule there would change
-- Red/Blue behaviour for no reported reason.
if def.generation ~= 2 then return false end
local known = channelCounts[name]
if known == nil then
local ok, channels = pcall(
require("src.core.ChipSynth").effectChannels, data, def)
-- effectChannels answers nil for "not knowable HERE" -- a file def, or the
-- program banks not readable yet (src/core/ChipSynth.lua effectChannels).
-- That is a "not yet", not a channel count: memoizing it as zero would
-- stamp a four-channel jingle as non-ducking for the rest of the session,
-- so it plays over the map music until the next launch. Only a header
-- that actually read is cached; a failed read is retried on the next play.
if not (ok and channels) then return false end
known = #channels
channelCounts[name] = known
end
return known >= FULL_BAND
end
local function ducks(data, name, def)
if type(def) == "table" and def.fanfare then return true end
local fanfares = data.audio and data.audio.fanfares or FANFARES
return fanfares[name] and true or false
if fanfares[name] then return true end
if GEN2_JINGLES[name] then return true end
return claimsEveryChannel(data, name, def)
end
-- Does playing this sfx stop the song? Exposed so a test can assert the rule
-- without an audio device.
function Sound.ducksMusic(data, name)
local sfx = data and data.audio and data.audio.sfx
name = Sound.resolve(data, name)
return ducks(data or {}, name, sfx and sfx[name])
end
local function played(kind, name, species)
@@ -184,12 +238,125 @@ local function played(kind, name, species)
Runtime.emit("sound.played", { kind = kind, name = name, species = species })
end
-- returns the started source (nil headless, or when the def failed to load)
-- so callers that block on a fanfare like the original's
-- PlaySoundWaitForCurrent -> WaitForSoundToFinish can poll it
function Sound.play(data, name)
local sfx = data.audio and data.audio.sfx
local def = sfx and sfx[name]
-- The shared UI names its sounds the way pokered does; Gen 2's sfx table is
-- keyed by pokegold's own labels, so a Gold session asking for "Press_AB"
-- finds nothing and the menu goes silent -- which is exactly what happened to
-- the A-press beep on every Gold dialogue. Only the shared modules a Gold
-- session actually enters need a row here, and today that is src/render/
-- TextBox.lua and src/ui/ChoiceBox.lua, both playing "Press_AB": the cart
-- sounds SFX_READ_TEXT_2 at both of those moments (home/joypad.asm
-- PromptButton for the textbox wait, home/menu.asm PlayClickSFX for a menu
-- pick). Every other shared player of a pokered sfx name sits in a module
-- Gold replaces under src/world/gen2 or src/ui/gen2, and those name their
-- sounds in pokegold's labels directly. So a row belongs here only once a
-- shared module is reachable from Gold, and its target is whatever the cart
-- plays at that same moment -- not the nearest-sounding Gen 2 label.
Sound.GEN2_ALIASES = {
Press_AB = "Sfx_ReadText2",
}
-- The hop Sound.resolve took for a raw name, so the argument-only entry points
-- (stop, isPlaying) can reach a source Sound.play cached under the resolved
-- key without a data table of their own.
local aliased = {}
function Sound.resolve(data, name)
local sfx = data and data.audio and data.audio.sfx
if not sfx then return name end
if sfx[name] then return name end
local alias = Sound.GEN2_ALIASES[name]
if alias and sfx[alias] then
aliased[name] = alias
return alias
end
return name
end
-- the source a raw name plays through, whichever key it ended up cached under
local function cached(name)
local src = cache[name]
if src == nil then
local key = aliased[name]
if key then src = cache[key] end
end
return src
end
-- Gen 2's overworld/menu entry point is a PRIORITY GATE, not a bare play
-- (home/audio.asm PlaySFX). It asks CheckSFX whether any of the four sfx
-- channels is still sounding, and when one is it compares the id that owns
-- them: `ld a, [wCurSFX] / cp e / jr c, .done` DROPS the new sound outright
-- while the playing id is numerically lower (constants/sfx_constants.asm
-- orders the table highest priority first). Only an id at or below wCurSFX
-- falls through, and _PlaySFX turns off and re-zeroes ch5-ch8 before it loads
-- the new header (audio/engine.asm _PlaySFX), cutting the old sound dead.
-- Either way sfx NEVER layer here. SproutTower3FRivalScene is the plain
-- case: `playsound SFX_TACKLE` ($41) then `playsound SFX_ELEVATOR` ($6e) one
-- command later, with the two-note tackle still sounding, so the cart never
-- plays the elevator rumble at all -- the pillar sways to the thud alone.
--
-- Battle ANIMATION sounds are a different entry point and must not come
-- through here: anim_sound reaches PlayStereoSFX (engine/battle_anims/
-- anim_commands.asm), which has no gate at all and, with stereo on, does not
-- even clear the channels another sfx holds. That is Sound.playStereo below.
local sfxIds -- { label -> SFX_* id }, derived from data.audio.sfxOrder
local curSfx -- { src, id } of the last gated sfx that started, i.e. wCurSFX
local function sfxIdFor(data, name)
local order = data and data.audio and data.audio.sfxOrder
if not order then return nil end
if not sfxIds then
sfxIds = {}
-- sfxOrder is audio/sfx_pointers.asm in table order, so id = index - 1
-- (RomExtractorGen2 extractAudio writes it from constants.sfxOrder).
for index, label in ipairs(order) do sfxIds[label] = index - 1 end
end
return sfxIds[name]
end
-- Would PlaySFX start this sound now? Answers false for `jr c, .done`, which
-- the caller honours by dropping the request whole: a discarded sfx neither
-- sounds nor ducks the music. The second return is the id to remember as
-- wCurSFX once the sound actually starts.
local function sfxPriorityGate(data, name, def)
-- Gen 1 keeps today's behaviour: pokered's PlaySound arbitrates by channel
-- rather than by a single wCurSFX, and Sound.playMove already ports that.
if type(def) ~= "table" or def.generation ~= 2 then return true end
local id = sfxIdFor(data, name)
if not id then return true end
if curSfx then
local ok, playing = pcall(curSfx.src.isPlaying, curSfx.src)
if not (ok and playing) then
curSfx = nil -- CheckSFX returns no carry; wCurSFX stops mattering
elseif curSfx.id < id then
return false -- the sound already going outranks this one
else
pcall(curSfx.src.stop, curSfx.src) -- _PlaySFX zeroes ch5-ch8 first
curSfx = nil
end
end
return true, id
end
-- CheckSFX (home/audio.asm): is a gated sfx still sounding on ch5-ch8? This
-- is the state WaitSFX blocks on, and it is the gate's OWN wCurSFX rather
-- than whatever the caller last held a source for, so a sound started
-- somewhere else entirely (the A-press beep a textbox plays) still answers
-- busy here. Phone_StartRinging (engine/phone/phone.asm:564) is the caller
-- that needs it: SFX_CALL is $6a, low enough that any louder sound still on
-- the channels makes sfxPriorityGate DROP the ring outright, where the cart
-- merely waits for it.
function Sound.sfxBusy()
if not curSfx then return false end
local ok, playing = pcall(curSfx.src.isPlaying, curSfx.src)
if not (ok and playing) then
curSfx = nil -- CheckSFX returns no carry; wCurSFX stops mattering
return false
end
return true
end
local function startSfx(data, name, def)
local src = playPath(data, name, def)
if not src then return end
if ducks(data, name, def) then
@@ -199,6 +366,31 @@ function Sound.play(data, name)
return src
end
-- returns the started source (nil headless, when the def failed to load, or
-- when the priority gate dropped the sound) so callers that block on a
-- fanfare like the original's PlaySoundWaitForCurrent -> WaitForSoundToFinish
-- can poll it
function Sound.play(data, name)
local sfx = data.audio and data.audio.sfx
name = Sound.resolve(data, name)
local def = sfx and sfx[name]
local allowed, id = sfxPriorityGate(data, name, def)
if not allowed then return end
local src = startSfx(data, name, def)
if src and id then curSfx = { src = src, id = id } end
return src
end
-- PlayStereoSFX (audio/engine.asm), the battle animation path: same sound,
-- same fanfare duck, but no CheckSFX/wCurSFX gate, and it never writes
-- wCurSFX either -- so an animation sound can neither be dropped by, nor
-- become, the priority the overworld path compares against.
function Sound.playStereo(data, name)
local sfx = data.audio and data.audio.sfx
name = Sound.resolve(data, name)
return startSfx(data, name, sfx and sfx[name])
end
-- Play a move's sound with its MoveSoundTable pitch/tempo modifiers
-- (data/moves/sfx.asm; GetMoveSound loads them into wFrequencyModifier/
-- wTempoModifier and the battle sound engine applies them to every
@@ -426,7 +618,7 @@ end
-- .musicLoop polls wChannelSoundIDs+CHAN5 until SFX_SAFARI_ZONE_PA
-- ends.) Headless / never-played names read as silent.
function Sound.isPlaying(name)
local src = cache[name]
local src = cached(name)
if not src then return false end
local ok, playing = pcall(src.isPlaying, src)
return ok and playing or false
@@ -435,7 +627,7 @@ end
-- cut a one-shot short (the SFX_STOP_ALL_MUSIC beats around the
-- elevator shake stop the last collision thud mid-ring)
function Sound.stop(name)
local src = cache[name]
local src = cached(name)
if src then pcall(src.stop, src) end
end
@@ -520,6 +712,14 @@ end
-- variants included) or all of them, so the next play re-resolves the def
function Sound.invalidate(name)
lastMoveSfx = nil -- its source is about to be dropped or stopped
-- Same for wCurSFX, and a reloaded table can repoint the id order.
curSfx = nil
sfxIds = nil
-- A replaced def may claim a different set of channels.
if name then channelCounts[name] = nil else channelCounts = {} end
-- A mod that registers the raw name outright ends the alias hop, so the
-- memo has to be re-derived from the reloaded sfx table too.
if name then aliased[name] = nil else aliased = {} end
local function evict(store, key)
local src = store[key]
if src then pcall(src.stop, src) end
+25 -2
View File
@@ -13,9 +13,22 @@ end
-- screen.pushed/popped fire after enter/exit so listeners observe the
-- settled state; the wants guard keeps the no-listener path allocation-free
-- enter/exit are OPTIONAL callbacks, so the test is "is it callable", not "is
-- it there". A state is an ordinary table and `exit` is an ordinary word: the
-- Gen 2 GameFreak screen counts its 16-frame exit tail in a field, and under a
-- truthiness test the stack called into a number and took the process down at
-- a screen hand-off. Reserving the names is still the contract (see
-- src/ui/gen2/GameFreakPresents.lua's exitTail), but the stack does not need
-- to be the thing that enforces it by crashing.
local function callback(state, name)
local fn = state and state[name]
return type(fn) == "function" and fn or nil
end
function StateStack:push(state, ...)
table.insert(self.states, state)
if state.enter then state:enter(...) end
local enter = callback(state, "enter")
if enter then enter(state, ...) end
if Runtime.wants("screen.pushed") then
Runtime.emit("screen.pushed", { state = state })
end
@@ -23,7 +36,8 @@ end
function StateStack:pop()
local state = table.remove(self.states)
if state and state.exit then state:exit() end
local exit = callback(state, "exit")
if exit then exit(state) end
if state and Runtime.wants("screen.popped") then
Runtime.emit("screen.popped", { state = state })
end
@@ -34,6 +48,15 @@ function StateStack:top()
return self.states[#self.states]
end
-- Tear the whole stack down top-first, so every state still gets its exit and
-- every listener still sees screen.popped in the order it would have on a
-- hand-written unwind. Gold's boot cinema hands off between screens this way
-- (title -> intro menu -> Oak) and the Gen 1 paths that did
-- `while self.stack:top() do self.stack:pop() end` mean exactly this.
function StateStack:clear()
while self:top() do self:pop() end
end
function StateStack:update(dt)
local top = self:top()
if top and top.update then top:update(dt) end
+15 -3
View File
@@ -12,6 +12,17 @@
-- (main.lua then drives it with the mouse); POKEPORT_TOUCH=0 forces it
-- off everywhere.
--
-- BOTH GENERATIONS, one module. Red/Blue/Yellow reach it from
-- src/core/Game.lua and Gold from src/core/Game2.lua, through the same six
-- seams in the same order: init + applyOptions at boot, touchpressed /
-- touchmoved / touchreleased ahead of the mod pointer hook (the pad keeps
-- first refusal, #807), noteGamepad on any controller input, joystickremoved
-- when the last pad goes away, reset on focus/visibility loss, and draw as the
-- last thing in the frame -- after the post passes, so the controls are never
-- inside the CRT/GBC grid the picture is being shown through. One
-- options.touchControls block serves both games, so a layout edited in the
-- launcher's editor is the layout Gold draws.
--
-- Player preferences (options.touchControls) can permanently disable the
-- overlay and/or override per-control positions as normalized window
-- fractions. Positions and a size multiplier are stored per orientation
@@ -589,9 +600,10 @@ local function drawIcon(img, zone, pressed, alphaMul)
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
end
-- Screen-space, called by Game:draw after Renderer:endFrame so the
-- overlay rides on top of everything (world, UI, CRT/GBC FX included).
-- Also used by the launcher layout editor under preview mode.
-- Screen-space, called by Game:draw after Renderer:endFrame -- and by
-- Game2:drawHud after Gold's own present pass -- so the overlay rides on top
-- of everything (world, UI, CRT/GBC FX included). Also used by the launcher
-- layout editor under preview mode.
function TouchControls:draw()
if not self:visible() then return end
local L = self:layout()
+522
View File
@@ -0,0 +1,522 @@
-- Kurt, the apricorns, the trees they grow on, and the day he takes to turn
-- one into a ball.
--
-- Gold has no engine/events/kurt.asm: the whole conversation is map script
-- bytecode in maps/KurtsHouse.asm, and the only compiled routines behind it are
-- Kurt_SelectApricorn (engine/menus/menu_2.asm), the ApricornBalls table
-- (data/items/apricorn_balls.asm) and the SelectApricornForKurt special
-- (engine/events/specials.asm), which is the one that actually takes the
-- apricorn out of the bag. So this module is those three plus the clock the
-- script leans on, and nothing else: the dialogue belongs to the extracted
-- script and stays there.
--
-- The trees are the other half. engine/events/fruit_trees.asm is a whole
-- FruitTreeScript in the ROM already; what it needs from the port is the
-- FruitTreeItems lookup, the per-tree picked flag and the daily reset that
-- refills every tree at once. Seven of the thirty trees are apricorn trees,
-- which is why they live in this file rather than in a fruit module of their
-- own.
--
-- THE DAY-LONG WAIT is the piece most likely to be got wrong. Kurt does not
-- run a timer of his own. `setflag ENGINE_KURT_MAKING_BALLS` sets bit 0 of
-- wDailyFlags1, and the ONLY thing that ever clears it is CheckDailyResetTimer
-- wiping wDailyFlags1 and wDailyFlags2 whole once a day has passed
-- (engine/overworld/time.asm). So "come back tomorrow" means "come back after
-- the next daily rollover", which can be twenty-three hours or one minute
-- depending on when you handed the apricorn over -- and a player who winds the
-- clock BACK gets the rollover immediately, because _CalcDaysSince wraps a
-- negative difference into a large positive one instead of clamping it. The
-- cart does nothing to stop that, and neither does this.
--
-- The RTC helpers themselves are in src/core/gen2/BugContest.lua, which is the
-- port's only second-resolution consumer of the same engine/overworld/time.asm
-- block; both belong in a src/core/gen2/Time.lua once one exists.
local BugContest = require("src.core.gen2.BugContest")
local Runtime = require("src.mods.Runtime")
local Apricorns = {}
-- ------------------------------------------------------- apricorns and balls
--
-- data/items/apricorn_balls.asm, in table order. That order is load bearing
-- twice over: FindApricornsInBag walks it to build Kurt's menu, so the menu is
-- always red, blue, yellow, green, white, black, pink regardless of pack
-- order, and Kurt1's checkevent chain in maps/KurtsHouse.asm tests the seven
-- EVENT_GAVE_KURT_*_APRICORN flags in the same order, so a save that somehow
-- held two of them would hand back the earlier ball first.
--
-- The event ids are constants/event_flags.asm indices, which is what the
-- extracted script's `checkevent` / `setevent` / `clearevent` carry. They are
-- NOT the count of `const` lines above them: `const_next 600` two lines before
-- EVENT_GAVE_KURT_RED_APRICORN jumps the counter, which is why the block sits
-- at 600 rather than at the 237 a reader who counted would arrive at. The
-- extracted Kurt1 script agrees -- its red-apricorn arm is `setevent 600`.
Apricorns.BALLS = {
{ apricorn = "RED_APRICORN", ball = "LEVEL_BALL", event = 600 },
{ apricorn = "BLU_APRICORN", ball = "LURE_BALL", event = 601 },
{ apricorn = "YLW_APRICORN", ball = "MOON_BALL", event = 602 },
{ apricorn = "GRN_APRICORN", ball = "FRIEND_BALL", event = 603 },
{ apricorn = "WHT_APRICORN", ball = "FAST_BALL", event = 604 },
{ apricorn = "BLK_APRICORN", ball = "HEAVY_BALL", event = 605 },
{ apricorn = "PNK_APRICORN", ball = "LOVE_BALL", event = 606 },
}
-- ENGINE_KURT_MAKING_BALLS, constants/engine_flags.asm index 79, backed by
-- wDailyFlags1 bit DAILYFLAGS1_KURT_MAKING_BALLS_F.
Apricorns.ENGINE_KURT_MAKING_BALLS = 79
-- Every ENGINE_* id that lives in wDailyFlags1 or wDailyFlags2, in order, so
-- the daily reset can clear the lot the way `ld [hli], a / ld [hl], a` over the
-- two bytes does. Named as well as numbered because the port's save keeps
-- engine flags in a sparse table keyed by the script's numeric id, and a
-- reader of this list should not have to count constants to know what it just
-- wiped.
Apricorns.DAILY_ENGINE_FLAGS = {
{ id = 79, name = "ENGINE_KURT_MAKING_BALLS" },
{ id = 80, name = "ENGINE_DAILY_BUG_CONTEST" },
{ id = 81, name = "ENGINE_SWARM" },
{ id = 82, name = "ENGINE_TIME_CAPSULE" },
{ id = 83, name = "ENGINE_ALL_FRUIT_TREES" },
{ id = 84, name = "ENGINE_GOT_SHUCKIE_TODAY" },
{ id = 85, name = "ENGINE_GOLDENROD_UNDERGROUND_MERCHANT_CLOSED" },
{ id = 86, name = "ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY" },
{ id = 87, name = "ENGINE_MT_MOON_SQUARE_CLEFAIRY" },
{ id = 88, name = "ENGINE_UNION_CAVE_LAPRAS" },
{ id = 89, name = "ENGINE_GOLDENROD_UNDERGROUND_GOT_HAIRCUT" },
{ id = 90, name = "ENGINE_GOLDENROD_DEPT_STORE_TM27_RETURN" },
{ id = 91, name = "ENGINE_DAISYS_GROOMING" },
{ id = 92, name = "ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT" },
}
-- ENGINE_ALL_FRUIT_TREES, the wDailyFlags1 bit TryResetFruitTrees tests before
-- it will refill the trees.
Apricorns.ENGINE_ALL_FRUIT_TREES = 83
local BY_APRICORN, BY_BALL = {}, {}
for index, row in ipairs(Apricorns.BALLS) do
row.index = index
BY_APRICORN[row.apricorn] = row
BY_BALL[row.ball] = row
end
-- ------------------------------------------------------------- the registry
--
-- The `apricorns` registry (src/mods/Schemas.lua), one of the Gen 2-only six:
-- Red has no Kurt and no apricorn balls, so the name is gated under Gen 1 and
-- routed to data.gen2Apricorns under Gen 2. src/mods/Builtins.lua seeds it
-- with the seven rows above, engine-owned, keyed by the apricorn item -- what
-- the player hands over and what FindApricornsInBag walks the bag for.
--
-- The three lookups below are rebuilt from the merged table when there is one,
-- so a registered row reaches Kurt's menu (Apricorns.inBag walks BALLS in
-- table order, which is why `index` is a field and the rebuild sorts by it),
-- the ball he hands back and the apricorn test the bag uses. With no loader
-- the module's own rows stand, which is what every headless test gets.
local function rebuildLookups(rows)
local ordered = {}
for _, row in pairs(rows) do
if type(row) == "table" and row.apricorn and row.ball then
ordered[#ordered + 1] = row
end
end
table.sort(ordered, function(a, b)
if (a.index or 0) ~= (b.index or 0) then
return (a.index or 0) < (b.index or 0)
end
return tostring(a.apricorn) < tostring(b.apricorn)
end)
Apricorns.BALLS, BY_APRICORN, BY_BALL = ordered, {}, {}
for _, row in ipairs(ordered) do
BY_APRICORN[row.apricorn] = row
BY_BALL[row.ball] = row
end
return #ordered
end
-- vanilla registrations, engine-owned
function Apricorns.registerInto(registry, _, owner)
for _, row in ipairs(Apricorns.BALLS) do
registry:register(row.apricorn, row, owner)
end
return #Apricorns.BALLS
end
-- the merged table, folded into the three lookups; nil restores nothing (the
-- module's rows are already standing)
function Apricorns.useRegistry(data)
local rows = data and data.gen2Apricorns
if type(rows) ~= "table" then return 0 end
return rebuildLookups(rows)
end
function Apricorns.row(apricorn) return BY_APRICORN[apricorn] end
function Apricorns.isApricorn(item) return BY_APRICORN[item] ~= nil end
function Apricorns.ballFor(apricorn)
local row = BY_APRICORN[apricorn]
return row and row.ball or nil
end
function Apricorns.apricornFor(ball)
local row = BY_BALL[ball]
return row and row.apricorn or nil
end
-- ------------------------------------------------------------- the save shape
--
-- Kurt keeps NO state of his own. Everything he reads is already in the two
-- tables the extracted script writes:
--
-- save.events wEventFlags, the EVENT_GAVE_KURT_*_APRICORN ids above
-- and EVENT_KURT_GAVE_YOU_LURE_BALL (53) for the free one
-- save.engineFlags ENGINE_KURT_MAKING_BALLS (79)
--
-- and the one thing that is genuinely new:
--
-- save.dailyReset { remaining = 1, day = <wCurDay> }
-- wDailyResetTimer and the start day beside it
-- save.fruitTrees { [FRUITTREE_*] = true } for a tree already picked today
--
-- so nothing below invents a parallel copy of a flag the script can also see.
--
-- save.events is the SERIALIZED BITFIELD src/world/gen2/Events.lua writes --
-- byte index -> byte value, the shape wEventFlags has in SRAM -- and not a set
-- of ids, which is why the two helpers below do the byte and bit arithmetic
-- rather than indexing it. While the game is running the live copy is
-- world.events and the save's is only refreshed on a write, so anything that
-- has to flip one of these flags MID PLAY goes through the script's own
-- `setevent` (Kurt's does): these two are for a save file at rest.
local FLAGS_PER_BYTE = 8
local function events(save)
if type(save) ~= "table" then return nil end
save.events = save.events or {}
return save.events
end
local function engineFlags(save)
if type(save) ~= "table" then return nil end
save.engineFlags = save.engineFlags or {}
return save.engineFlags
end
function Apricorns.event(save, id)
local flags = events(save)
if not (flags and id) then return false end
local byte = flags[math.floor(id / FLAGS_PER_BYTE)] or 0
return math.floor(byte / 2 ^ (id % FLAGS_PER_BYTE)) % 2 == 1
end
function Apricorns.setEvent(save, id, value)
local flags = events(save)
if not (flags and id) then return end
local index = math.floor(id / FLAGS_PER_BYTE)
local mask = 2 ^ (id % FLAGS_PER_BYTE)
local byte = flags[index] or 0
local set = math.floor(byte / mask) % 2 == 1
if value and not set then
flags[index] = byte + mask
elseif not value and set then
flags[index] = byte - mask
end
end
-- ------------------------------------------------------ Kurt_SelectApricorn
--
-- FindApricornsInBag walks ApricornBalls and appends every apricorn the pack
-- holds, then appends a 0 for the CANCEL row -- so the list Kurt shows is
-- always in table order and always ends in CANCEL. Its `scf` return is the
-- "you have none" case: a count of exactly 1 means nothing but CANCEL, and the
-- script's `ifequal FALSE` treats that the same as backing out.
--
-- `inventory` is the flat item -> count map the port's bag uses.
function Apricorns.bagList(inventory)
inventory = inventory or {}
local list = {}
for _, row in ipairs(Apricorns.BALLS) do
if (inventory[row.apricorn] or 0) > 0 then
list[#list + 1] = row.apricorn
end
end
-- The CANCEL row is part of the cart's list, not a decoration the menu adds:
-- wKurtApricornCount counts it, and the menu's last entry is item 0.
list.cancel = #list + 1
list.empty = #list == 0
return list
end
-- What the SelectApricornForKurt special leaves in wScriptVar: the chosen
-- apricorn, or FALSE. `choice` is 1-based over the list bagList returned, and
-- the CANCEL row is `list.cancel`.
function Apricorns.select(inventory, choice)
local list = Apricorns.bagList(inventory)
if list.empty then return nil end
if not choice or choice >= list.cancel then return nil end
return list[choice]
end
-- The rest of SelectApricornForKurt, which the map script does NOT do and
-- which is easy to miss because it is in the special rather than in the
-- bytecode: the chosen apricorn is tossed out of the bag, one unit, before the
-- script ever sets its event.
--
-- ld [wCurItem], a / ld a, 1 / ld [wItemQuantityChange], a
-- ld hl, wNumItems / call TossItem
function Apricorns.takeApricorn(save, apricorn)
if not (save and BY_APRICORN[apricorn]) then return false end
local inventory = save.inventory or {}
save.inventory = inventory
local have = inventory[apricorn] or 0
if have <= 0 then return false end
have = have - 1
inventory[apricorn] = have > 0 and have or nil
return true
end
-- The whole handover, as one call: take the apricorn, set that colour's event,
-- and set ENGINE_KURT_MAKING_BALLS. The two script lines this stands in for
-- are `setevent EVENT_GAVE_KURT_<colour>_APRICORN` and
-- `setflag ENGINE_KURT_MAKING_BALLS`, in .GaveKurtApricorns.
function Apricorns.give(save, apricorn)
local row = BY_APRICORN[apricorn]
if not row then return false end
if not Apricorns.takeApricorn(save, apricorn) then return false end
Apricorns.setEvent(save, row.event, true)
local flags = engineFlags(save)
if flags then flags[Apricorns.ENGINE_KURT_MAKING_BALLS] = true end
return true, row.ball
end
-- Which apricorn Kurt currently has, in the order Kurt1 tests the events. He
-- takes exactly ONE at a time: the .AskApricorn branch is only reachable when
-- every one of the seven events is clear.
function Apricorns.pending(save)
for _, row in ipairs(Apricorns.BALLS) do
if Apricorns.event(save, row.event) then
return row.apricorn, row.ball
end
end
return nil
end
function Apricorns.isWorking(save)
local flags = engineFlags(save)
return (flags and flags[Apricorns.ENGINE_KURT_MAKING_BALLS]) == true
end
-- .GiveLevelBall and its six siblings all open with
-- `checkflag ENGINE_KURT_MAKING_BALLS / iftrue .KurtMakingBallsScript`, so the
-- ball is ready exactly when he has an apricorn and the daily flag has rolled
-- over.
function Apricorns.readyBall(save)
if Apricorns.isWorking(save) then return nil end
local _, ball = Apricorns.pending(save)
return ball
end
-- `verbosegiveitem <BALL> / iffalse .NoRoomForBall / clearevent
-- EVENT_GAVE_KURT_<colour>_APRICORN`. The clear happens only after the ball
-- actually lands in the pack, which is why a full pack leaves Kurt holding it
-- and this returns the ball WITHOUT clearing on a refusal.
function Apricorns.collect(save)
local ball = Apricorns.readyBall(save)
if not ball then return nil end
local apricorn = Apricorns.apricornFor(ball)
local row = BY_APRICORN[apricorn]
Apricorns.setEvent(save, row.event, false)
-- apricorn.converted, a Gen 2 invention: Gen 1 has no Kurt and no apricorn,
-- so there is no name to share. Raised on the handover rather than on
-- Apricorns.give, because the apricorn only becomes a ball once the daily
-- rollover has run and the ball has actually landed in the pack -- a full
-- pack leaves Kurt holding it and this function is not reached at all.
--
-- apricorn the APRICORN_* item that went in
-- ball the BALL item that came out
-- event the EVENT_GAVE_KURT_*_APRICORN flag just cleared
if Runtime.wants("apricorn.converted") then
Runtime.emit("apricorn.converted",
{ apricorn = apricorn, ball = ball, event = row.event })
end
return ball, apricorn
end
-- --------------------------------------------------------- the daily rollover
--
-- RestartDailyResetTimer / InitOneDayCountdown: one day, counted from today.
function Apricorns.startDailyResetTimer(save, now)
if type(save) ~= "table" then return nil end
local stamp = now or BugContest.now()
save.dailyReset = { remaining = 1, day = stamp.day }
return save.dailyReset
end
-- UpdateTimeRemaining: subtract the elapsed units from the counter, clamp at
-- zero, and set carry when it reaches zero. A delta of -1 (the "exceeds this
-- unit's range" sentinel GetTimeElapsed_ExceedsUnitLimit returns) zeroes it
-- outright.
local function updateTimeRemaining(remaining, elapsed)
if elapsed == -1 then return 0, true end
local left = remaining - elapsed
if left < 0 then left = 0 end
return left, left == 0
end
Apricorns.updateTimeRemaining = updateTimeRemaining
-- CheckDailyResetTimer. CheckDayDependentEventHL walks past the counter to
-- the start day, takes the days since it -- ADVANCING the stored day to today
-- as it goes, which is what makes the counter decrement by "days since the
-- last poll" rather than by "days since the timer started" -- and then
-- UpdateTimeRemaining decides whether the day is up.
--
-- When it is, wDailyFlags1 and wDailyFlags2 are cleared whole and the timer
-- restarts. Returns true on the frames the rollover actually happened.
function Apricorns.checkDailyResetTimer(save, now)
if type(save) ~= "table" then return false end
if not save.dailyReset then
Apricorns.startDailyResetTimer(save, now)
return false
end
local timer = save.dailyReset
local stamp = { day = timer.day }
local since = BugContest.elapsedSince(stamp, now, "day")
timer.day = stamp.day
local remaining, expired = updateTimeRemaining(timer.remaining or 1,
since.days)
timer.remaining = remaining
if not expired then return false end
Apricorns.dailyReset(save)
Apricorns.startDailyResetTimer(save, now)
return true
end
-- `xor a / ld hl, wDailyFlags1 / ld [hli], a / ld [hl], a`: both bytes, every
-- bit, in one go. Kurt's ball being finished is a SIDE EFFECT of this and not
-- a thing anyone checks for -- which is also why finishing the Bug Contest
-- (ENGINE_DAILY_BUG_CONTEST) and refilling every fruit tree
-- (ENGINE_ALL_FRUIT_TREES) happen on the same tick.
function Apricorns.dailyReset(save)
local flags = engineFlags(save)
if not flags then return end
for _, row in ipairs(Apricorns.DAILY_ENGINE_FLAGS) do
flags[row.id] = nil
end
-- The port keeps a couple of these under names as well as ids
-- (src/script/gen2/Specials.lua's ActivateFishingSwarm writes
-- save.dailyFlags), so the same wipe has to reach that table.
save.dailyFlags = {}
end
-- ---------------------------------------------------------------- the trees
--
-- data/items/fruit_trees.asm, indexed by FRUITTREE_*. That enum opens
-- `const_def 1`, so it is ONE based and a 1-based Lua list lines up with it
-- exactly -- GetCurTreeFruit's `dec a` before GetFruitTreeItem is the cart
-- converting the same 1-based id into a 0-based offset, not evidence of a
-- 0-based table.
Apricorns.FRUIT_TREES = {
"BERRY", -- 01 FRUITTREE_ROUTE_29
"BERRY", -- 02 FRUITTREE_ROUTE_30_1
"BERRY", -- 03 FRUITTREE_ROUTE_38
"BERRY", -- 04 FRUITTREE_ROUTE_46_1
"PSNCUREBERRY", -- 05 FRUITTREE_ROUTE_30_2
"PSNCUREBERRY", -- 06 FRUITTREE_ROUTE_33
"BITTER_BERRY", -- 07 FRUITTREE_ROUTE_31
"BITTER_BERRY", -- 08 FRUITTREE_ROUTE_43
"PRZCUREBERRY", -- 09 FRUITTREE_VIOLET_CITY
"PRZCUREBERRY", -- 0a FRUITTREE_ROUTE_46_2
"MYSTERYBERRY", -- 0b FRUITTREE_ROUTE_35
"MYSTERYBERRY", -- 0c FRUITTREE_ROUTE_45
"ICE_BERRY", -- 0d FRUITTREE_ROUTE_36
"ICE_BERRY", -- 0e FRUITTREE_ROUTE_26
"MINT_BERRY", -- 0f FRUITTREE_ROUTE_39
"BURNT_BERRY", -- 10 FRUITTREE_ROUTE_44
"RED_APRICORN", -- 11 FRUITTREE_ROUTE_37_1
"BLU_APRICORN", -- 12 FRUITTREE_ROUTE_37_2
"BLK_APRICORN", -- 13 FRUITTREE_ROUTE_37_3
"WHT_APRICORN", -- 14 FRUITTREE_AZALEA_TOWN
"PNK_APRICORN", -- 15 FRUITTREE_ROUTE_42_1
"GRN_APRICORN", -- 16 FRUITTREE_ROUTE_42_2
"YLW_APRICORN", -- 17 FRUITTREE_ROUTE_42_3
"BERRY", -- 18 FRUITTREE_ROUTE_11
"PSNCUREBERRY", -- 19 FRUITTREE_ROUTE_2
"BITTER_BERRY", -- 1a FRUITTREE_ROUTE_1
"PRZCUREBERRY", -- 1b FRUITTREE_ROUTE_8
"ICE_BERRY", -- 1c FRUITTREE_PEWTER_CITY_1
"MINT_BERRY", -- 1d FRUITTREE_PEWTER_CITY_2
"BURNT_BERRY", -- 1e FRUITTREE_FUCHSIA_CITY
}
Apricorns.NUM_FRUIT_TREES = #Apricorns.FRUIT_TREES
-- GetCurTreeFruit.
function Apricorns.treeFruit(tree)
return Apricorns.FRUIT_TREES[tree]
end
local function treeFlags(save)
if type(save) ~= "table" then return nil end
save.fruitTrees = save.fruitTrees or {}
return save.fruitTrees
end
-- TryResetFruitTrees, run at the TOP of FruitTreeScript, before the tree is
-- checked: if ENGINE_ALL_FRUIT_TREES is clear then every tree in the game
-- refills at once and the flag is set so it only happens once a day. It is
-- the daily reset above that clears the flag again.
function Apricorns.tryResetFruitTrees(save)
local flags = engineFlags(save)
if not flags then return false end
if flags[Apricorns.ENGINE_ALL_FRUIT_TREES] then return false end
save.fruitTrees = {}
flags[Apricorns.ENGINE_ALL_FRUIT_TREES] = true
return true
end
-- CheckFruitTree's `ld b, 2 / GetFruitTreeFlag` is a CHECK_FLAG, and the
-- wScriptVar it leaves is TRUE for a tree already picked -- so the script's
-- `iffalse .fruit` reads "not picked yet, there is fruit here".
function Apricorns.treePicked(save, tree)
local flags = treeFlags(save)
return (flags and flags[tree]) == true
end
-- PickedFruitTree's `ld b, 1` is a SET_FLAG.
function Apricorns.pickTree(save, tree)
local flags = treeFlags(save)
if not (flags and Apricorns.FRUIT_TREES[tree]) then return nil end
if flags[tree] then return nil end
flags[tree] = true
return Apricorns.FRUIT_TREES[tree]
end
-- ------------------------------------------------------------- the module map
--
-- Where each half is called from, now that all four have a call site:
--
-- SelectApricornForKurt src/script/gen2/Specials.lua's handler:
-- Apricorns.bagList(save.inventory) builds
-- FindApricornsInBag's list, Apricorns.select(...)
-- reads the row the menu came back with, and
-- Apricorns.takeApricorn is the special's own
-- TossItem. It stops there ON PURPOSE: the setevent
-- and the setflag after it belong to
-- maps/KurtsHouse.asm, which the extractor has, so
-- doing them here as well would set them twice.
-- Apricorns.give is the same pair as one call for a
-- caller that has no script behind it.
-- Kurt1's .GiveXBall the extracted script's own `checkflag
-- ENGINE_KURT_MAKING_BALLS` / `verbosegiveitem` /
-- `clearevent`; Apricorns.readyBall and
-- Apricorns.collect are the same rule for a reader
-- holding nothing but a save file.
-- FruitTreeScript the VM's `fruittree` branch, through World's
-- fruitTreeItem / fruitTreeReset / fruitTreePicked /
-- fruitTreePick hooks: Apricorns.treeFruit,
-- Apricorns.tryResetFruitTrees, Apricorns.treePicked
-- and Apricorns.pickTree in that order.
-- CheckTimeEvents World:checkTimeEvents, once a frame off the
-- player-event chain: Apricorns.checkDailyResetTimer
return Apricorns
+313
View File
@@ -0,0 +1,313 @@
-- Gen 2 automated joypad input: home/joypad.asm (GetJoypad's .auto arm,
-- StartAutoInput, StopAutoInput).
--
-- While a stream is armed the cart stops looking at the joypad entirely.
-- GetJoypad branches on wInputType == AUTO_INPUT before it ever reads
-- hJoypadDown, and writes hJoyDown / hJoyPressed straight out of the stream,
-- so anything the player is physically holding is discarded until the stream
-- ends. That suppression is half the feature: the catching tutorial hands the
-- DUDE the controller, and a player mashing A must not be able to steer it.
--
-- Stream format, quoting the asm: [input][duration], and an input of $ff ends
-- the stream immediately. A duration is the number of EXTRA frames the input
-- is held for (wAutoInputLength counts down, and only a zero count re-reads
-- the stream), so a duration of 0 means "one frame". A duration of $ff is the
-- odd one: it stores $ff, forces the input to NO_INPUT, and leaves
-- wAutoInputAddress pointing at the same pair -- the two `dec hl`s in that arm
-- are vestigial, because only the .next arm ever writes the address back. The
-- effect is "hold nothing forever", re-arming itself every 256 frames, which
-- is how every stream in the ROM parks at its end without releasing control.
--
-- The port feeds the decoded frame through src/core/Input.lua the way
-- src/core/TouchControls.lua does, under its own source names, so the per
-- fixed-step edge detection in Input:step sees a real press and a real
-- release. Game2 steps this BEFORE Input:step for the same reason tool
-- mods run there: a button chosen this tick has to be visible to this
-- tick's logic, not the next one.
local AutoInput = {}
AutoInput.__index = AutoInput
-- constants/hardware.inc PAD_*. Same bit order as hJoypadDown.
local PAD_A = 0x01
local PAD_B = 0x02
local PAD_SELECT = 0x04
local PAD_START = 0x08
local PAD_RIGHT = 0x10
local PAD_LEFT = 0x20
local PAD_UP = 0x40
local PAD_DOWN = 0x80
local NO_INPUT = 0x00
AutoInput.PAD_A = PAD_A
AutoInput.PAD_B = PAD_B
AutoInput.PAD_RIGHT = PAD_RIGHT
AutoInput.PAD_DOWN = PAD_DOWN
AutoInput.NO_INPUT = NO_INPUT
-- Bit -> the GB button name the rest of the engine uses. Ordered so a decoded
-- frame always presses in the same sequence, which keeps Input:step's queue
-- deterministic for the tests.
local BITS = {
{ PAD_A, "a" },
{ PAD_B, "b" },
{ PAD_SELECT, "select" },
{ PAD_START, "start" },
{ PAD_RIGHT, "right" },
{ PAD_LEFT, "left" },
{ PAD_UP, "up" },
{ PAD_DOWN, "down" },
}
-- Lua 5.1 has no bitops in the base library and the engine targets LuaJIT
-- semantics, so the mask test is arithmetic: these are eight distinct single
-- bits, and a stream byte is always < 256.
local function held(mask, bit)
return math.floor(mask / bit) % 2 == 1
end
-- The four streams that exist in the ROM. Flat [input][duration] byte arrays,
-- transcribed rather than generated: the extractor emits only the bank and
-- pointer an `autoinput` command carries (Script_autoinput's three GetScriptByte
-- calls), never the bytes behind it.
AutoInput.STREAMS = {
-- engine/events/catch_tutorial.asm CatchTutorial.AutoInput: the DUDE's battle
-- is played entirely by the re-arms below, so the stream wrapped around
-- StartBattle only has to hold the player's own hands off the controller.
CATCH_TUTORIAL = { NO_INPUT, 0xff },
-- engine/events/catch_tutorial_input.asm. PromptButton, the battle menu and
-- the pack re-arm one of these each time they want the DUDE to answer, which
-- is why the tutorial reads as a person playing rather than as a macro.
DUDE_A = {
NO_INPUT, 0x50,
PAD_A, 0x00,
NO_INPUT, 0xff,
},
DUDE_RIGHT_A = {
NO_INPUT, 0x08,
PAD_RIGHT, 0x00,
NO_INPUT, 0x08,
PAD_A, 0x00,
NO_INPUT, 0xff,
},
DUDE_DOWN_A = {
NO_INPUT, 0xfe,
NO_INPUT, 0xfe,
NO_INPUT, 0xfe,
NO_INPUT, 0xfe,
PAD_DOWN, 0x00,
NO_INPUT, 0xfe,
NO_INPUT, 0xfe,
NO_INPUT, 0xfe,
NO_INPUT, 0xfe,
PAD_A, 0x00,
NO_INPUT, 0xff,
},
}
-- bank:address -> stream name, from ../pokegold-symbols/pokegold.sym. An
-- `autoinput` command names its stream by a `dba`, so this is how a script's
-- bank:pointer becomes bytes we actually have. Nothing else in the ROM can be
-- the target: StartAutoInput has exactly these four call sites.
AutoInput.POINTERS = {
["08:79fc"] = "CATCH_TUTORIAL",
["70:4dfe"] = "DUDE_A",
["70:4e04"] = "DUDE_RIGHT_A",
["70:4e0e"] = "DUDE_DOWN_A",
}
function AutoInput.new()
return setmetatable({
-- wAutoInputAddress, as an index into `bytes`
pos = 1,
-- wAutoInputLength
length = 0,
bytes = nil,
active = false,
-- hJoyDown's current value, kept so a frame inside a duration can leave the
-- mirrors alone the way the .quit arm does
current = NO_INPUT,
}, AutoInput)
end
function AutoInput:isActive()
return self.active
end
-- StartAutoInput. `stream` is a stream name from AutoInput.STREAMS or a raw
-- byte array; `input` is src/core/Input.lua, whose mirrors are cleared here the
-- way StartAutoInput clears hJoyPressed / hJoyReleased / hJoyDown, so a button
-- the player was holding when the stream armed does not leak into it.
function AutoInput:start(stream, input)
local bytes = stream
if type(stream) == "string" then bytes = AutoInput.STREAMS[stream] end
if type(bytes) ~= "table" or bytes[1] == nil then return false end
self.bytes = bytes
self.pos = 1
-- "Start reading the stream immediately": a zero length makes the very next
-- step take the .updateauto arm.
self.length = 0
self.current = NO_INPUT
self.active = true
-- Frame pace unless the caller asks for poll pace; see skipIdle.
self.pollPaced = nil
if input and input.reset then input:reset() end
return true
end
-- Play the armed stream at POLL pace instead of frame pace: every pair that
-- presses nothing is skipped, so only the buttons are left, one per step.
--
-- This is a PORT correction, not something the cart does, and it is only for
-- the streams a menu consumes. The cart advances the stream once per GetJoypad
-- call, and a menu's wait loop calls GetJoypad with no frame delay at all
-- (engine/menus/menu.asm `.loopRTC`, engine/items/pack.asm's own loop), so
-- DudeAutoInput_DownA's four `NO_INPUT, $fe` runs are loop iterations there and
-- are gone in a frame or two. This port polls once per fixed step, where the
-- same runs would be 1020 steps of the DUDE staring at the battle menu. The
-- presses and their ORDER -- which is all those streams encode -- are untouched.
--
-- PromptButton's own loop DOES delay a frame per iteration, so DUDE_A is left
-- frame-paced and its 0x51 blank frames are the real beat between two lines.
--
-- A `$ff` duration is never skipped: that pair is the stream parking itself,
-- not a pause before a press.
function AutoInput:skipIdle()
self.pollPaced = true
if not self.active then return false end
local skipped = self:dropIdlePairs()
-- A zero length is what makes the next step re-read the stream.
self.length = 0
self.current = NO_INPUT
return skipped
end
function AutoInput:dropIdlePairs()
local bytes = self.bytes or {}
local skipped = false
while true do
local value = bytes[self.pos]
local duration = bytes[self.pos + 1]
if value ~= NO_INPUT or duration == nil or duration == 0xff then break end
self.pos = self.pos + 2
skipped = true
end
return skipped
end
-- Script_autoinput's `dba`: bank first, then the 16-bit address.
function AutoInput:startPointer(bank, address, input)
local key = string.format("%02x:%04x", bank or 0, address or 0)
local name = AutoInput.POINTERS[key]
if not name then
-- The bytes are not in the cache and the pointer is not one of the ROM's
-- own streams, so there is nothing to replay. Recorded rather than
-- guessed: arming an invented stream would take the controller away from
-- the player with no way to hand it back.
self.unknownPointer = key
return false
end
return self:start(name, input)
end
-- StopAutoInput. Clears the stream and puts wInputType back to normal input;
-- Input:reconcile is the port's equivalent of GetJoypad going back to reading
-- hJoypadDown, i.e. a key the player is still physically holding is down again
-- on the very next step rather than waiting for a fresh keypress event.
function AutoInput:stop(input)
self.bytes = nil
self.pos = 1
self.length = 0
self.current = NO_INPUT
local wasActive = self.active
self.active = false
self.restorePending = nil
if wasActive and input then
if input.reset then input:reset() end
if input.reconcile then input:reconcile() end
end
return wasActive
end
-- One GetJoypad .auto pass. Returns the pad mask for this frame, and true as
-- a second value on the frame the stream ended: .stopauto calls StopAutoInput
-- from inside GetJoypad, so the ring is disarmed here rather than by the
-- caller, and the handback to the real pad is left for the step after.
function AutoInput:advance()
if not self.active then return NO_INPUT end
-- "We only update when the input duration has expired."
if self.length ~= 0 then
self.length = self.length - 1
return self.current
end
-- A poll-paced stream drops the blank pairs BETWEEN its presses as well as
-- the ones in front of them: on the cart the loop consuming it burns through
-- both at the same speed. See skipIdle.
if self.pollPaced then self:dropIdlePairs() end
local bytes = self.bytes or {}
local value = bytes[self.pos]
-- "An input of $ff will end the stream." A stream that runs off its own end
-- is malformed data rather than something the ROM can produce, and is
-- treated as the terminator so control still comes back.
if value == nil or value == 0xff then
self:stop()
self.restorePending = true
return NO_INPUT, true
end
local duration = bytes[self.pos + 1]
if duration == nil then
self:stop()
self.restorePending = true
return NO_INPUT, true
end
self.length = duration
if duration == 0xff then
-- "A duration of $ff will end the stream indefinitely": the current input
-- is overwritten and the address is left pointing at this same pair.
value = NO_INPUT
else
self.pos = self.pos + 2
end
self.current = value
return value
end
-- Called once per fixed step, before Input:step. Presses this frame's buttons
-- through the same per-source bookkeeping the touch overlay and mod input use.
-- Returns true while the stream owns the controller.
function AutoInput:step(input)
if not self.active then
-- The terminator frame below still belonged to the stream, so the handback
-- lands here, one step later: that is the frame GetJoypad would first read
-- hJoypadDown again. Doing it on the terminator frame itself would let a
-- key the player was leaning on register a press the cart never saw.
if self.restorePending then
self.restorePending = nil
if input then
if input.reset then input:reset() end
if input.reconcile then input:reconcile() end
end
end
return false
end
local mask = self:advance()
if input then
-- GetJoypad overwrites the mirrors outright in this arm, so every other
-- source is dropped for the frame. Re-pressing each held button every
-- step is deliberate: hJoyPressed is written once per stream update and
-- then left latched for the whole duration, so an auto-held A really does
-- read as pressed on every frame it covers.
if input.reset then input:reset() end
for _, entry in ipairs(BITS) do
if held(mask, entry[1]) then
input:sourcePress(entry[2], "auto:" .. entry[2])
end
end
end
-- .stopauto has already disarmed the ring inside advance; the frame the
-- terminator lands on is still an auto frame (NO_INPUT into the mirrors),
-- and the step after it is the player's.
return true
end
return AutoInput
+165
View File
@@ -0,0 +1,165 @@
-- Gen 2 storage system: 14 boxes of 20, the party<->box moves the PC does, and
-- the default BOX1..BOX14 names.
--
-- The save already carries `boxes`, `boxNames` and `currentBox`
-- (src/core/gen2/Save.lua); this is the logic that operates on them, kept out
-- of the UI so a deposit is testable without a screen.
--
-- Rules taken from engine/pokemon/bills_pc.asm:
-- * the PC cannot be opened with an empty party (.CheckCanUsePC)
-- * DEPOSIT refuses to send the last healthy party mon away, because a party
-- of nothing whites you out on the next step
-- * WITHDRAW refuses once the party is full
-- * a box holds MONS_PER_BOX and no more
-- * DEPOSIT refuses a mon holding MAIL, because sPartyMail has six slots and
-- a boxed mon has none of them (src/core/gen2/Mail.lua)
local Mail = require("src.core.gen2.Mail")
local Save = require("src.core.gen2.Save")
local Boxes = {}
Boxes.NUM_BOXES = Save.NUM_BOXES
Boxes.MONS_PER_BOX = Save.MONS_PER_BOX
Boxes.PARTY_SIZE = Save.PARTY_SIZE
-- SetDefaultBoxNames (engine/menus/intro_menu.asm): "BOX" then 1..14.
function Boxes.defaultName(index)
return "BOX" .. tostring(index)
end
function Boxes.name(save, index)
local names = save and save.boxNames
local given = names and names[index]
if type(given) == "string" and given ~= "" then return given end
return Boxes.defaultName(index)
end
function Boxes.rename(save, index, name)
if not save or not index then return false end
if index < 1 or index > Boxes.NUM_BOXES then return false end
save.boxNames = save.boxNames or {}
save.boxNames[index] = name
return true
end
-- The box's mon list, created on demand so a fresh save carries 14 empty
-- tables only once one is actually used.
function Boxes.box(save, index)
if not save then return {} end
index = index or save.currentBox or 1
if index < 1 or index > Boxes.NUM_BOXES then return {} end
save.boxes = save.boxes or {}
save.boxes[index] = save.boxes[index] or {}
return save.boxes[index]
end
function Boxes.count(save, index)
return #Boxes.box(save, index)
end
function Boxes.isFull(save, index)
return Boxes.count(save, index) >= Boxes.MONS_PER_BOX
end
function Boxes.setCurrent(save, index)
if not save or index < 1 or index > Boxes.NUM_BOXES then return false end
save.currentBox = index
return true
end
-- How many party members could still fight. DEPOSIT checks this, not the raw
-- party count: a party of one fainted mon plus one healthy one may not send
-- the healthy one to a box.
function Boxes.healthyCount(party)
local n = 0
for _, mon in ipairs(party or {}) do
if (mon.hp or 0) > 0 then n = n + 1 end
end
return n
end
-- Returns true, or false plus a reason string the caller shows in a text box.
function Boxes.canDeposit(save, partyIndex, boxIndex)
if not save then return false, "No save." end
local mon = save.party and save.party[partyIndex]
if not mon then return false, "There is no POKéMON there." end
if Boxes.isFull(save, boxIndex) then
return false, "The BOX is full."
end
if (mon.hp or 0) > 0 and Boxes.healthyCount(save.party) <= 1 then
return false, "You can't deposit\nthe last POKéMON!"
end
-- BillsPC_CheckMon's .HasMail arm (engine/pokemon/bills_pc.asm), which reads
-- the wBillsPC_MonHasMail byte PCMonInfo set while drawing the row. It is
-- checked AFTER the last-healthy rule and prints PCString_RemoveMail, which
-- is one short line rather than a two-line refusal: a boxed mon's letter has
-- nowhere to live, because sPartyMail is six structs keyed by party slot.
if Mail.monHoldsMail(mon) then
return false, "Remove MAIL."
end
return true
end
function Boxes.deposit(save, partyIndex, boxIndex)
local ok, reason = Boxes.canDeposit(save, partyIndex, boxIndex)
if not ok then return false, reason end
local mon = table.remove(save.party, partyIndex)
-- RemoveMonFromPartyOrBox's "Mail time!" tail: sPartyMail is keyed by SLOT,
-- so every letter after the departing mon moves up one. The mon leaving
-- here never has mail of its own (canDeposit just refused that), but the
-- ones behind it may.
Mail.removeSlot(save, partyIndex)
local box = Boxes.box(save, boxIndex)
box[#box + 1] = mon
return true, mon
end
function Boxes.canWithdraw(save, boxIndex, slot)
if not save then return false, "No save." end
local box = Boxes.box(save, boxIndex)
if not box[slot] then return false, "There is no POKéMON there." end
if #(save.party or {}) >= Boxes.PARTY_SIZE then
return false, "You can't take\nany more POKéMON."
end
return true
end
function Boxes.withdraw(save, boxIndex, slot)
local ok, reason = Boxes.canWithdraw(save, boxIndex, slot)
if not ok then return false, reason end
local mon = table.remove(Boxes.box(save, boxIndex), slot)
save.party = save.party or {}
save.party[#save.party + 1] = mon
return true, mon
end
-- RELEASE from a box. The cart lets you release anything in storage; the
-- party's last-healthy rule does not apply because a boxed mon is never in it.
function Boxes.release(save, boxIndex, slot)
local box = Boxes.box(save, boxIndex)
if not box[slot] then return false, "There is no POKéMON there." end
return true, table.remove(box, slot)
end
-- Move a boxed mon to another box (MOVE PKMN W/O MAIL's box-to-box case).
function Boxes.move(save, fromBox, slot, toBox)
if fromBox == toBox then return false, "It's already there." end
local source = Boxes.box(save, fromBox)
if not source[slot] then return false, "There is no POKéMON there." end
if Boxes.isFull(save, toBox) then return false, "The BOX is full." end
local mon = table.remove(source, slot)
local target = Boxes.box(save, toBox)
target[#target + 1] = mon
return true, mon
end
-- .CheckCanUsePC: "You'll need a POKéMON to call with."
function Boxes.canUsePc(save)
if not (save and save.party and #save.party > 0) then
return false, "You'll need a\nPOKéMON to call\nwith."
end
return true
end
return Boxes
File diff suppressed because it is too large Load Diff
+834
View File
@@ -0,0 +1,834 @@
-- The Bug Catching Contest (engine/events/bug_contest/).
--
-- A MODE, not a menu. For its duration the party is masked down to the lead
-- mon, the pack is replaced by twenty PARK BALLs, a twenty minute clock runs
-- off the RTC, wild encounters come from the contest's OWN table rather than
-- from National Park's grass, and only ONE caught mon is kept at a time. When
-- it is over the judge scores that mon against five rolled contestants, and
-- the placing decides whether the player walks out with a SUN STONE.
--
-- The pieces, and the file each is transcribed from:
--
-- ContestScore bug_contest/judging.asm the player's score
-- ComputeAIContestantScores bug_contest/judging.asm the five AI rolls
-- DetermineContestWinners bug_contest/judging.asm the podium
-- BugContest_GetPlayersResult bug_contest/judging.asm the placing, 0..3
-- BugContestantPointers data/events/bug_contest_winners.asm
-- BugCatchingContestantEventFlagTable data/events/bug_contest_flags.asm
-- ContestMons data/wild/bug_contest_mons.asm
-- ChooseWildEncounter_BugContest engine/overworld/events.asm
-- TryWildEncounter_BugContest engine/overworld/events.asm
-- ContestDropOffMons / ContestReturnMons bug_contest/contest_2.asm
-- BugContest_SetCaughtContestMon bug_contest/caught_mon.asm
-- GiveParkBalls bug_contest/contest.asm
-- StartBugContestTimer / CheckBugContestTimer engine/overworld/time.asm
--
-- NOTHING here draws. src/ui/gen2/ContestMenu.lua is the STOCK-versus-THIS
-- comparison screen, and it asks this module every question it needs answered,
-- so the rules can be tested with no love at all.
--
-- The DRIVER is the extracted script bytecode: Route35NationalParkGate's
-- officer calls ContestDropOffMons, GiveParkBalls and
-- SelectRandomBugContestContestants, and BugContestResultsScript calls
-- BugContestJudging, ContestReturnMons and CheckPartyFullAfterContest. Each of
-- those specials is one call into this module -- see the module map at the
-- bottom of the file.
--
-- This file also carries the RTC delta helpers from engine/overworld/time.asm.
-- They live here because the contest timer is the port's only SECOND
-- resolution consumer of them; src/core/gen2/Apricorns.lua reuses the same two
-- functions at day resolution for Kurt's wait. Both belong in a
-- src/core/gen2/Time.lua the day one exists.
local Runtime = require("src.mods.Runtime")
local BugContest = {}
-- ---------------------------------------------------------------- constants
--
-- constants/script_constants.asm.
BugContest.BALLS = 20 -- BUG_CONTEST_BALLS
BugContest.MINUTES = 20 -- BUG_CONTEST_MINUTES
BugContest.SECONDS = 0 -- BUG_CONTEST_SECONDS
BugContest.PLAYER = 1 -- BUG_CONTEST_PLAYER
BugContest.NUM_CONTESTANTS = 10 -- NUM_BUG_CONTESTANTS, not counting the player
BugContest.CONTESTANT_SIZE = 4 -- BUG_CONTESTANT_SIZE: id, mon, score hi, lo
-- SelectRandomBugContestContestants sets five of the ten flags.
BugContest.CONTESTANTS_PICKED = 5
-- The three-way answer CheckPartyFullAfterContest leaves in wScriptVar, which
-- BugContestResults_DidNotLeaveMons branches on.
BugContest.CAUGHT_MON = 0
BugContest.BOXED_MON = 1
BugContest.NO_CATCH = 2
-- constants/engine_flags.asm, by index: the two ENGINE_* ids the gate scripts
-- set and clear around a contest. ENGINE_BUG_CONTEST_TIMER is what makes
-- CheckTimeEvents poll the clock instead of the daily reset;
-- ENGINE_DAILY_BUG_CONTEST is a wDailyFlags1 bit, so it clears itself overnight
-- and that is what makes the contest a once-a-day thing.
BugContest.ENGINE_BUG_CONTEST_TIMER = 16
BugContest.ENGINE_DAILY_BUG_CONTEST = 80
-- Route35OfficerScriptContest turns you away on SUNDAY, MONDAY, WEDNESDAY and
-- FRIDAY, so the contest runs Tuesday, Thursday and Saturday. Weekday numbers
-- are GetWeekday's, which is wCurDay mod 7 with SUNDAY == 0
-- (constants/ram_constants.asm).
BugContest.SUNDAY, BugContest.MONDAY, BugContest.TUESDAY = 0, 1, 2
BugContest.WEDNESDAY, BugContest.THURSDAY = 3, 4
BugContest.FRIDAY, BugContest.SATURDAY = 5, 6
BugContest.CONTEST_DAYS = { [2] = true, [4] = true, [6] = true }
-- BugContestResults_FirstPlace / _SecondPlace / _ThirdPlace, and the
-- consolation BERRY every entrant who placed nowhere gets.
BugContest.PRIZES = { "SUN_STONE", "EVERSTONE", "GOLD_BERRY" }
BugContest.CONSOLATION_PRIZE = "BERRY"
-- The one ball that works inside the park. BattleMenu_Pack's `.contest`
-- branch does not open the pack at all: it loads PARK_BALL into wCurItem and
-- runs the item effect, so there is no way to throw anything else.
BugContest.BALL = "PARK_BALL"
-- ------------------------------------------------------------- the RTC
--
-- wCurDay counts 0..139 (_CalcDaysSince wraps by adding 20 * 7), hours by
-- MAX_HOUR, minutes and seconds by 60.
BugContest.DAY_WRAP = 20 * 7
BugContest.HOUR_WRAP = 24 -- MAX_HOUR, constants/misc_constants.asm
-- The host clock in the cart's shape. `stamp` is optional and is an os.time()
-- value, so a test can pin the clock without touching the real one.
function BugContest.now(stamp)
local t = os.date("*t", stamp)
-- Days since the epoch in LOCAL time, folded into wCurDay's range. The
-- absolute value is meaningless (the cart's own wCurDay is only ever read
-- through a difference or a mod 7), so any monotone daily counter serves,
-- and folding it here is what makes a save survive a year of real time.
local midday = os.time({ year = t.year, month = t.month, day = t.day,
hour = 12, min = 0, sec = 0 })
local day = math.floor(midday / 86400) % BugContest.DAY_WRAP
return { day = day, hour = t.hour, minute = t.min, second = t.sec }
end
-- GetWeekday: wCurDay mod 7, SUNDAY == 0.
function BugContest.weekday(now)
return ((now or BugContest.now()).day or 0) % 7
end
function BugContest.isContestDay(now)
return BugContest.CONTEST_DAYS[BugContest.weekday(now)] == true
end
local function borrowed(value, wrap)
-- One `sub`/`sbc` step: the wrapped difference, plus the borrow that has to
-- carry into the next unit up.
if value < 0 then return value + wrap, 1 end
return value, 0
end
-- CalcSecsMinsHoursDaysSince. Two things about this routine matter and both
-- are easy to lose in a port:
--
-- * it ADVANCES the stored stamp to `now` in place (`ld [hl], c ; current
-- seconds`, and again for minutes, hours and days). So the deltas are
-- "since the LAST poll", not "since the timer started", and two polls a
-- minute apart subtract one minute each rather than one and then two.
-- * every unit wraps rather than going negative. A clock moved BACKWARDS
-- therefore reads as a very large jump forward, which is exactly what
-- makes a rewound clock end the contest and roll the daily flags.
--
-- `depth` picks how far down the struct the cart went: CalcDaysSince stops at
-- days, CalcMinsHoursDaysSince at minutes, CalcSecsMinsHoursDaysSince runs the
-- lot. Unread units are left alone in the stamp, the same way the shorter
-- entry points never touch them.
function BugContest.elapsedSince(stamp, now, depth)
now = now or BugContest.now()
depth = depth or "second"
local out = { days = 0, hours = 0, minutes = 0, seconds = 0 }
local carry = 0
if depth == "second" then
local value
value, carry = borrowed((now.second or 0) - (stamp.second or 0), 60)
stamp.second = now.second or 0
out.seconds = value
end
if depth == "second" or depth == "minute" then
local value
value, carry = borrowed((now.minute or 0) - (stamp.minute or 0) - carry, 60)
stamp.minute = now.minute or 0
out.minutes = value
end
if depth ~= "day" then
local value
value, carry = borrowed((now.hour or 0) - (stamp.hour or 0) - carry,
BugContest.HOUR_WRAP)
stamp.hour = now.hour or 0
out.hours = value
end
local days
days, carry = borrowed((now.day or 0) - (stamp.day or 0) - carry,
BugContest.DAY_WRAP)
stamp.day = now.day or 0
out.days = days
return out
end
-- ------------------------------------------------------------- the state
--
-- Everything the contest owns lives under save.bugContest, spelled after the
-- WRAM it stands in for:
--
-- active ENGINE_BUG_CONTEST_TIMER (wStatusFlags2 bit 0)
-- balls wParkBallsRemaining
-- minutes wBugContestMinsRemaining
-- seconds wBugContestSecsRemaining
-- startTime wBugContestStartTime, { day, hour, minute, second }
-- caught wContestMon, one party-shaped mon or nil
-- stash the party tail ContestDropOffMons masks off
-- contestants { [1..10] = true } for a SET flag, i.e. NOT in this contest
-- results wBugContestResults, { first, second, third }
-- place what BugContestJudging left in wScriptVar, 0..3
function BugContest.state(save)
if type(save) ~= "table" then return nil end
save.bugContest = save.bugContest or {}
return save.bugContest
end
function BugContest.isActive(save)
local state = BugContest.state(save)
return (state and state.active) == true
end
-- ------------------------------------------------------- the encounter table
--
-- data/wild/bug_contest_mons.asm, transcribed. `chance` is the row's slice of
-- 100, NOT a cumulative total: ChooseWildEncounter_BugContest subtracts each
-- row from the roll until it borrows. The ten real rows already add to 100, so
-- the trailing VENOMOTH row -- whose chance byte is -1, i.e. "always" -- is
-- unreachable. It is kept because dropping it would be editing the cart's
-- table, and because it is the row a modified chance list would fall through
-- to.
BugContest.MONS = {
{ chance = 20, species = "CATERPIE", min = 7, max = 18 },
{ chance = 20, species = "WEEDLE", min = 7, max = 18 },
{ chance = 10, species = "METAPOD", min = 9, max = 18 },
{ chance = 10, species = "KAKUNA", min = 9, max = 18 },
{ chance = 5, species = "BUTTERFREE", min = 12, max = 15 },
{ chance = 5, species = "BEEDRILL", min = 12, max = 15 },
{ chance = 10, species = "VENONAT", min = 10, max = 16 },
{ chance = 10, species = "PARAS", min = 10, max = 17 },
{ chance = 5, species = "SCYTHER", min = 13, max = 14 },
{ chance = 5, species = "PINSIR", min = 13, max = 14 },
{ chance = 255, species = "VENOMOTH", min = 30, max = 40 },
}
-- The extractor writes the same eleven rows as encounters.bugContest, which is
-- its own table in the cache and not a grass row: the park's grass entry is
-- what the map rolls off OUTSIDE the twenty minutes. This reader prefers the
-- cache, so the transcription above is the pinned fallback rather than a
-- second source of truth that can drift.
function BugContest.contestMons(data)
local extracted = data and data.encounters and data.encounters.bugContest
if type(extracted) == "table" and #extracted > 0 then return extracted end
return BugContest.MONS
end
-- `call Random` gives one byte. Kept on the module, not taken from the VM, so
-- every roll below can be pinned by a test; the convention is the ASM's, a
-- function of no arguments returning 0..255.
function BugContest.random()
if love and love.math and love.math.random then
return love.math.random(0, 255)
end
return math.random(0, 255)
end
local function byte(random)
return (random or BugContest.random)()
end
-- TryWildEncounter_BugContest: 40 percent in super-tall grass, 20 percent in
-- ordinary grass, and `percent` is `* $ff / 100` so those are 102 and 51 out of
-- 256, not 40 and 20 out of 100.
BugContest.ENCOUNTER_RATE_SUPER_TALL = math.floor(40 * 0xff / 100)
BugContest.ENCOUNTER_RATE_GRASS = math.floor(20 * 0xff / 100)
function BugContest.encounterRate(superTallGrass)
if superTallGrass then return BugContest.ENCOUNTER_RATE_SUPER_TALL end
return BugContest.ENCOUNTER_RATE_GRASS
end
function BugContest.triggers(superTallGrass, random)
return byte(random) < BugContest.encounterRate(superTallGrass)
end
-- ChooseWildEncounter_BugContest. The roll is rejected until it is under
-- 200 and then halved, which is a uniform 0..99 with no modulo bias, and the
-- level is `min + Random % (max - min + 1)` unless min and max are equal.
function BugContest.chooseWild(data, random)
local rows = BugContest.contestMons(data)
local roll
repeat
roll = byte(random)
until roll < 200
roll = math.floor(roll / 2)
local row
for index = 1, #rows do
row = rows[index]
local chance = row.chance or 0
if roll < chance then break end
roll = roll - chance
end
if not row then return nil end
local level = row.min or 1
local span = (row.max or level) - level
if span ~= 0 then
-- SimpleDivide's remainder over (max - min + 1), added to min.
level = level + (byte(random) % (span + 1))
end
return { species = row.species, level = level }
end
-- ------------------------------------------------------------ the contestants
--
-- data/events/bug_contest_winners.asm. Each row is `db class, id` followed by
-- three `dbw mon, score` rows, best first.
--
-- INDEXING, which is where this table bites. BugContestantPointers has
-- NUM_BUG_CONTESTANTS + 1 entries: slot 0 is a duplicate of Bug Catcher Don
-- that the comment marks "this reverts back to the player" and nothing ever
-- reads. ComputeAIContestantScores walks e = 0..9 and looks up slot e + 1,
-- and LoadContestantName takes a winner ID and looks up slot ID - 1. So the
-- ten real contestants are slots 1..10 and their winner IDs are 2..11, with
-- ID 1 reserved for the player (BUG_CONTEST_PLAYER). A 1-based Lua list lines
-- up with the slot numbers exactly, which is why this one is NOT zero based.
BugContest.CONTESTANTS = {
{ class = "BUG_CATCHER", trainer = 1, name = "DON",
mons = { { species = "KAKUNA", score = 300 },
{ species = "METAPOD", score = 285 },
{ species = "CATERPIE", score = 226 } } },
{ class = "BUG_CATCHER", trainer = 3, name = "ED",
mons = { { species = "BUTTERFREE", score = 286 },
{ species = "BUTTERFREE", score = 251 },
{ species = "CATERPIE", score = 237 } } },
{ class = "COOLTRAINERM", trainer = 1, name = "NICK",
mons = { { species = "SCYTHER", score = 357 },
{ species = "BUTTERFREE", score = 349 },
{ species = "PINSIR", score = 368 } } },
{ class = "POKEFANM", trainer = 1, name = "WILLIAM",
mons = { { species = "PINSIR", score = 332 },
{ species = "BUTTERFREE", score = 324 },
{ species = "VENONAT", score = 321 } } },
{ class = "BUG_CATCHER", trainer = 5, name = "BENNY",
mons = { { species = "BUTTERFREE", score = 318 },
{ species = "WEEDLE", score = 295 },
{ species = "CATERPIE", score = 285 } } },
{ class = "CAMPER", trainer = 5, name = "BARRY",
mons = { { species = "PINSIR", score = 366 },
{ species = "VENONAT", score = 329 },
{ species = "KAKUNA", score = 314 } } },
{ class = "PICNICKER", trainer = 5, name = "CINDY",
mons = { { species = "BUTTERFREE", score = 341 },
{ species = "METAPOD", score = 301 },
{ species = "CATERPIE", score = 264 } } },
{ class = "BUG_CATCHER", trainer = 7, name = "JOSH",
mons = { { species = "SCYTHER", score = 326 },
{ species = "BUTTERFREE", score = 292 },
{ species = "METAPOD", score = 282 } } },
{ class = "YOUNGSTER", trainer = 5, name = "SAMUEL",
mons = { { species = "WEEDLE", score = 270 },
{ species = "PINSIR", score = 282 },
{ species = "CATERPIE", score = 251 } } },
{ class = "SCHOOLBOY", trainer = 2, name = "KIPP",
mons = { { species = "VENONAT", score = 267 },
{ species = "PARAS", score = 254 },
{ species = "KAKUNA", score = 259 } } },
}
-- The winner ID a slot answers to, and back. BUG_CONTEST_PLAYER is 1.
function BugContest.contestantId(slot) return slot + 1 end
function BugContest.contestantSlot(id) return id - 1 end
-- LoadContestantName: the trainer CLASS name, its trailing terminator replaced
-- by a space, then the trainer's own name appended -- "BUG CATCHER DON". ID 1
-- is the player, whose name is copied straight out of wPlayerName.
--
-- The class and trainer names come from the cache (trainers.lua keys classes
-- by their constant and lists each class's members in order), and fall back to
-- the transcribed row when a class is missing, so a headless test needs no
-- cache at all.
function BugContest.contestantName(data, id, playerName)
if id == BugContest.PLAYER then return playerName or "<PLAYER>" end
local row = BugContest.CONTESTANTS[BugContest.contestantSlot(id)]
if not row then return "" end
local classes = data and data.trainers and data.trainers.classes
local class = classes and classes[row.class]
local className = (class and class.name) or row.class
local member = class and class.trainers and class.trainers[row.trainer]
local trainerName = (member and member.name) or row.name
return className .. " " .. trainerName
end
-- ---------------------------------------------------------------- the score
--
-- ContestScore. Everything it tallies is an EIGHT BIT read out of the party
-- struct, and the struct is big-endian, so `[wContestMonMaxHP + 1]` is the LOW
-- byte of max HP and not the high one. The accumulator itself is 16 bit:
-- .AddContestStat adds into hMultiplicand and carries into the byte below it,
-- which the union in ram/hram.asm makes hProduct, and BugContest_JudgeContestants
-- reads that pair back as the score.
--
-- max HP low byte, four times
-- Attack, Defense, Speed, Special Attack, Special Defense, low bytes
-- a DV term (below)
-- current HP low byte, shifted right three times
-- 1 if the mon is holding an item
--
-- The DV term is the odd one. It reads BIT 1 of four of the DVs -- never bit
-- 0, never the whole nibble -- and weights them 16 for Defense, 8 for Attack,
-- 4 for Special and 1 for Speed:
--
-- ld a, [wContestMonDVs + 0] / and %0010 / add a / add a ; c = 8 * def_bit1
-- swap b / and %0010 / add a / add c ; d = 4 * atk_bit1 + c
-- ld a, [wContestMonDVs + 1] / and %0010 ; c = 2 * spc_bit1
-- swap b / and %0010 / srl a / add c / add c / add d / add d
--
-- which lands on spd_bit1 + 4 * spc_bit1 + 8 * atk_bit1 + 16 * def_bit1.
local function low(value) return math.floor(value or 0) % 256 end
local function bit1(value) return math.floor((value or 0) / 2) % 2 end
function BugContest.score(mon)
-- `ld a, [wContestMonSpecies] / and a / jr z, .done`: an empty slot scores 0.
if not (mon and mon.species) then return 0 end
local stats = mon.stats or {}
local maxHp = mon.maxHp or stats.hp
local total = low(maxHp) * 4
total = total + low(stats.attack) + low(stats.defense) + low(stats.speed)
+ low(stats.specialAttack) + low(stats.specialDefense)
local dvs = mon.dvs or {}
total = total + 16 * bit1(dvs.defense) + 8 * bit1(dvs.attack)
+ 4 * bit1(dvs.special) + bit1(dvs.speed)
total = total + math.floor(low(mon.hp) / 8)
-- `ld a, [wContestMonItem] / and a / jr z, .done`, the last term either way.
if mon.item then total = total + 1 end
return total % 65536
end
-- ---------------------------------------------------------------- the podium
--
-- DetermineContestWinners compares the temp entry against first, then second,
-- then third, with CompareBytes over the two score bytes. CompareBytes only
-- sets carry when the temp score is STRICTLY LESS, so an equal score DISPLACES
-- the sitting entry and pushes it down a place. That is not a rounding detail:
-- it is why the player, who is scored last, wins a tie.
local function beats(entry, incumbent)
return (entry.score or 0) >= ((incumbent and incumbent.score) or 0)
end
local function copyEntry(entry)
if not entry then return nil end
return { id = entry.id, species = entry.species, score = entry.score }
end
function BugContest.placeEntry(results, entry)
if beats(entry, results.first) then
results.third = copyEntry(results.second)
results.second = copyEntry(results.first)
results.first = copyEntry(entry)
elseif beats(entry, results.second) then
results.third = copyEntry(results.second)
results.second = copyEntry(entry)
elseif beats(entry, results.third) then
results.third = copyEntry(entry)
end
return results
end
-- ComputeAIContestantScores' inner roll, for ONE contestant. Two `call Random`
-- bytes: the first picks which of the three listed mons this contestant turned
-- up with (masked to 2 bits and REROLLED on 3, so 0, 1 and 2 are equally
-- likely), the second is a 0..7 bump added to that mon's listed score.
function BugContest.rollContestant(slot, random)
local row = BugContest.CONTESTANTS[slot]
if not row then return nil end
local pick
repeat
pick = byte(random) % 4
until pick ~= 3
local mon = row.mons[pick + 1]
return {
id = BugContest.contestantId(slot),
species = mon.species,
score = mon.score + (byte(random) % 8),
}
end
-- BugContest_JudgeContestants. ClearContestResults wipes the podium,
-- ComputeAIContestantScores walks the ten contestants and skips any whose flag
-- is SET (a set flag is what kept that trainer OFF the contest map, so the five
-- SelectRandomBugContestContestants picked are the five who do NOT score), and
-- only THEN is the player's own entry placed.
--
-- `state.contestants` is that flag table: state.contestants[slot] == true means
-- the flag is set, i.e. absent. A state with no table at all scores all ten,
-- which is what a save from before the picking ran looks like.
function BugContest.judge(state, playerMon, playerScore, random)
local absent = (state and state.contestants) or {}
local results = { first = nil, second = nil, third = nil }
for slot = 1, BugContest.NUM_CONTESTANTS do
if not absent[slot] then
local entry = BugContest.rollContestant(slot, random)
if entry then BugContest.placeEntry(results, entry) end
end
end
BugContest.placeEntry(results, {
id = BugContest.PLAYER,
species = playerMon and playerMon.species or nil,
score = playerScore or BugContest.score(playerMon),
})
return results
end
-- BugContest_GetPlayersResult: walk the podium from THIRD upwards with b
-- counting 3, 2, 1 and stop at the player. Falling off the end leaves b at 0,
-- and that 0 is what sends the script down the consolation-BERRY branch.
function BugContest.playerPlace(results)
local order = { results.third, results.second, results.first }
for index = 1, 3 do
local entry = order[index]
if entry and entry.id == BugContest.PLAYER then return 4 - index end
end
return 0
end
-- _BugContestJudging end to end: score, judge, and leave the placing where
-- BugContestJudging's `ld a, b / ld [wScriptVar], a` leaves it. The results
-- are kept on the state so the gate's three text pages can name each winner.
function BugContest.runJudging(save, random)
local state = BugContest.state(save)
if not state then return 0 end
local mon = state.caught
local score = BugContest.score(mon)
local results = BugContest.judge(state, mon, score, random)
state.results = results
state.playerScore = score
state.place = BugContest.playerPlace(results)
-- bug_contest.scored, a Gen 2 invention: Gen 1 has no contest, so there is
-- no name to share. Raised once per contest, after the podium is settled
-- and before the gate prints it, which is the only moment both the player's
-- number and the three winners exist together.
--
-- mon the mon the player brought out of the park, or nil for none
-- score BugContest.score of it, the number DetermineContestWinners used
-- place 1, 2, 3, or 0 for the consolation BERRY branch
-- results the podium, { first, second, third }, each
-- { id, species, score } with id BugContest.PLAYER for the player
if Runtime.wants("bug_contest.scored") then
Runtime.emit("bug_contest.scored", {
mon = mon, score = score, place = state.place, results = results,
})
end
return state.place
end
-- The prize for a placing, or the consolation BERRY for 0.
function BugContest.prizeFor(place)
return BugContest.PRIZES[place] or BugContest.CONSOLATION_PRIZE
end
-- ---------------------------------------------------- SelectRandomBugContestContestants
--
-- data/events/bug_contest_flags.asm, transcribed: EVENT_BUG_CATCHING_CONTESTANT_1A
-- through _10A, by NUMBER, because wEventFlags is keyed by number. These are
-- the ten NationalParkBugContest object_event flags, in slot order, and they
-- are the *A set -- the *B set is the same ten people standing in
-- Route36NationalParkGate before the contest starts and nothing here touches
-- it.
--
-- The extractor now writes the same ten as events.bugContestFlags, so this is
-- the pinned fallback for a cache that predates it rather than a second source
-- of truth; tests/gen2_contest_test.lua compares the two row by row.
BugContest.FLAGS = {
1814, 1815, 1816, 1817, 1818, 1819, 1820, 1821, 1822, 1823,
}
-- data/generated/events.lua's copy when it is there, the transcription above
-- when it is not. `tables` is the eventTables the VM carries, not the whole
-- cache: the flag table is a side table a command NAMES, the same as the
-- trades and the floor labels.
function BugContest.contestantFlags(tables)
local extracted = tables and tables.bugContestFlags
if type(extracted) == "table" and #extracted == BugContest.NUM_CONTESTANTS then
return extracted
end
return BugContest.FLAGS
end
-- Five flags chosen at uniform random out of ten, rejecting a duplicate rather
-- than reshuffling: `call Random / cp $ff / 10 * 10 / jr nc` throws away any
-- byte 250 or over, divides by 25 to land on 0..9, and rerolls a slot whose
-- flag is already set. A SET flag hides that contestant's sprite, so these
-- five are the ones NOT in the park -- and, downstream, the five
-- ComputeAIContestantScores skips.
function BugContest.pickContestants(save, random)
local state = BugContest.state(save)
if not state then return nil end
local n = BugContest.NUM_CONTESTANTS
local limit = math.floor(0xff / n) * n
local step = math.floor(0xff / n)
local chosen = {}
local picked = 0
while picked < BugContest.CONTESTANTS_PICKED do
local roll
repeat
roll = byte(random)
until roll < limit
-- SimpleDivide's quotient, i.e. 0..9, then 1-based for the Lua table.
local slot = math.floor(roll / step) + 1
if not chosen[slot] then
chosen[slot] = true
picked = picked + 1
end
end
state.contestants = chosen
return chosen
end
-- The half of SelectRandomBugContestContestants that touches wEventFlags, and
-- the reason it is a separate call: `.loop1` runs EventFlagAction RESET_FLAG
-- over ALL TEN before a single pick is made. That reset is not tidiness --
-- without it last contest's five are still hidden, and the two contests
-- together would empty the park. So every slot is written here, true for a
-- picked one and false for the rest, rather than only the five being set.
--
-- `events` is src/world/gen2/Events.lua (wEventFlags); a nil one is a headless
-- caller with no flag store, which changes nothing else about the pick.
function BugContest.applyContestantFlags(events, chosen, tables)
if not events then return nil end
local flags = BugContest.contestantFlags(tables)
chosen = chosen or {}
for slot = 1, BugContest.NUM_CONTESTANTS do
local flag = flags[slot]
if flag then events:set(flag, chosen[slot] == true) end
end
return flags
end
-- ------------------------------------------------------- entering and leaving
--
-- ContestDropOffMons. The party is not stored anywhere on the cart, it is
-- MASKED: wPartyCount is written down to 1 and the second species byte is
-- replaced with the -1 terminator, so for the duration only the lead mon
-- exists. A Lua list cannot be truncated in place and restored, so the tail
-- moves to state.stash -- which MUST live on the save, because the cart's
-- masked-off party is still sitting in SRAM and survives a save and reload
-- mid-contest.
--
-- Returns FALSE (0) on success and TRUE (1) when the lead mon has fainted,
-- which is the wScriptVar the officer's `iftrue` branches on.
function BugContest.dropOffMons(save)
local state = BugContest.state(save)
local party = save and save.party
if not (state and party) then return 1 end
local lead = party[1]
if not lead or (lead.hp or 0) <= 0 then return 1 end
local stash = {}
for index = 2, #party do stash[#stash + 1] = party[index] end
state.stash = stash
for index = #party, 2, -1 do party[index] = nil end
return 0
end
-- ContestReturnMons. The species of the second mon goes back and the party
-- count is RECOMPUTED by walking to the terminator, which is why a mon caught
-- during the contest -- already sitting in slot 2 by the time this runs -- is
-- kept and the tail lands BEHIND it rather than over it.
function BugContest.returnMons(save)
local state = BugContest.state(save)
local party = save and save.party
if not (state and party) then return end
for _, mon in ipairs(state.stash or {}) do
party[#party + 1] = mon
end
state.stash = nil
end
-- GiveParkBalls, plus the StartBugContestTimer it farcalls. wContestMon is
-- cleared first, so entering a second contest cannot inherit the last one's
-- catch.
function BugContest.start(save, now)
local state = BugContest.state(save)
if not state then return nil end
state.active = true
state.caught = nil
state.balls = BugContest.BALLS
state.minutes = BugContest.MINUTES
state.seconds = BugContest.SECONDS
state.results = nil
state.place = nil
state.playerScore = nil
local stamp = now or BugContest.now()
state.startTime = { day = stamp.day, hour = stamp.hour,
minute = stamp.minute, second = stamp.second }
return state
end
-- BugContestResultsScript's `clearflag ENGINE_BUG_CONTEST_TIMER`, and the
-- clean-up around it. The caught mon is deliberately LEFT on the state:
-- CheckPartyFullAfterContest is what consumes it, and it runs after this.
function BugContest.stop(save)
local state = BugContest.state(save)
if not state then return end
state.active = false
state.minutes = 0
state.seconds = 0
state.startTime = nil
end
-- CheckBugContestTimer. Called from CheckTimeEvents while
-- ENGINE_BUG_CONTEST_TIMER is set, and returning true is what makes the
-- overworld call BugCatchingContestOverScript.
--
-- Any whole day or hour of elapsed time ends it outright. Otherwise the
-- seconds are subtracted with a wrap, and the BORROW that wrap produces is
-- carried into the minutes by the `sbc` -- the `add 60` that rewraps the
-- seconds always overflows a byte (the largest possible shortfall is 59), so
-- the carry it leaves behind is the borrow, not an accident. A minute count
-- that goes negative is the timeout.
function BugContest.tickTimer(save, now)
local state = BugContest.state(save)
if not (state and state.active and state.startTime) then return false end
local since = BugContest.elapsedSince(state.startTime, now, "second")
if since.days ~= 0 or since.hours ~= 0 then
state.minutes, state.seconds = 0, 0
return true
end
local seconds, borrow = borrowed((state.seconds or 0) - since.seconds, 60)
state.seconds = seconds
local minutes = (state.minutes or 0) - since.minutes - borrow
if minutes < 0 then
state.minutes, state.seconds = 0, 0
return true
end
state.minutes = minutes
return false
end
function BugContest.timeLeft(save)
local state = BugContest.state(save)
if not state then return 0, 0 end
return state.minutes or 0, state.seconds or 0
end
-- ------------------------------------------------------------- the park balls
--
-- ContestBattleMenu's third row is "PARKBALL×" followed by
-- wParkBallsRemaining, and PokeBallEffect's `.used_park_ball` does `dec [hl]`
-- instead of tossing an item out of the pack -- so a Park Ball is never in the
-- bag, never taken from it, and never restored.
function BugContest.ballsLeft(save)
local state = BugContest.state(save)
return (state and state.balls) or 0
end
function BugContest.useBall(save)
local state = BugContest.state(save)
if not state then return 0 end
state.balls = math.max(0, (state.balls or 0) - 1)
return state.balls
end
-- CheckContestBattleOver: no balls left is a DRAW and ends the battle, and
-- BugCatchingContestBattleScript's `readmem wParkBallsRemaining / iffalse`
-- then sends the player back to the gate.
function BugContest.isOver(save)
return BugContest.ballsLeft(save) <= 0
end
-- ----------------------------------------------------------- the caught mon
--
-- BugContest_SetCaughtContestMon. With no stock mon the catch is kept
-- outright; with one, the player is shown the STOCK versus THIS comparison and
-- asked, and a YES swaps. PlaceYesNoBox's `ret c` is the NO, so the DEFAULT --
-- cancelling out of the box with B -- keeps the mon already in stock.
BugContest.KEEP_FIRST = "first" -- .firstcatch, no question asked
BugContest.ASK_SWITCH = "switch" -- DisplayCaughtContestMonStats, then yes/no
function BugContest.catch(save, mon)
local state = BugContest.state(save)
if not state then return nil end
BugContest.useBall(save)
if not state.caught then
state.caught = mon
return BugContest.KEEP_FIRST
end
return BugContest.ASK_SWITCH, state.caught, mon
end
-- The YES arm of that question.
function BugContest.switchCaught(save, mon)
local state = BugContest.state(save)
if not state then return nil end
state.caught = mon
return mon
end
function BugContest.caughtMon(save)
local state = BugContest.state(save)
return state and state.caught or nil
end
-- ------------------------------------------------- CheckPartyFullAfterContest
--
-- The catch joins the party if there is room and goes to the current box if
-- there is not, and the answer is the three-way wScriptVar the gate branches
-- on. Boxing needs src/core/gen2/Boxes.lua, which is required lazily so this
-- module stays loadable on its own.
function BugContest.collectCaughtMon(save, partySize, boxes)
local state = BugContest.state(save)
local mon = state and state.caught
if not mon then return BugContest.NO_CATCH end
state.caught = nil
-- caught_nickname.asm:34-39 copies wPlayerName when the contest mon joins.
require("src.battle.gen2.Mon").stampOT(save, mon)
local party = save.party or {}
save.party = party
partySize = partySize or 6
if #party < partySize then
party[#party + 1] = mon
return BugContest.CAUGHT_MON, mon
end
boxes = boxes or require("src.core.gen2.Boxes")
local box = boxes.box(save, save.currentBox or 1)
if box then box[#box + 1] = mon end
return BugContest.BOXED_MON, mon
end
-- ------------------------------------------------------------- the module map
--
-- What each special in data/events/special_pointers.asm should call, so the
-- half of this system that lives in src/script/gen2/Specials.lua meets this
-- half exactly once:
--
-- ContestDropOffMons BugContest.dropOffMons(save) -> scriptVar
-- ContestReturnMons BugContest.returnMons(save)
-- GiveParkBalls BugContest.start(save)
-- BugContestJudging BugContest.runJudging(save) -> scriptVar
-- CheckPartyFullAfterContest BugContest.collectCaughtMon(save)
-- -> scriptVar
-- SelectRandomBugContestContestants BugContest.pickContestants(save) and
-- BugContest.applyContestantFlags(events,
-- chosen, eventTables)
--
-- and outside the specials table:
--
-- CheckTimeEvents BugContest.tickTimer(save) -> ended?
-- ChooseWildEncounter_BugContest BugContest.chooseWild(data)
-- TryWildEncounter_BugContest BugContest.triggers(superTall)
-- PokeBallEffect .used_park_ball BugContest.catch(save, mon)
BugContest.SCREEN_ID = "Gen2ContestMenu"
return BugContest
+137
View File
@@ -0,0 +1,137 @@
-- The DUDE's catching demonstration (engine/events/catch_tutorial.asm).
--
-- `catchtutorial BATTLETYPE_TUTORIAL` on Route 29 is a REAL battle, not a
-- cutscene: CatchTutorial swaps the player's name for the DUDE's, hands him a
-- pack of his own, arms an auto-input stream and then farcalls StartBattle.
-- Everything the DUDE does inside that battle is the auto-input ring
-- (src/core/gen2/AutoInput.lua) answering the prompts, which is why the demo
-- reads as somebody playing rather than as a scripted animation.
--
-- What BATTLETYPE_TUTORIAL changes inside the battle itself, all of it from
-- engine/battle/core.asm and engine/items/item_effects.asm:
--
-- * no mon is sent out (`jp z, .tutorial_debug` straight to BattleMenu), so
-- the player's box keeps a trainer back-pic for the whole battle and there
-- is no player HUD;
-- * GetTrainerBackpic swaps ChrisBackpic for DudeBackpic;
-- * BattleMenu skips UpdateBattleHuds and EmptyBattleTextbox, so whatever
-- the textbox already said stays under the menu;
-- * BattleMenu_Pack takes `.tutorial`: TutorialPack shows the DUDE's pack,
-- its answer is thrown away (`wPackUsedItem` = FALSE) and POKE_BALL is
-- used regardless;
-- * PokeBallEffect jumps to `.catch_without_fail`, and its tail returns
-- early for a tutorial battle, so nothing is added to the party, nothing
-- is written to the Pokedex and no ball is taken out of the bag.
--
-- The port keeps all of that here and in src/ui/gen2/BattleState.lua's
-- `tutorial` arm rather than in a Gen 1-shaped fork.
local AutoInput = require("src.core.gen2.AutoInput")
local CatchTutorial = {}
-- CatchTutorial.Dude: `db "DUDE@"`.
CatchTutorial.DUDE_NAME = "DUDE"
-- wBattleType (constants/battle_constants.asm), the value Route 29's three
-- `catchtutorial` commands carry.
CatchTutorial.BATTLETYPE_TUTORIAL = 3
-- .LoadDudeData, as a flat id -> count bag of the shape PackMenu reads.
--
-- The POKE_BALL count really is 5: the routine writes the ball's own item id
-- into the quantity byte as well,
--
-- ld a, POKE_BALL
-- ld [hli], a ; the item
-- ld [hli], a ; its quantity
--
-- and POKE_BALL is 5 in constants/item_constants.asm. It is invisible on the
-- cart only because the DUDE never gets to a second throw. Reproduced rather
-- than tidied to 1, the same way src/battle/gen2/Catching.lua reproduces the
-- catch-rate bugs: a "fixed" pack shows the player a screen the game never
-- draws.
CatchTutorial.PACK = { POTION = 1, POKE_BALL = 5 }
-- The ball the demo always throws, whatever TutorialPack came back with.
CatchTutorial.BALL = "POKE_BALL"
-- The four re-arm points, by the stream name in AutoInput.STREAMS:
-- PROMPT home/joypad.asm .wait_input, every text box that waits for A
-- MENU engine/battle/core.asm BattleMenu, which picks ITEM
-- PACK engine/items/pack.asm TutorialPack, which crosses to the BALL
-- pocket and picks the POKE BALL
-- and CatchTutorial's own stream, which is armed around StartBattle and does
-- nothing but hold the player's hands off the controller.
CatchTutorial.PROMPT_STREAM = "DUDE_A"
CatchTutorial.MENU_STREAM = "DUDE_DOWN_A"
CatchTutorial.PACK_STREAM = "DUDE_RIGHT_A"
CatchTutorial.BATTLE_STREAM = "CATCH_TUTORIAL"
-- Arm one of the streams above on the ring, if there is one. Every re-arm in
-- the ASM is guarded by `ld a, [wInputType] / or a / jr z, .skip`: the DUDE
-- only answers while an auto-input stream is already running, so a player who
-- somehow reaches these prompts by hand is never pushed around by them.
--
-- `skipIdle` drops the leading blank pairs of a stream a MENU consumes; see
-- AutoInput:skipIdle for why the two kinds of stream are paced differently.
function CatchTutorial.rearm(ring, stream, input, skipIdle)
if not (ring and ring.isActive and ring:isActive()) then return false end
if not AutoInput.STREAMS[stream] then return false end
if not ring:start(stream, input) then return false end
if skipIdle then ring:skipIdle() end
return true
end
-- The pack TutorialPack draws: wDudeNumItems / wDudeNumBalls are their own
-- buffers, so this is a save-shaped shim rather than a swap of the real bag.
-- The DUDE's name rides along because the PACK's own text addresses the
-- trainer whose bag it is.
function CatchTutorial.dudeSave()
local inventory = {}
for id, count in pairs(CatchTutorial.PACK) do inventory[id] = count end
return {
player = { name = CatchTutorial.DUDE_NAME },
inventory = inventory,
}
end
-- The bracket CatchTutorial puts around StartBattle, in the ASM's order:
-- back the player's name up into wMomsName, copy DUDE over it, then force the
-- text delay to TEXT_DELAY_MED so the demo reads at one speed whatever the
-- player set. Returns the state CatchTutorial.finish needs to undo it.
function CatchTutorial.begin(save, options)
local player = save and save.player
local state = {
name = player and player.name,
textSpeed = options and options.textSpeed,
}
if player then
-- `ld hl, wPlayerName / ld de, wMomsName / call CopyBytes`. This is not
-- scratch space: wMomsName is where InitializeNPCNames put "MOM", and the
-- tutorial overwrites it and never puts it back, so from here on the <MOM>
-- character prints the player's name. A real, observable cart quirk, kept
-- for the same reason the catch-rate bugs are kept.
save.mom = save.mom or {}
save.mom.name = player.name
player.name = CatchTutorial.DUDE_NAME
end
if options then
-- `and ~TEXT_DELAY_MASK / add TEXT_DELAY_MED`: only the delay field is
-- touched, every other option bit survives.
options.textSpeed = "MID"
end
return state
end
-- The tail of .DudeTutorial: `pop af / ld [wOptions], a`, then the player's
-- name is copied back out of wMomsName. Mom's name is NOT restored, because
-- the cart has nowhere left to restore it from.
function CatchTutorial.finish(save, options, state)
state = state or {}
local player = save and save.player
if player and state.name then player.name = state.name end
if options and state.textSpeed then options.textSpeed = state.textSpeed end
end
return CatchTutorial
+131
View File
@@ -0,0 +1,131 @@
-- The game clock, as the cart keeps it (home/time.asm, engine/rtc/timeset.asm).
--
-- Gold does not store "the time". It stores wStartHour / wStartMinute /
-- wStartDay -- the RTC reading at the moment the player answered Oak -- and
-- every read is CalcNSecsHoursDaysSince: the RTC now, MINUS that base, plus
-- what the player said it was. That is why setting the clock to 10 AM does
-- not stop it: it only re-anchors the offset the RTC is read through.
--
-- The port has no battery-backed RTC to read, so `now` is the host clock and
-- the base is the host clock at the moment the player answered. The stored
-- pair is the same pair the cart stores, so the arithmetic below IS
-- InitTime's, not a second clock: a save with no base at all reads the host
-- clock straight through, which is what every save made before this did.
--
-- Lives here rather than on World because two screens and one special write
-- it (src/ui/gen2/InitClock.lua, src/script/gen2/Specials.lua SetDayOfWeek)
-- and World only ever reads it.
local Runtime = require("src.mods.Runtime")
local Clock = {}
Clock.MINUTES_PER_DAY = 24 * 60
Clock.DAYS = 7
-- InitClock's own default: `ld a, 10 ; default hour = 10 AM`, with the minute
-- buffer left at the zero ByteFill put there.
Clock.DEFAULT_HOUR = 10
Clock.DEFAULT_MINUTE = 0
local function hostMinutes()
local hour = tonumber(os.date("%H")) or 0
local minute = tonumber(os.date("%M")) or 0
return (hour * 60 + minute) % Clock.MINUTES_PER_DAY
end
local function hostWeekday()
-- os.date("%w") is Sunday 0, and constants/misc_constants.asm's SUNDAY is 0
-- too, so the two agree without a shift.
return (tonumber(os.date("%w")) or 0) % Clock.DAYS
end
Clock.hostMinutes = hostMinutes
Clock.hostWeekday = hostWeekday
local function rtc(save)
return type(save) == "table" and save.rtc or nil
end
-- clock.day_changed, a Gen 2 invention: Gen 1 has no clock at all, so there is
-- no name to share. The cart has no "day changed" routine either -- everything
-- daily is a countdown compared against wCurDay when it is next read -- so the
-- event is raised off the read that IS GetWeekday: every consumer of the day
-- (VAR_WEEKDAY, the world.tod ctx, the Pokegear clock card, the daily resets)
-- goes through Clock.weekday, so a rollover cannot get past this.
--
-- day the weekday now, SUNDAY 0 .. SATURDAY 6
-- previous the weekday the last read answered
-- reason "rollover" for the host clock crossing midnight, "set" for
-- Mom's wheel re-anchoring the day (src/ui/gen2/InitClock.lua)
--
-- The last-seen day is process-local rather than saved: the first read after a
-- boot has nothing to compare against and reports nothing, which is why a
-- Gold boot does not open with a spurious day change. It is only maintained
-- while somebody is subscribed, which is what keeps a mod-free boot free.
local lastDay = nil
local function noteDay(day, reason)
if not Runtime.wants("clock.day_changed") then
lastDay = nil
return day
end
local previous = lastDay
lastDay = day
if previous ~= nil and previous ~= day then
Runtime.emit("clock.day_changed",
{ day = day, previous = previous, reason = reason })
end
return day
end
-- _InitTime: store the base so that reading it back answers `hour:minute`.
function Clock.setTime(save, hour, minute)
if type(save) ~= "table" then return false end
save.rtc = save.rtc or {}
local wanted = (math.floor(hour or 0) % 24) * 60
+ (math.floor(minute or 0) % 60)
save.rtc.startMinute = (wanted - hostMinutes()) % Clock.MINUTES_PER_DAY
return true
end
-- InitDayOfWeek, which is the same anchor for wCurDay.
function Clock.setWeekday(save, day)
if type(save) ~= "table" then return false end
save.rtc = save.rtc or {}
save.rtc.startDay = (math.floor(day or 0) - hostWeekday()) % Clock.DAYS
save.rtc.dayOfWeek = math.floor(day or 0) % Clock.DAYS
noteDay(save.rtc.dayOfWeek, "set")
return true
end
-- The clock the game reads: the host clock through the stored offset.
function Clock.minutes(save)
local r = rtc(save)
local offset = r and tonumber(r.startMinute) or 0
return (hostMinutes() + offset) % Clock.MINUTES_PER_DAY
end
function Clock.hour(save)
return math.floor(Clock.minutes(save) / 60)
end
function Clock.minute(save)
return Clock.minutes(save) % 60
end
-- GetWeekday, and the poll site clock.day_changed is raised from (see noteDay).
function Clock.weekday(save)
local r = rtc(save)
local offset = r and tonumber(r.startDay) or 0
return noteDay((hostWeekday() + offset) % Clock.DAYS, "rollover")
end
-- True once the player has actually answered Oak, so a caller can tell "10 AM
-- because that is what the host says" from "10 AM because the player set it".
function Clock.isSet(save)
local r = rtc(save)
return r ~= nil and r.startMinute ~= nil
end
return Clock
+70
View File
@@ -0,0 +1,70 @@
-- The player's coin case: engine/events/money.asm GiveCoins / TakeCoins /
-- CheckCoins, transcribed onto save.player.coins.
--
-- This used to live inside src/ui/gen2/PrizeMenu.lua, which is a registered
-- screen module (Screens.lua id Gen2PrizeMenu). The slot machine and card
-- flip screens read the same case, so a mod that replaces the prize-counter
-- screen has no business also replacing the coin case those other two
-- screens depend on. It is model, not menu, so it lives here instead.
--
-- MAX_COINS is 9999 (constants/misc_constants.asm) and Save.MAX_COINS is the
-- same number on the save side (src/core/gen2/Save.lua), which is also where
-- Save.normalize re-clamps a loaded file.
local Save = require("src.core.gen2.Save")
local CoinCase = {}
CoinCase.MAX_COINS = Save.MAX_COINS
function CoinCase.coins(save)
local player = save and save.player
return (player and player.coins) or 0
end
-- GiveCoins: add, and if the total passes MAX_COINS write MAX_COINS back and
-- return carry. Returns the new balance and whether the case capped.
function CoinCase.giveCoins(save, amount)
local player = save and save.player
if not player then return 0, false end
local total = (player.coins or 0) + math.floor(amount or 0)
if total >= CoinCase.MAX_COINS then
player.coins = CoinCase.MAX_COINS
return player.coins, true
end
player.coins = total
return total, false
end
-- TakeCoins: subtract, and on borrow leave the case at zero rather than
-- wrapping (`; leave with 0 coins`).
function CoinCase.takeCoins(save, amount)
local player = save and save.player
if not player then return 0, false end
local total = (player.coins or 0) - math.floor(amount or 0)
if total < 0 then
player.coins = 0
return 0, true
end
player.coins = total
return total, false
end
-- CheckCoins -> CompareMoneyAction, which writes wScriptVar.
-- constants/script_constants.asm: HAVE_MORE 0, HAVE_AMOUNT 1, HAVE_LESS 2.
CoinCase.HAVE_MORE = 0
CoinCase.HAVE_AMOUNT = 1
CoinCase.HAVE_LESS = 2
function CoinCase.checkCoins(save, amount)
local have = CoinCase.coins(save)
amount = math.floor(amount or 0)
if have < amount then return CoinCase.HAVE_LESS end
if have == amount then return CoinCase.HAVE_AMOUNT end
return CoinCase.HAVE_MORE
end
function CoinCase.canAfford(save, cost)
return CoinCase.checkCoins(save, cost) ~= CoinCase.HAVE_LESS
end
return CoinCase
+117
View File
@@ -0,0 +1,117 @@
-- The strings an ENGINE routine prints, looked up by their pokegold label.
--
-- data/generated/text.lua is keyed by "bank:addr", because every string in it
-- was found by following a script pointer. The Day-Care, the POKeMART and the
-- Hall of Fame are printed by asm instead (`ld hl, .SomeText / call
-- PrintText`), so RomExtractorGen2's NAMED_TEXT seeds the walker at those
-- symbols by name and writes text.labels[label] -> that key. This is the
-- lookup on the other side of that table: a screen asks for
-- "_MartWelcomeText" and never for an address, so a repointed string still
-- resolves and a cache built before the seed simply answers nil.
--
-- `pages` puts the decoded stream back into the shape the Gen 2 speech box
-- draws it in -- up to two lines per screenful -- following home/text.asm:
--
-- \n `line` / `next`: the box's second row.
-- \f `para`: PlaceString clears the box, so the next screenful starts empty.
-- \v `cont`: the box SCROLLS one row, so the line that was on the bottom
-- row is now on the top one and the new text lands under it. That is
-- why a `cont` shows up here as a page whose first line repeats the
-- previous page's second.
--
-- `fill` substitutes the markers the decoder leaves behind for the values the
-- cart splices at runtime: {STRBUF} for a TX_RAM name and {NUM} for a
-- TX_DECIMAL PrintNum field, both in the order they appear, plus the named
-- {PLAYER} / {RIVAL}.
local CommonText = {}
-- The decoded string for a pokegold label, or nil when this cache predates
-- the seed (or the string is genuinely empty, like _DaycareDummyText).
function CommonText.get(text, label)
if type(text) ~= "table" or not label then return nil end
local labels = text.labels
local key = type(labels) == "table" and labels[label]
local body = key and text[key]
if type(body) ~= "string" or body == "" then return nil end
return body
end
-- One page is an array of one or two lines.
function CommonText.pages(body)
if type(body) ~= "string" or body == "" then return nil end
local out = {}
local top, bottom = "", nil
local function flush()
if bottom then
out[#out + 1] = { top, bottom }
else
out[#out + 1] = { top }
end
end
local i = 1
while i <= #body do
local marker = body:find("[\n\f\v]", i)
local chunk = body:sub(i, (marker or (#body + 1)) - 1)
if bottom then
bottom = bottom .. chunk
else
top = top .. chunk
end
if not marker then break end
local m = body:sub(marker, marker)
if m == "\n" then
bottom = bottom or ""
elseif m == "\f" then
flush()
top, bottom = "", nil
else -- "\v"
flush()
top, bottom = bottom or "", ""
end
i = marker + 1
end
flush()
return out
end
-- values: an array consumed in order by {STRBUF} and {NUM}, and optionally
-- values.player / values.rival for the two named markers.
function CommonText.fill(pages, values)
if not pages then return nil end
values = values or {}
local next_ = 1
-- One pass over both markers, because the order they are CONSUMED in is the
-- order they appear in: _MartFinalPriceText opens on its {NUM} and
-- _BargainShopFinalPriceText on its {STRBUF}, and filling one kind before
-- the other would swap the price and the item name on one of them.
local function marker(name)
if name == "PLAYER" or name == "RIVAL" then
return values[name:lower()] or ("{" .. name .. "}")
end
if name ~= "STRBUF" and name ~= "NUM" then return "{" .. name .. "}" end
local v = values[next_]
next_ = next_ + 1
return v ~= nil and tostring(v) or ""
end
local out = {}
for p, page in ipairs(pages) do
local lines = {}
for l, line in ipairs(page) do
lines[l] = (line:gsub("{(%u+)}", marker))
end
out[p] = lines
end
return out
end
-- The whole lookup in one call: nil when the cache has no such label, which
-- is every call site's cue to fall back to its own transcription.
function CommonText.of(text, label, values)
local pages = CommonText.pages(CommonText.get(text, label))
if not pages then return nil end
if values then return CommonText.fill(pages, values) end
return pages
end
return CommonText
+523
View File
@@ -0,0 +1,523 @@
-- The ornaments in the player's bedroom: what the player owns, what is set up
-- where, and the two routines that put both on the map.
-- engine/overworld/decorations.asm, with data/decorations/attributes.asm,
-- names.asm and decorations.asm beside it.
--
-- Three separate pieces of state, and keeping them apart is the whole model:
--
-- OWNED one wEventFlags bit per decoration (DECOATTR_EVENT_FLAG). Set
-- means the player has it; nothing else ever clears one. This is
-- the same bitfield `setevent` writes, which is why owning a
-- decoration survives in the save with no new field.
-- PLACED eight bytes (wDecoBed .. wDecoRightOrnament), each holding the
-- DECO_* id standing in that slot or 0 for nothing. A slot holds
-- ONE thing: setting up a second bed puts the first away.
-- VISIBLE what the map shows, which is neither of the above. It is
-- rebuilt from PLACED by ToggleDecorationsVisibility (the four
-- object slots) and ToggleMaptileDecorations (the four blocks) --
-- and only ever on a MAP LOAD, because those are the
-- PLAYERS_HOUSE_2F NEWMAP and TILES callbacks. A flag a running
-- script sets does not move an object; the object list is read
-- when the map loads and not again, which is why the PC's own
-- `warp NONE, 0, 0` (Script_warp's MAPSETUP_BADWARP arm) is what
-- makes a placement appear.
--
-- Everything here is love-free and takes its state by argument, so the menu on
-- top of it (src/ui/gen2/DecorationMenu.lua) and the tests can drive the same
-- routines the map callbacks do.
local Strings = require("src.core.Strings")
local Decorations = {}
-- constants/deco_constants.asm, decoration types. The type decides how
-- GetDecoName spells the row and, for the four maptile kinds, that
-- DECOATTR_SPRITE is a BLOCK id rather than a sprite one.
local PLANT, BED, CARPET, POSTER, DOLL, BIGDOLL = 1, 2, 3, 4, 5, 6
-- The eight wDeco* bytes. `slot` on an action names one of these.
Decorations.SLOTS = {
"bed", "carpet", "plant", "poster", "console", "bigDoll",
"leftOrnament", "rightOrnament",
}
-- DoDecorationAction2.DecoActions, as a slot plus a direction rather than a
-- jumptable index: the fourteen entries are seven pairs, and the pair is the
-- only thing any caller cares about. The ornament pair is the odd one out --
-- it asks which side first, so its slot is decided at run time.
local ACTIONS = {
SET_UP_BED = { slot = "bed" },
PUT_AWAY_BED = { slot = "bed", put = true },
SET_UP_CARPET = { slot = "carpet" },
PUT_AWAY_CARPET = { slot = "carpet", put = true },
SET_UP_PLANT = { slot = "plant" },
PUT_AWAY_PLANT = { slot = "plant", put = true },
SET_UP_POSTER = { slot = "poster" },
PUT_AWAY_POSTER = { slot = "poster", put = true },
SET_UP_CONSOLE = { slot = "console" },
PUT_AWAY_CONSOLE = { slot = "console", put = true },
SET_UP_BIG_DOLL = { slot = "bigDoll" },
PUT_AWAY_BIG_DOLL = { slot = "bigDoll", put = true },
SET_UP_DOLL = { ornament = true },
PUT_AWAY_DOLL = { ornament = true, put = true },
}
Decorations.ACTIONS = ACTIONS
-- wEventFlags bit numbers, from constants/event_flags.asm. Numbers rather
-- than names because that is what the bitfield is keyed by everywhere else in
-- this port (src/world/gen2/Events.lua), and because the extracted scripts
-- that share these bits carry numbers too.
local EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1 = 0
local EVENT_DECO_BED_1 = 676
local EVENT_DECO_CARPET_1 = 680
local EVENT_DECO_PLANT_1 = 684
local EVENT_DECO_POSTER_1 = 687
local EVENT_DECO_FAMICOM = 691
local EVENT_DECO_PIKACHU_DOLL = 695
local EVENT_PLAYERS_ROOM_POSTER = 716
local EVENT_DECO_GOLD_TROPHY = 717
local EVENT_DECO_SILVER_TROPHY = 718
local EVENT_DECO_BIG_SNORLAX_DOLL = 719
Decorations.EVENT_PLAYERS_ROOM_POSTER = EVENT_PLAYERS_ROOM_POSTER
-- The four objects PLAYERS_HOUSE_2F hangs its decorations off. Each is a
-- wVariableSprites slot (SPRITE_VARS-relative, the way `variablesprite`'s byte
-- already is) paired with the object's own event flag, and
-- ToggleDecorationVisibility writes both: the sprite byte says WHAT stands
-- there and the flag says WHETHER it stands there at all.
Decorations.OBJECT_SLOTS = {
{ slot = "console", sprite = 0, flag = 1857 }, -- SPRITE_CONSOLE
{ slot = "leftOrnament", sprite = 1, flag = 1858 }, -- SPRITE_DOLL_1
{ slot = "rightOrnament", sprite = 2, flag = 1859 }, -- SPRITE_DOLL_2
{ slot = "bigDoll", sprite = 3, flag = 1860 }, -- SPRITE_BIG_DOLL
}
-- data/decorations/attributes.asm, verbatim and in its order: row 0 is the
-- unnamed CANCEL row every category menu ends on, and the seven rows whose
-- name is PUT_IT_AWAY are the category headers the deco constants share their
-- numbering with (BEDS = 1, CARPETS = 6, ...). So this table is indexed by
-- DECO_*, and `wMenuSelection` on the cart is an index straight into it.
--
-- `sprite` is one byte with two meanings, exactly as DECOATTR_SPRITE is: a
-- BLOCK id for the four kinds ToggleMaptileDecorations paints, and a SPRITE_*
-- byte for the four an object stands on. The SPRITE_* names are in comments
-- because the value the cart stores IS the byte -- wVariableSprites holds it
-- raw and World:resolveSprite looks it up in constants.spriteOrder.
local function deco(kind, name, action, flag, sprite)
return { type = kind, name = name, action = action, flag = flag,
sprite = sprite }
end
local TEMP = EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1
local ATTRIBUTES = {
[0] = deco(PLANT, "CANCEL", nil, TEMP, 0),
deco(PLANT, "PUT IT AWAY", "PUT_AWAY_BED", TEMP, 0), -- BEDS
deco(BED, "FEATHERY", "SET_UP_BED", EVENT_DECO_BED_1 + 0, 0x1b),
deco(BED, "PINK", "SET_UP_BED", EVENT_DECO_BED_1 + 1, 0x1c),
deco(BED, "POLKADOT", "SET_UP_BED", EVENT_DECO_BED_1 + 2, 0x1d),
deco(BED, "PIKACHU", "SET_UP_BED", EVENT_DECO_BED_1 + 3, 0x1e),
deco(PLANT, "PUT IT AWAY", "PUT_AWAY_CARPET", TEMP, 0), -- CARPETS
deco(CARPET, "RED", "SET_UP_CARPET", EVENT_DECO_CARPET_1 + 0, 0x08),
deco(CARPET, "BLUE", "SET_UP_CARPET", EVENT_DECO_CARPET_1 + 1, 0x0b),
deco(CARPET, "YELLOW", "SET_UP_CARPET", EVENT_DECO_CARPET_1 + 2, 0x0e),
deco(CARPET, "GREEN", "SET_UP_CARPET", EVENT_DECO_CARPET_1 + 3, 0x11),
deco(PLANT, "PUT IT AWAY", "PUT_AWAY_PLANT", TEMP, 0), -- PLANTS
deco(PLANT, "MAGNAPLANT", "SET_UP_PLANT", EVENT_DECO_PLANT_1 + 0, 0x20),
deco(PLANT, "TROPICPLANT", "SET_UP_PLANT", EVENT_DECO_PLANT_1 + 1, 0x21),
deco(PLANT, "JUMBOPLANT", "SET_UP_PLANT", EVENT_DECO_PLANT_1 + 2, 0x22),
deco(PLANT, "PUT IT AWAY", "PUT_AWAY_POSTER", TEMP, 0), -- POSTERS
-- The TOWN MAP poster is a DECO_PLANT: its name is a DecorationNames entry
-- rather than a species, so GetDecoName must not append " POSTER" to it.
deco(PLANT, "TOWN MAP", "SET_UP_POSTER", EVENT_DECO_POSTER_1 + 0, 0x1f),
deco(POSTER, "PIKACHU", "SET_UP_POSTER", EVENT_DECO_POSTER_1 + 1, 0x23),
deco(POSTER, "CLEFAIRY", "SET_UP_POSTER", EVENT_DECO_POSTER_1 + 2, 0x24),
deco(POSTER, "JIGGLYPUFF", "SET_UP_POSTER", EVENT_DECO_POSTER_1 + 3, 0x25),
deco(PLANT, "PUT IT AWAY", "PUT_AWAY_CONSOLE", TEMP, 0), -- CONSOLES
deco(PLANT, "NES", "SET_UP_CONSOLE", EVENT_DECO_FAMICOM + 0, 0x5c), -- SPRITE_FAMICOM
deco(PLANT, "SUPER NES", "SET_UP_CONSOLE", EVENT_DECO_FAMICOM + 1, 0x5b),
deco(PLANT, "NINTENDO64", "SET_UP_CONSOLE", EVENT_DECO_FAMICOM + 2, 0x51),
deco(PLANT, "VIRTUAL BOY", "SET_UP_CONSOLE", EVENT_DECO_FAMICOM + 3, 0x57),
deco(PLANT, "PUT IT AWAY", "PUT_AWAY_BIG_DOLL", TEMP, 0), -- BIG_DOLLS
deco(BIGDOLL, "SNORLAX", "SET_UP_BIG_DOLL", EVENT_DECO_BIG_SNORLAX_DOLL + 0, 0x33),
deco(BIGDOLL, "ONIX", "SET_UP_BIG_DOLL", EVENT_DECO_BIG_SNORLAX_DOLL + 1, 0x50),
deco(BIGDOLL, "LAPRAS", "SET_UP_BIG_DOLL", EVENT_DECO_BIG_SNORLAX_DOLL + 2, 0x47),
deco(PLANT, "PUT IT AWAY", "PUT_AWAY_DOLL", TEMP, 0), -- DOLLS
deco(DOLL, "PIKACHU", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 0, 0x8e),
-- The surfing Pikachu doll is a DECO_PLANT too, and for the same reason:
-- "SURF PIKACHU DOLL" is one DecorationNames string, not a mon plus " DOLL".
deco(PLANT, "SURF PIKACHU DOLL", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 1, 0x34),
deco(DOLL, "CLEFAIRY", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 2, 0x8f),
deco(DOLL, "JIGGLYPUFF", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 3, 0x94),
deco(DOLL, "BULBASAUR", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 4, 0x93),
deco(DOLL, "CHARMANDER", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 5, 0x90),
deco(DOLL, "SQUIRTLE", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 6, 0x89),
deco(DOLL, "POLIWAG", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 7, 0x8d),
deco(DOLL, "DIGLETT", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 8, 0x8c),
-- STARYU's doll stands on SPRITE_STARMIE; the cart's own row says so.
deco(DOLL, "STARYU", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 9, 0x92),
deco(DOLL, "MAGIKARP", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 10, 0x88),
deco(DOLL, "ODDISH", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 11, 0x85),
deco(DOLL, "GENGAR", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 12, 0x86),
deco(DOLL, "SHELLDER", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 13, 0x84),
deco(DOLL, "GRIMER", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 14, 0x95),
deco(DOLL, "VOLTORB", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 15, 0x9b),
deco(DOLL, "WEEDLE", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 16, 0x83),
deco(DOLL, "UNOWN", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 17, 0x80),
deco(DOLL, "GEODUDE", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 18, 0x81),
deco(DOLL, "MACHOP", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 19, 0x9a),
deco(DOLL, "TENTACOOL", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 20, 0x98),
-- Both trophies are SET_UP_DOLL: a trophy stands in an ornament slot.
deco(PLANT, "GOLD TROPHY", "SET_UP_DOLL", EVENT_DECO_GOLD_TROPHY, 0x5e),
deco(PLANT, "SILVER TROPHY", "SET_UP_DOLL", EVENT_DECO_SILVER_TROPHY, 0x5f),
}
Decorations.ATTRIBUTES = ATTRIBUTES
-- The seven category menus, in _PlayerDecorationMenu's .owned_pointers order.
-- `id` is the DECO_* of the category's own PUT_IT_AWAY row, which is exactly
-- what FindOwnedDecosInCategory appends to its list, and `members` is that
-- routine's own db list -- transcribed rather than derived from a range,
-- because the doll list runs past the two trophies and the big dolls do not
-- sit next to the small ones.
local function range(first, last)
local out = {}
for id = first, last do out[#out + 1] = id end
return out
end
Decorations.CATEGORIES = {
{ id = 1, label = "BED", members = range(2, 5) },
{ id = 6, label = "CARPET", members = range(7, 10) },
{ id = 11, label = "PLANT", members = range(12, 14) },
{ id = 15, label = "POSTER", members = range(16, 19) },
{ id = 20, label = "GAME CONSOLE", members = range(21, 24) },
{ id = 29, label = "ORNAMENT", members = range(30, 52) },
{ id = 25, label = "BIG DOLL", members = range(26, 28) },
}
-- data/decorations/decorations.asm DecorationIDs: DECOFLAG_* -> DECO_*. The
-- only thing that reads it is GetDecorationID, i.e. the routines that GIVE a
-- decoration, which name what they hand over by DECOFLAG.
local DECORATION_IDS = {}
do
local order = {
range(2, 5), range(7, 10), range(12, 14), range(16, 19), range(21, 24),
range(30, 50), range(26, 28), { 51, 52 },
}
for _, group in ipairs(order) do
for _, id in ipairs(group) do
DECORATION_IDS[#DECORATION_IDS + 1] = id
end
end
end
-- DECOFLAG_* is a `const_def` block, so it is 0-based: shift the 1-based Lua
-- list rather than leaving a caller to guess.
function Decorations.idForFlag(decoFlag)
return DECORATION_IDS[(decoFlag or 0) + 1]
end
-- constants/deco_constants.asm DECOFLAG_*, for the two callers that name one.
Decorations.DECOFLAG_GOLD_TROPHY_DOLL = 43
Decorations.DECOFLAG_SILVER_TROPHY_DOLL = 44
-- DescribeDecoration's five arms and the wDeco* byte each one reads
-- (constants/script_constants.asm DECODESC_*, which is what the cache's
-- decorationOrder carries). Only the three that share
-- DecorationDesc_OrnamentOrConsole put a NAME in wStringBuffer3; the poster
-- arm picks a different script instead, and the giant ornament's says the same
-- thing whatever is standing there.
Decorations.DESC_SLOTS = {
DECODESC_POSTER = { slot = "poster" },
DECODESC_LEFT_DOLL = { slot = "leftOrnament", named = true },
DECODESC_RIGHT_DOLL = { slot = "rightOrnament", named = true },
DECODESC_BIG_DOLL = { slot = "bigDoll" },
DECODESC_CONSOLE = { slot = "console", named = true },
}
--------------------------------------------------------------------------
-- State
--------------------------------------------------------------------------
-- The eight wDeco* bytes, on the save. InitDecorations (called from
-- intro_menu.asm at New Game) is the two defaults below: the feathery bed and
-- the TOWN MAP poster are set up before the player has chosen anything, which
-- is why a new game's room already has a bed in it. Filling them in lazily
-- rather than in Save.newGame means an older save gets the same room.
function Decorations.state(save)
if type(save) ~= "table" then return {} end
local state = save.decorations
if not state then
state = { bed = 2, poster = 16 } -- DECO_FEATHERY_BED, DECO_TOWN_MAP
save.decorations = state
end
return state
end
-- ------------------------------------------------------------ the registry
--
-- The `decorations` registry (src/mods/Schemas.lua), one of the Gen 2-only
-- six: Red's bedroom has no PC decoration menu, so the name is gated under
-- Gen 1 and routed to data.gen2Decorations under Gen 2. src/mods/Builtins.lua
-- seeds it with the ATTRIBUTES rows above, engine-owned.
--
-- Ids are "deco:<n>", where n is the attribute row's index -- the DECO_* byte,
-- which is what wMenuSelection holds and what every caller passes. The cart's
-- decoration constants are a bare const_def block with no name table in the
-- ROM behind it, so there is nothing to spell them by; battle_anims addresses
-- its unnamed rows the same way ("subanim:<n>").
local DECO_ID_PREFIX = "deco:"
local registryRows = nil
function Decorations.idFor(decoId)
return DECO_ID_PREFIX .. tostring(decoId)
end
-- One read point for the attribute row, so the merged record reaches every
-- caller: name/owns/give/apply/visibility/tiles below all come through here,
-- as do src/ui/gen2/DecorationMenu.lua and the two DECO_* screens. Falls back
-- to the module's own table, which is what a headless test and a boot with no
-- loader get.
function Decorations.attributes(decoId)
if decoId == nil then return nil end
local merged = registryRows and registryRows[DECO_ID_PREFIX .. tostring(decoId)]
return merged or ATTRIBUTES[decoId]
end
-- vanilla registrations, engine-owned
function Decorations.registerInto(registry, _, owner)
local count = 0
for decoId, attr in pairs(ATTRIBUTES) do
registry:register(Decorations.idFor(decoId), attr, owner)
count = count + 1
end
return count
end
-- the merged table, held by reference; nil forgets it
function Decorations.useRegistry(data)
registryRows = data and data.gen2Decorations or nil
return registryRows ~= nil
end
-- GetDecoName: the display name, built from the type and the name column. The
-- four types that name a SPECIES read the mon's name out of the data table,
-- which is what `monName` is for; with no resolver the constant is already the
-- English name for all twenty-four of them.
function Decorations.name(decoId, monName)
local attr = Decorations.attributes(decoId)
if not attr then return "" end
local base = attr.name
if attr.type == BED then return base .. " BED" end
if attr.type == CARPET then return base .. " CARPET" end
local mon = (monName and monName(base)) or base
if attr.type == POSTER then return mon .. " POSTER" end
if attr.type == DOLL then return mon .. " DOLL" end
if attr.type == BIGDOLL then return "BIG " .. mon end
return base
end
--------------------------------------------------------------------------
-- Owning
--------------------------------------------------------------------------
-- DecorationFlagAction CHECK_FLAG. `events` is the src/world/gen2/Events.lua
-- bitfield the rest of the port keys by number.
function Decorations.owns(events, decoId)
local attr = Decorations.attributes(decoId)
if not (events and attr and attr.flag) then return false end
return events:get(attr.flag) and true or false
end
-- SetSpecificDecorationFlag, i.e. how a decoration is acquired at all: the
-- NORMAL_BOX / GORGEOUS_BOX trophies (engine/items/item_effects.asm), Mom's
-- doll purchases (engine/events/mom_phone.asm Mom_GiveItemOrDoll) and Mystery
-- Gift all end here. Named by DECOFLAG_*, because that is what every caller
-- passes.
function Decorations.giveFlag(events, decoFlag)
return Decorations.give(events, Decorations.idForFlag(decoFlag))
end
function Decorations.give(events, decoId)
local attr = Decorations.attributes(decoId)
if not (events and attr and attr.flag) then return false end
events:set(attr.flag, true)
return true
end
-- .FindOwnedDecos: the categories with at least one owned decoration, in the
-- .owned_pointers order. EXIT is not in that list -- DecoExitMenu is the
-- eighth .category_pointers row and is always on the menu -- so the caller
-- appends it, the way .FindCategoriesWithOwnedDecos appends its own 7.
function Decorations.ownedCategories(events)
local out = {}
for _, category in ipairs(Decorations.CATEGORIES) do
for _, id in ipairs(category.members) do
if Decorations.owns(events, id) then
out[#out + 1] = category
break
end
end
end
return out
end
-- FindOwnedDecosInCategory: every owned decoration in the category, then the
-- category's own PUT_IT_AWAY row, then row 0 (CANCEL). An empty category
-- answers an empty list and PopulateDecoCategoryMenu prints "There's nothing
-- to choose." instead of opening a menu.
function Decorations.rows(events, category)
local out = {}
for _, id in ipairs(category and category.members or {}) do
if Decorations.owns(events, id) then out[#out + 1] = id end
end
if #out == 0 then return out end
out[#out + 1] = category.id
out[#out + 1] = 0
return out
end
--------------------------------------------------------------------------
-- Placing
--------------------------------------------------------------------------
-- data/text/common_1.asm. Declared up here and formatted at the call site, so
-- Strings.source is what registers them.
local SET_UP = Strings.source("Set up the\n%s.")
local PUT_AWAY = Strings.source("Put away the\n%s.")
local NOTHING_TO_PUT_AWAY = Strings.source("There's nothing to\nput away.")
local ALREADY_SET_UP = Strings.source("That's already set\nup.")
local NOTHING_TO_CHOOSE = Strings.source("There's nothing to\nchoose.")
-- _PutAwayAndSetUpText is one text with a `para` in it, so it is two pages.
local PUT_AWAY_PAGE = Strings.source("Put away the\n%s")
local AND_SET_UP = Strings.source("and set up the\n%s.")
Decorations.NOTHING_TO_CHOOSE = NOTHING_TO_CHOOSE
-- DoDecorationAction2 for one menu row. Returns
-- changed wChangedDecorations: TRUE only when the room actually changed,
-- which is what makes the PC reload the map on the way out
-- pages the text to print, in order
-- `side` is "left" or "right" and only an ornament row reads it; a nil side on
-- an ornament row is DecoAction_AskWhichSide's cancel (`scf`), which changes
-- nothing and prints nothing.
function Decorations.apply(state, decoId, side, monName)
local attr = Decorations.attributes(decoId)
if not (state and attr) then return false, {} end
local action = attr.action and ACTIONS[attr.action]
-- DecoAction_nothing: row 0, the CANCEL row. `scf` and no text.
if not action then return false, {} end
local slot = action.slot
if action.ornament then
if side ~= "left" and side ~= "right" then return false, {} end
slot = (side == "right") and "rightOrnament" or "leftOrnament"
end
local current = state[slot] or 0
local name = function(id) return Decorations.name(id, monName) end
if action.put then
-- DecoAction_TryPutItAway clears the slot BEFORE it checks what was in it,
-- so putting away an empty slot still writes a 0 over the 0.
state[slot] = 0
if current == 0 then return false, { Strings(NOTHING_TO_PUT_AWAY) } end
-- DecoAction_PutItAway_Ornament names the thing that WAS out, not the row
-- the player picked (the row is the PUT IT AWAY row and has no name).
return true, { Strings(PUT_AWAY, name(current)) }
end
if current == decoId then
-- .alreadythere: carry, so nothing is written and nothing changed.
return false, { Strings(ALREADY_SET_UP) }
end
state[slot] = decoId
if current == 0 then
return true, { Strings(SET_UP, name(decoId)) }
end
return true, { Strings(PUT_AWAY_PAGE, name(current)),
Strings(AND_SET_UP, name(decoId)) }
end
-- DecoAction_SetItUp_Ornament .getwhichside: setting a doll up on one side
-- when the SAME doll is already on the other takes it off the other side --
-- there is only one of each. Called by the menu right after apply() on an
-- ornament row, because the cart does it inside the same action.
function Decorations.clearOtherSide(state, decoId, side)
if not (state and decoId and decoId ~= 0) then return end
local other = (side == "right") and "leftOrnament" or "rightOrnament"
if state[other] == decoId then state[other] = 0 end
end
--------------------------------------------------------------------------
-- Showing: the two map callbacks
--------------------------------------------------------------------------
-- ToggleDecorationsVisibility (the PLAYERS_HOUSE_2F MAPCALLBACK_NEWMAP). One
-- row per object slot: an empty slot SETS the object's event flag, which hides
-- it, and a filled one clears the flag and writes the decoration's sprite byte
-- into wVariableSprites.
--
-- Answers a plain list so the caller can apply it to a live world or a test
-- table; nothing here touches love or the map.
function Decorations.visibility(state)
local out = {}
for _, row in ipairs(Decorations.OBJECT_SLOTS) do
local decoId = state and state[row.slot] or 0
local attr = Decorations.attributes(decoId)
if decoId ~= 0 and attr then
out[#out + 1] = { sprite = row.sprite, byte = attr.sprite,
flag = row.flag, hidden = false }
else
out[#out + 1] = { sprite = row.sprite, flag = row.flag, hidden = true }
end
end
return out
end
-- ToggleMaptileDecorations (the MAPCALLBACK_TILES one). Its coordinates "work
-- the same way as for changeblock": PadCoords_de adds 4 to each and
-- GetBlockLocation halves them, so the pairs in the asm are CELL coordinates
-- and the block written is (x / 2, y / 2) -- the same halving
-- src/script/gen2/Vm.lua does for `changeblock`.
--
-- bed cell (0, 4) -> block (0, 2)
-- plant cell (7, 4) -> block (3, 2)
-- poster cell (6, 0) -> block (3, 0)
-- carpet cell (0, 0) -> block (0, 0), and cell (0, 2) -> block row (0, 1)
--
-- The carpet is the only one that writes more than one block: its top-left
-- block is the sprite byte and the row under it is +1, +2, +1. An empty slot
-- writes NOTHING (SetDecorationTile's `and a / ret z`), so the map keeps the
-- bare block it was loaded with.
function Decorations.tiles(state)
local out = {}
local function put(slot, blockX, blockY)
local attr = Decorations.attributes(state and state[slot])
if attr and attr.sprite and attr.sprite ~= 0 then
out[#out + 1] = { x = blockX, y = blockY, block = attr.sprite }
return attr.sprite
end
return nil
end
put("bed", 0, 2)
put("plant", 3, 2)
put("poster", 3, 0)
local carpet = put("carpet", 0, 0)
if carpet then
out[#out + 1] = { x = 0, y = 1, block = carpet + 1 }
out[#out + 1] = { x = 1, y = 1, block = carpet + 2 }
out[#out + 1] = { x = 2, y = 1, block = carpet + 1 }
end
return out
end
-- SetPosterVisibility, which rides along inside ToggleMaptileDecorations: the
-- bedroom's poster bg_event is BGEVENT_IFSET on EVENT_PLAYERS_ROOM_POSTER, so
-- a bare wall must not be readable at all.
function Decorations.posterVisible(state)
return ((state and state.poster) or 0) ~= 0
end
return Decorations
+510
View File
@@ -0,0 +1,510 @@
-- Gen 2 evolution: which species a party member turns into, whether its
-- condition is met right now, and what the party record becomes afterwards.
--
-- Love-free on purpose, the same way src/core/gen2/Boxes.lua is: every
-- question a screen asks here is table math over data/generated/pokemon.lua's
-- `evolutions` rows, so tests/gen2_evolution_test.lua can drive a whole
-- evolution with no window. src/ui/gen2/EvolutionAnim.lua is the only half
-- that draws.
--
-- Ported from engine/pokemon/evolve.asm:
-- EvolveAfterBattle the master loop over the party, one flagged
-- slot at a time, and the condition walk inside
-- each species' EvosAttacks rows
-- UpdateSpeciesNameIfNotNicknamed the nickname keeps only if it is a real
-- nickname and not the old species' own name
-- LearnLevelMoves the new species' moves for the level it is
-- already at, run right after the pic changes
-- and the frame counts of engine/movie/evolution_animation.asm, which live
-- here rather than in the screen so the schedule is assertable.
--
-- What flags a slot: engine/battle/core.asm sets wEvolvableFlags for a mon the
-- moment it levels up (right after its LearnLevelMoves run), and ExitBattle
-- calls EvolveAfterBattle only on a win. src/ui/gen2/BattleState.lua keeps
-- that flag set from the battle's own `level` events.
--
-- Everything a party member becomes is built by src/battle/gen2/Mon.lua and
-- nothing else: Evolution.apply recomputes stats through Mon.stats and rebuilds
-- the record through Mon.new, so an evolved mon can never end up with the
-- half-filled shape a second builder would hand back.
local Mon = require("src.battle.gen2.Mon")
local Runtime = require("src.mods.Runtime")
local Evolution = {}
-- constants/pokemon_data_constants.asm, as the extractor spells them into
-- pokemon.lua's `evolutions` rows.
Evolution.LEVEL = "EVOLVE_LEVEL"
Evolution.ITEM = "EVOLVE_ITEM"
Evolution.TRADE = "EVOLVE_TRADE"
Evolution.HAPPINESS = "EVOLVE_HAPPINESS"
Evolution.STAT = "EVOLVE_STAT"
-- HAPPINESS_TO_EVOLVE EQU 220.
Evolution.HAPPINESS_TO_EVOLVE = 220
-- IsMonHoldingEverstone: one item id, checked before LEVEL, HAPPINESS, STAT
-- and TRADE. It is deliberately NOT checked on the ITEM path -- .item in
-- EvolveAfterBattle never calls it -- which is why a stone still works on a
-- mon holding an Everstone in Gen 2.
Evolution.EVERSTONE = "EVERSTONE"
-- EVOLVE_HAPPINESS triggers (TR_ANYTIME / TR_MORNDAY / TR_NITE). A row with
-- no `time` is TR_ANYTIME, the first constant.
Evolution.ANYTIME = "ANYTIME"
Evolution.MORNDAY = "MORNDAY"
Evolution.NITE = "NITE"
-- EVOLVE_STAT comparisons (ATK_GT_DEF / ATK_LT_DEF / ATK_EQ_DEF).
Evolution.ATK_GT_DEF = "ATK_GT_DEF"
Evolution.ATK_LT_DEF = "ATK_LT_DEF"
Evolution.ATK_EQ_DEF = "ATK_EQ_DEF"
--------------------------------------------------------------------------
-- Conditions
--------------------------------------------------------------------------
function Evolution.holdsEverstone(mon)
return (mon and mon.item) == Evolution.EVERSTONE
end
-- .got_tyrogue_evo: CompareBytes over wTempMonAttack vs wTempMonDefense, so
-- the comparison is on the mon's CURRENT stats, not its base stats or DVs.
function Evolution.statComparison(mon)
local stats = (mon and mon.stats) or {}
local attack, defense = stats.attack or 0, stats.defense or 0
if attack == defense then return Evolution.ATK_EQ_DEF end
if attack < defense then return Evolution.ATK_LT_DEF end
return Evolution.ATK_GT_DEF
end
-- wTimeOfDay is compared against NITE_F and nothing else, so every daytime
-- that is not night reads the same to a happiness evolution.
local function isNight(timeOfDay)
return timeOfDay == Evolution.NITE or timeOfDay == "NITE_F"
end
-- One EvosAttacks row against one mon. Returns true, or false plus the short
-- reason the row was skipped (for tests and the driver; the cart just falls
-- through to .dont_evolve_N).
--
-- `ctx` is the state EvolveAfterBattle reads out of WRAM:
-- link wLinkMode ~= 0 (a trade is in progress)
-- timeCapsule wLinkMode == LINK_TIMECAPSULE
-- force wForceEvolution ~= 0 (an evolution stone was just used),
-- which is what gates the ITEM path ON and every other
-- non-trade path OFF
-- item wCurItem, the stone being used
-- timeOfDay wTimeOfDay, one of MORN / DAY / NITE / DARK
-- One record per EvosAttacks method, in the shape src/mods/Schemas.lua's
-- `evolution_methods` registry validates. Same registry NAME Gen 1 fills from
-- src/pokemon/Evolution.lua, because a mod that adds a way to evolve should not
-- have to learn a second noun -- only the ids differ, and they have to: Gold's
-- extractor writes EVOLVE_LEVEL where Red's writes LEVEL.
--
-- `check` is the schema's required field and keeps its Gen 1 job of answering
-- "does this row fire right now"; the signature is Gold's own
-- fn(entry, mon, ctx) -> ok, reason, consumesHeldItem, because Gold reads an
-- EvosAttacks row and a party record rather than Gen 1's game/mon/evo/trigger.
--
-- Two fields Gen 2 adds rather than renaming anything, and both exist because
-- EvolveAfterBattle's two cross-cutting gates are per-method:
--
-- requiresLink EVOLVE_TRADE, the one method a link ENABLES rather than
-- blocks, so it is tested ahead of the link gate
-- requiresForce EVOLVE_ITEM, which only ever fires from a stone's use, so
-- wForceEvolution does not block it the way it blocks the rest
Evolution.METHODS = {
[Evolution.TRADE] = {
requiresLink = true,
check = function(entry, mon, ctx)
if not ctx.link then return false, "not trading" end
if Evolution.holdsEverstone(mon) then return false, "everstone" end
-- `ld a, [hli] / ld b, a / inc a / jr z, .proceed`: $ff (the extractor
-- writes that as no item at all) means any trade will do.
if entry.item then
if ctx.timeCapsule then return false, "time capsule" end
if (mon and mon.item) ~= entry.item then return false, "wrong item" end
-- The held item is consumed by the trade evolution.
return true, nil, true
end
return true
end,
},
[Evolution.ITEM] = {
requiresForce = true,
check = function(entry, _, ctx)
if entry.item and ctx.item ~= entry.item then
return false, "wrong item"
end
-- .item's own `ld a, [wForceEvolution] / and a / jp z, .dont_evolve_3`:
-- a stone evolution only ever fires from the item's use, never from the
-- after-battle sweep.
if not ctx.force then return false, "not forced" end
return true
end,
},
[Evolution.LEVEL] = {
check = function(entry, mon, _)
if ((mon and mon.level) or 1) < (entry.level or 0) then
return false, "level"
end
if Evolution.holdsEverstone(mon) then return false, "everstone" end
return true
end,
},
[Evolution.HAPPINESS] = {
check = function(entry, mon, ctx)
if ((mon and mon.happiness) or 0) < Evolution.HAPPINESS_TO_EVOLVE then
return false, "happiness"
end
if Evolution.holdsEverstone(mon) then return false, "everstone" end
local trigger = entry.time or Evolution.ANYTIME
if trigger == Evolution.NITE and not isNight(ctx.timeOfDay) then
return false, "daytime"
end
if trigger == Evolution.MORNDAY and isNight(ctx.timeOfDay) then
return false, "night"
end
return true
end,
},
[Evolution.STAT] = {
check = function(entry, mon, _)
if ((mon and mon.level) or 1) < (entry.level or 0) then
return false, "level"
end
if Evolution.holdsEverstone(mon) then return false, "everstone" end
if entry.comparison ~= Evolution.statComparison(mon) then
return false, "stats"
end
return true
end,
},
}
-- vanilla registrations, engine-owned (Schemas.ENGINE), so a mod's register of
-- one of these ids collides the way it does on Red and has to say override
function Evolution.registerInto(registry, _, owner)
for id, record in pairs(Evolution.METHODS) do
registry:register(id, record, owner)
end
end
-- the merged `evolution_methods` record for a method id, the module's own when
-- no loader ran (src/pokemon/Evolution.lua:pendingFor is the Gen 1 twin)
function Evolution.methodFor(data, method)
if method == nil then return nil end
local merged = data and data.gen2EvolutionMethods
return (merged and merged[method]) or Evolution.METHODS[method]
end
function Evolution.rowMatches(entry, mon, ctx, data)
ctx = ctx or {}
if not (entry and entry.method and entry.into) then return false, "empty" end
local record = Evolution.methodFor(data, entry.method)
local check = record and record.check
-- The two cross-cutting gates in EvolveAfterBattle's own order, with each
-- method's exemption tested just ahead of the gate it is exempt from -- so
-- an unknown method still reports "linked" or "forced" first, exactly as the
-- if-chain this replaced did.
--
-- .trade runs BEFORE the link check, because it is the one method that
-- requires a link rather than being blocked by one.
if check and record.requiresLink then return check(entry, mon, ctx) end
-- `ld a, [wLinkMode] / and a / jp nz, .dont_evolve_2`: nothing else fires
-- while a link is up.
if ctx.link then return false, "linked" end
-- .item runs before the force check for the mirror reason: a stone
-- evolution only ever fires WITH wForceEvolution set.
if check and record.requiresForce then return check(entry, mon, ctx) end
-- Everything else is blocked once wForceEvolution is set, so using a stone
-- cannot also trip a level or happiness evolution on the same mon.
if ctx.force then return false, "forced" end
if not check then return false, "unknown method" end
return check(entry, mon, ctx)
end
-- The first row of `def.evolutions` that fires, walked in EvosAttacks order
-- exactly the way .loop does -- the order in the ROM is the tiebreak, which is
-- why Poliwhirl's WATER_STONE row beats its KING'S ROCK trade row.
--
-- Returns entry, consumesHeldItem.
--
-- Each row's decision is wrapped by the evolution.check hook so a mod can
-- cancel or force any evolution. The contract is the Gen 1 one verbatim
-- (src/pokemon/Evolution.lua:pendingFor): four arguments, and the chain
-- returns ONE boolean. Positions 2, 3 and 4 carry the same things in both
-- games -- the mon, the evolutions[] row, the trigger -- so a wrap written
-- once serves both. Position 1 is Gen 1's `game`, which does not exist this
-- deep in Gold; it carries `data` here, which is the object a listener would
-- reach through game.data anyway.
--
-- rowMatches is a pure predicate, so running it as the chain's vanilla costs
-- nothing when a mod skips it. `consumes` (the trade row that eats its held
-- item) rides an upvalue rather than a second return, because a Gen 1 mod
-- returns a bare boolean and would otherwise silently clear it; a mod that
-- forces an evolution without calling next() therefore gets consumes = false,
-- which is the safe direction -- an item not eaten, never one eaten twice.
function Evolution.check(def, mon, ctx, data)
local hooked = Runtime.wantsHook("evolution.check")
for _, entry in ipairs((def and def.evolutions) or {}) do
local consumes = false
local function vanilla()
local matched, _, eats = Evolution.rowMatches(entry, mon, ctx, data)
consumes = eats or false
return matched and true or false
end
local ok
if hooked then
ok = Runtime.call("evolution.check", vanilla, data, mon, entry, ctx)
else
ok = vanilla()
end
if ok then return entry, consumes end
end
return nil
end
-- The same, looking the species up in pokemon.lua for the caller. `data`
-- carries on to rowMatches, which is where the merged evolution_methods
-- registry is read.
function Evolution.checkMon(data, mon, ctx)
local def = data and data.pokemon and mon and data.pokemon[mon.species]
if not def then return nil end
return Evolution.check(def, mon, ctx, data)
end
-- ExitBattle's gate: the sweep runs only when `wBattleResult & $f` is WIN, so
-- a loss (and the whiteout that follows it) never evolves anything.
function Evolution.runsAfterBattle(outcome)
return outcome ~= "lose" and outcome ~= "draw"
end
-- EvolveAfterBattle_MasterLoop: the flagged party slots in party order, each
-- with the row that will fire. `flags` is a set of party indices, matching
-- wEvolvableFlags; nil means every slot is eligible (the item path, which sets
-- the flag for wCurPartyMon only, passes a single-entry set).
function Evolution.plan(data, party, flags, ctx)
local out = {}
for index, mon in ipairs(party or {}) do
if not flags or flags[index] then
local entry, consumes = Evolution.checkMon(data, mon, ctx)
if entry then
out[#out + 1] = {
index = index,
mon = mon,
entry = entry,
into = entry.into,
consumesHeldItem = consumes,
}
end
end
end
return out
end
--------------------------------------------------------------------------
-- Applying it
--------------------------------------------------------------------------
-- The species' display name, which is what the nickname is compared against.
function Evolution.speciesName(data, species)
local def = data and data.pokemon and data.pokemon[species]
return (def and def.name) or species
end
-- UpdateSpeciesNameIfNotNicknamed: wStringBuffer2 (the nickname captured
-- before the animation) is compared byte for byte against the OLD species'
-- name, and only a mon whose "nickname" is not that name keeps it. The port
-- stores nil for an un-nicknamed mon, so both shapes have to read as "no
-- nickname" here.
function Evolution.keptNickname(data, mon)
local nickname = mon and mon.nickname
if not nickname or nickname == "" then return nil end
if nickname == Evolution.speciesName(data, mon.species) then return nil end
return nickname
end
-- LearnLevelMoves at wCurPartyLevel: the NEW species' level-up moves whose
-- level is EXACTLY the level the mon is already at (`cp b / jr nz`, not a
-- range), skipping any it already knows. An evolution at level 16 therefore
-- teaches only the moves the new species learns at 16 -- everything it "should"
-- have learned earlier stays unlearned, which is the cart's behaviour.
function Evolution.learnedOnEvolve(data, species, level, mon)
local def = data and data.pokemon and data.pokemon[species]
local known = {}
for _, move in ipairs((mon and mon.moves) or {}) do known[move.id] = true end
local out = {}
for _, row in ipairs((def and def.levelMoves) or {}) do
if row.level == level and not known[row.move] then
known[row.move] = true
out[#out + 1] = row.move
end
end
return out
end
-- Every field src/battle/gen2/Mon.lua's builder writes. Evolution.apply hands
-- all of these to Mon.new (or sets them straight after) and carries only the
-- keys outside this set across, so the two files cannot drift into disagreeing
-- about who owns a party record's shape.
Evolution.MON_FIELDS = {
species = true, name = true, nickname = true, level = true,
experience = true, dvs = true, stats = true, hp = true, maxHp = true,
types = true, moves = true, item = true, status = true, happiness = true,
caughtLevel = true, shiny = true, gender = true,
}
-- Turn `mon` into `entry.into`. Returns the NEW record; the caller writes it
-- back into the party slot the way `.pop de / pop hl / ld [hl], a` does.
--
-- The cart's order, and the reason each step is where it is:
-- UpdateSpeciesNameIfNotNicknamed before GetBaseData, so the comparison is
-- still against the old species' name
-- GetBaseData + CalcMonStats new stats at the SAME level and DVs
-- HP += (newMaxHP - oldMaxHP) the delta, not a refill and not a refill
-- to full: a mon that walked in at half
-- health walks out at half health plus the
-- max-HP gain
-- CopyBytes tempmon -> party slot
-- LearnLevelMoves handled by the caller so it can print
-- SetSeenAndCaughtMon Evolution.markPokedex
function Evolution.apply(data, mon, entry)
local species = entry and entry.into
local def = data and data.pokemon and species and data.pokemon[species]
if not def then return nil end
local level = mon.level or 1
-- CalcMonStats runs through the one builder, so an evolved mon's stats can
-- never disagree with a freshly built one's.
local stats = Mon.stats(def.baseStats, mon.dvs, level, mon.statExp)
local previousMax = mon.maxHp or (mon.stats and mon.stats.hp) or stats.hp
local hp = (mon.hp or previousMax) + (stats.hp - previousMax)
-- The cart does not clamp; the bound only matters for data where an
-- evolution LOSES max HP, which no shipped species does.
hp = math.max(0, math.min(stats.hp, hp))
-- `xor a / ld [wTempMonItem], a`: only the trade branch that DEMANDED a held
-- item consumes it; the `$ff` (any trade) branch jumps to .proceed with the
-- item still on. Spelled out rather than as `cond and nil or item`, which
-- would quietly evaluate to the item in both cases.
local heldItem = mon.item
if entry.method == Evolution.TRADE and entry.item then heldItem = nil end
local evolved = Mon.new(data, species, level, {
dvs = mon.dvs,
moves = mon.moves,
hp = hp,
item = heldItem,
happiness = mon.happiness,
nickname = Evolution.keptNickname(data, mon),
})
if not evolved then return nil end
-- wTempMonExp is never touched: the mon keeps the experience it walked in
-- with, so an evolution cannot push it up or down a level.
evolved.experience = mon.experience
evolved.status = mon.status
evolved.caughtLevel = mon.caughtLevel
-- Anything a future field adds to a party record (mail, pokerus) rides along
-- rather than being silently dropped. Only fields Mon.new does NOT own may
-- be carried: copying `nickname` back would undo
-- UpdateSpeciesNameIfNotNicknamed, and copying `item` back would undo the
-- trade evolution's `xor a / ld [wTempMonItem], a`.
for key, value in pairs(mon) do
if Evolution.MON_FIELDS[key] == nil then evolved[key] = value end
end
-- Same name and payload keys as the Gen 1 site (src/pokemon/Evolution.lua),
-- so one subscription covers both games. `mon` is the EVOLVED record, not
-- the one that walked in: Gen 1 emits after the species swap, and a listener
-- reading mon.species expects the new one. `via` is the method id that
-- fired (EVOLVE_LEVEL, EVOLVE_ITEM, EVOLVE_TRADE, EVOLVE_HAPPINESS, ...),
-- which is Gen 2's equivalent of Gen 1's trigger kind.
Runtime.emit("pokemon.evolved", {
mon = evolved, fromSpecies = mon.species, toSpecies = species,
via = entry.method,
})
return evolved
end
-- SetSeenAndCaughtMon: an evolution ticks the new species off as BOTH seen and
-- caught, the same pair GivePoke sets, because the mon is in the party.
function Evolution.markPokedex(save, species)
if not (save and species) then return false end
save.pokedex = save.pokedex or {}
save.pokedex.seen = save.pokedex.seen or {}
save.pokedex.caught = save.pokedex.caught or {}
save.pokedex.seen[species] = true
save.pokedex.caught[species] = true
return true
end
--------------------------------------------------------------------------
-- Animation schedule (engine/movie/evolution_animation.asm)
--------------------------------------------------------------------------
-- EvolveAfterBattle prints EvolvingText ("What? <NICK> is evolving!") and then
-- `ld c, 50 / call DelayFrames` before it clears the top 12 rows and starts
-- the animation. The text box itself is NOT cleared, so that line stays under
-- the pic for the whole animation.
Evolution.EVOLVING_FRAMES = 50
-- The old mon's cry, then MUSIC_EVOLUTION, then `ld c, 80 / call DelayFrames`
-- before the palette goes to PREDEFPAL_BLACKOUT and the flashing starts.
Evolution.MUSIC_FRAMES = 80
-- Each .ReplaceFrontpic ends in WaitBGMap, i.e. one frame per pic swap, and a
-- "flash" is two of them: the new stage's tiles, then back to the old.
Evolution.SWAP_FRAMES = 1
-- .PlayEvolvedSFX: 32 frames spawning balls of light (two every other frame,
-- 32 in all) and then `ld c, 32` more frames animating them out.
Evolution.BALL_SPAWN_FRAMES = 32
Evolution.BALL_TAIL_FRAMES = 32
-- After the animation: CongratulationsYourPokemonText, EvolvedIntoText,
-- MUSIC_NONE, SFX_CAUGHT_MON, WaitSFX, then `ld c, 40 / call DelayFrames`.
Evolution.CONGRATS_FRAMES = 40
-- `lb bc, 1, 16` then, per round, `inc b / dec c / dec c`: eight rounds of
-- "hold the old pic for c frames (watching for B), then alternate the two pics
-- b times". The hold shrinks by two frames a round while the alternation gets
-- one flash longer, which is what makes the flicker accelerate.
function Evolution.flashRounds()
local rounds = {}
local flashes, wait = 1, 16
while wait > 0 do
rounds[#rounds + 1] = { wait = wait, flashes = flashes }
flashes = flashes + 1
wait = wait - 2
end
return rounds
end
-- How many frames the flashing half of the animation takes, end to end.
function Evolution.flashFrames()
local total = 0
for _, round in ipairs(Evolution.flashRounds()) do
total = total + round.wait + round.flashes * 2 * Evolution.SWAP_FRAMES
end
return total
end
-- .GenerateBallOfLight spawns two balls on every EVEN jumptable index over the
-- 32 spawn frames, 180 degrees apart, and AnimSeq_RevealNewMon walks each one
-- out from radius $10 in steps of $08 until it passes $80.
Evolution.BALL_RADIUS_START = 0x10
Evolution.BALL_RADIUS_STEP = 0x08
Evolution.BALL_RADIUS_END = 0x80
-- depixel 9, 11 -- Y TILE FIRST -- and an OAM object draws at (x - 8, y - 16),
-- so the balls come out of (80, 56), just under the middle of the 7x7 pic box
-- at hlcoord 7, 2.
Evolution.BALL_ORIGIN_X = 11 * 8 - 8
Evolution.BALL_ORIGIN_Y = 9 * 8 - 16
return Evolution
+262
View File
@@ -0,0 +1,262 @@
-- The Hall of Fame roster: what the save keeps when the champion is beaten.
--
-- Two pokegold routines, and neither of them draws anything:
--
-- engine/events/halloffame.asm HallOfFame the induction's bookkeeping --
-- the status flag, wSpawnAfterChampion, the win counter and its cap, and
-- GetHallOfFameParty, which packs the party into the roster row
-- engine/menus/save.asm AddHallOfFameEntry the SRAM shuffle that pushes
-- that row in at the front and drops the thirtieth
--
-- The screens (src/ui/gen2/HallOfFame.lua) read this and nothing else, which
-- is what lets the whole roster be tested headless.
--
-- THE ROW. constants/pokemon_data_constants.asm spells the format out:
--
-- hof_mon: species, id, dvs, level, nickname HOF_MON_LENGTH $10
-- hall_of_fame: win count, party, terminator HOF_LENGTH $62
--
-- so a row is one win count, up to PARTY_LENGTH mons and a -1. The nickname
-- really is capped at MON_NAME_LENGTH - 1 = 10 characters: GetHallOfFameParty
-- copies exactly that many bytes and DisplayHOFMon writes the '@' itself. A
-- Lua list needs no terminator, so the -1 becomes `#entry.mons`, and the cap
-- is enforced on the way in rather than left to whoever reads it back.
--
-- WHAT IS NOT KEPT. The row has no stats, no moves and no OT name. That is
-- the cart's own choice and it is why the PC's viewer prints a species, a
-- nickname, a level and an ID and nothing else: a Hall of Fame entry is a
-- photograph, not a mon. Nothing here should grow past those six fields.
local HallOfFame = {}
-- constants/pokemon_data_constants.asm
HallOfFame.NUM_TEAMS = 30 -- NUM_HOF_TEAMS
HallOfFame.PARTY_LENGTH = 6 -- PARTY_LENGTH
HallOfFame.MON_LENGTH = 0x10 -- HOF_MON_LENGTH, for the record
HallOfFame.LENGTH = 0x62 -- HOF_LENGTH, ditto
-- constants/text_constants.asm MON_NAME_LENGTH - 1
HallOfFame.NAME_LENGTH = 10
-- constants/misc_constants.asm
HallOfFame.MASTER_COUNT = 200 -- HOF_MASTER_COUNT
-- constants/ram_constants.asm. wSpawnAfterChampion is one byte with two
-- values that matter: SPAWN_LANCE after the Elite Four, SPAWN_RED after the
-- Mt. Silver credits. The port keeps the spawn's own name rather than the
-- enum, because that is what src/world/gen2/World.lua resolves against
-- landmarks.spawns.
HallOfFame.SPAWN_LANCE = "SPAWN_LANCE"
HallOfFame.SPAWN_RED = "SPAWN_RED"
-- engine/menus/intro_menu.asm .SpawnAfterE4 / SpawnAfterRed: where each of
-- those two actually puts the player back on CONTINUE.
HallOfFame.POST_CREDITS_SPAWN = {
SPAWN_LANCE = "SPAWN_NEW_BARK",
SPAWN_RED = "SPAWN_MT_SILVER",
}
--------------------------------------------------------------------------
-- The save's block
--------------------------------------------------------------------------
-- sHallOfFame plus wHallOfFameCount, as one table. Created on demand so a
-- caller never has to check, and so src/core/gen2/Save.lua's normalize can
-- lean on the same shape a migration produces.
function HallOfFame.record(save)
if type(save) ~= "table" then return nil end
save.hallOfFame = save.hallOfFame or {}
local record = save.hallOfFame
record.count = tonumber(record.count) or 0
if type(record.teams) ~= "table" then record.teams = {} end
return record
end
-- STATUSFLAGS_HALL_OF_FAME_F (constants/ram_constants.asm), the bit
-- `HallOfFame::` sets before it saves. It is not decoration: the Pokegear map
-- reads it to unlock Kanto (engine/pokegear/pokegear.asm), the radio reads it,
-- and Credits reads it to decide whether B may skip.
function HallOfFame.hasEntered(save)
local record = HallOfFame.record(save)
if not record then return false end
return record.count > 0 or record.entered == true
end
function HallOfFame.count(save)
local record = HallOfFame.record(save)
return record and record.count or 0
end
-- `ld a, [hl] / cp HOF_MASTER_COUNT / jr nc, .ok / inc [hl]`.
--
-- `ld a, [hl]` leaves a holding the PRE-increment count, so the test is on the
-- OLD value: a save sitting at exactly 200 stops counting there. Returns the
-- new count, which is what GetHallOfFameParty then writes into the row.
function HallOfFame.bumpCount(save)
local record = HallOfFame.record(save)
if not record then return 0 end
if record.count < HallOfFame.MASTER_COUNT then
record.count = record.count + 1
end
return record.count
end
--------------------------------------------------------------------------
-- GetHallOfFameParty
--------------------------------------------------------------------------
local function isEgg(mon)
if not mon then return false end
if mon.isEgg or mon.egg then return true end
if mon.species == "EGG" then return true end
local ok, Breeding = pcall(require, "src.core.gen2.Breeding")
if ok and Breeding and Breeding.isEgg then return Breeding.isEgg(mon) end
return false
end
HallOfFame.isEgg = isEgg
-- One hof_mon out of one party member. The six fields are exactly the six
-- `ld [de], a` runs in GetHallOfFameParty's .mon block, in its order.
local function packMon(mon)
return {
species = mon.species,
otId = tonumber(mon.otId) or 0,
-- MON_DVS is two bytes and the port keeps them as the four nibbles; both
-- forms are stored so the viewer can show a shiny or an Unown letter
-- without a second table.
dvs = mon.dvs,
level = tonumber(mon.level) or 1,
-- `ld bc, MON_NAME_LENGTH - 1 / call CopyBytes`: ten bytes, no terminator.
nickname = tostring(mon.nickname or mon.name or mon.species or "")
:sub(1, HallOfFame.NAME_LENGTH),
shiny = mon.shiny or nil,
gender = mon.gender or nil,
}
end
HallOfFame.packMon = packMon
-- GetHallOfFameParty: the win count, then every party member that is not an
-- EGG, then the -1.
--
-- `cp EGG / jr nz, .mon` skips the egg WITHOUT copying it but still steps the
-- party index (`inc c`), which is why the mon behind an egg lands in the row
-- at its own party slot's data and not at the egg's. A Lua walk gets that for
-- free; the loop is written the cart's way anyway so the skip is visible.
function HallOfFame.buildParty(save, party)
local record = HallOfFame.record(save)
local entry = { winCount = record and record.count or 0, mons = {} }
for _, mon in ipairs(party or {}) do
if #entry.mons >= HallOfFame.PARTY_LENGTH then break end
if mon and not isEgg(mon) then
entry.mons[#entry.mons + 1] = packMon(mon)
end
end
return entry
end
--------------------------------------------------------------------------
-- AddHallOfFameEntry
--------------------------------------------------------------------------
-- The SRAM shuffle: every stored row is copied one slot UP (the copy runs
-- backwards, from the second-to-last row to the last, so nothing is clobbered
-- on the way), the thirtieth falls off the end, and the new row is written at
-- sHallOfFame. So the roster is newest first and holds NUM_HOF_TEAMS.
function HallOfFame.addEntry(save, entry)
local record = HallOfFame.record(save)
if not (record and entry) then return nil end
table.insert(record.teams, 1, entry)
while #record.teams > HallOfFame.NUM_TEAMS do
table.remove(record.teams)
end
return entry
end
-- LoadHOFTeam: `cp NUM_HOF_TEAMS / jr nc, .invalid` and then `ld a, [hl] /
-- and a / jr z, .absent` -- an index past the end of the table and a row whose
-- first byte (the win count) is zero both mean "stop", which is what ends the
-- PC's master loop at the oldest entry the player actually has.
function HallOfFame.team(save, index)
local record = HallOfFame.record(save)
if not record then return nil end
index = tonumber(index) or 0
if index < 1 or index > HallOfFame.NUM_TEAMS then return nil end
local entry = record.teams[index]
if not entry or (tonumber(entry.winCount) or 0) == 0 then return nil end
return entry
end
function HallOfFame.teamCount(save)
local record = HallOfFame.record(save)
if not record then return 0 end
local count = 0
for index = 1, HallOfFame.NUM_TEAMS do
if not HallOfFame.team(save, index) then break end
count = count + 1
end
return count
end
--------------------------------------------------------------------------
-- The induction
--------------------------------------------------------------------------
-- Everything `HallOfFame::` does to the save, in its order and without the
-- screens:
--
-- set STATUSFLAGS_HALL_OF_FAME_F
-- wSpawnAfterChampion = SPAWN_LANCE
-- bump wHallOfFameCount, capped
-- SaveGameData
-- GetHallOfFameParty
-- AddHallOfFameEntry
--
-- The save really does happen BEFORE the roster row is written: the cart's
-- AddHallOfFameEntry pokes SRAM directly, so it needs no second save. This
-- port has no SRAM, so `saveFn` is called after the row lands instead -- the
-- one deliberate reordering here, and it exists so a crash between the two
-- cannot leave a save whose count says "inducted" and whose roster is empty.
--
-- Returns the row, and SECOND the value STATUSFLAGS_HALL_OF_FAME_F held BEFORE
-- the induction. That second value is not bookkeeping: `HallOfFame::` pushes
-- wStatusFlags before it sets the bit and hands the pushed copy to Credits,
-- which is the whole reason a first-time champion cannot fast-forward the
-- credits and a repeat one can.
function HallOfFame.induct(save, party, opts)
opts = opts or {}
local record = HallOfFame.record(save)
if not record then return nil end
local wasEntered = HallOfFame.hasEntered(save)
record.entered = true
save.spawnAfterChampion = opts.spawn or HallOfFame.SPAWN_LANCE
HallOfFame.bumpCount(save)
local entry = HallOfFame.buildParty(save, party or save.party)
HallOfFame.addEntry(save, entry)
if opts.saveFn then opts.saveFn(save) end
return entry, wasEntered
end
-- RedCredits' half of the same thing: no roster row and no counter, just the
-- spawn that sends CONTINUE to Mt. Silver. Kept here because the byte is the
-- same byte and nothing else in the port writes it.
function HallOfFame.markRedCredits(save)
if type(save) ~= "table" then return end
save.spawnAfterChampion = HallOfFame.SPAWN_RED
end
--------------------------------------------------------------------------
-- The post-game continue
--------------------------------------------------------------------------
-- engine/menus/intro_menu.asm: CONTINUE reads wSpawnAfterChampion, and a
-- non-zero one replaces the saved position with a spawn point and a WARP map
-- entry rather than a CONTINUE one. PostCreditsSpawn then clears the byte, so
-- this only ever fires on the first load after the credits.
--
-- Returns the SPAWN_* id to start at, or nil for an ordinary continue.
function HallOfFame.consumePostGameSpawn(save)
if type(save) ~= "table" then return nil end
local pending = save.spawnAfterChampion
if not pending then return nil end
save.spawnAfterChampion = nil
return HallOfFame.POST_CREDITS_SPAWN[pending]
end
return HallOfFame
+288
View File
@@ -0,0 +1,288 @@
-- Gen 2 friendship.
--
-- src/battle/gen2/Mon.lua has carried a `happiness` field since the party
-- struct was ported and src/core/gen2/Evolution.lua reads it, but until this
-- module existed nothing ever MOVED it: every mon sat on BASE_HAPPINESS
-- forever, which made EVOLVE_HAPPINESS unreachable and the Goldenrod
-- friendship rater a constant.
--
-- Two separate mechanisms, both from the cart:
--
-- ChangeHappiness (engine/events/happiness_egg.asm) applies one of the
-- eighteen HAPPINESS_* events to one party mon. The step it applies is NOT
-- fixed: HappinessChanges (data/events/happiness_changes.asm) is a
-- `table_width 3` block whose three columns are "happiness < 100",
-- "happiness < 200", and "otherwise", so a mon that already likes you gains
-- less and (for the bitter herbs and a poison faint) loses MORE. That tier
-- is read off the value BEFORE the change.
--
-- StepHappiness (engine/events/happiness_egg.asm) raises the whole party by
-- one point, and it is called only when wStepCount wraps -- and then only on
-- every OTHER call, because it keeps its own wHappinessStepCount toggle.
-- The visible period is therefore 512 footfalls, not 256.
--
-- Both routines refuse to touch an EGG: ChangeHappiness `cp EGG / ret z` on
-- wPartySpecies before it even finds the byte, and StepHappiness's loop skips
-- the slot. An egg's "happiness" byte is its remaining hatch cycles
-- (engine/pokemon/move_mon.asm writes wBaseEggSteps there), so incrementing it
-- would hand the player a Togepi 512 steps early. The port keeps the two
-- apart on `mon.eggSteps` (see src/core/gen2/Breeding.lua), and this module
-- still honours the egg gate so the ORDER of events matches the cart.
local Runtime = require("src.mods.Runtime")
local Happiness = {}
-- happiness.changed, one of the handful of names Gen 2 invents because Gen 1
-- has no friendship byte at all (docs/mod-api-gen2-compat.md, "New in Gen 2").
-- Raised from the two routines that MOVE the byte and from nowhere else, so a
-- mod that mirrors friendship into its own UI sees every point:
--
-- mon the party record whose byte moved
-- event the HAPPINESS_* name/index the caller passed, or nil for a step
-- reason "event" for ChangeHappiness, "step" for StepHappiness
-- delta the signed step actually applied, AFTER the byte's own clamps
-- from, to the value either side of the change
--
-- `delta` is `to - from` rather than the table's column, because the $ff and 0
-- carry clamps are part of what the cart applied: a mon at 254 gaining "5"
-- gained 1.
local function emitChanged(mon, event, reason, from, to)
if not Runtime.wants("happiness.changed") then return end
Runtime.emit("happiness.changed", {
mon = mon, event = event, reason = reason,
delta = to - from, from = from, to = to,
})
end
-- constants/pokemon_data_constants.asm, "significant happiness values".
Happiness.BASE = 70
Happiness.FRIEND_BALL = 200
Happiness.TO_EVOLVE = 220
Happiness.THRESHOLD_1 = 100
Happiness.THRESHOLD_2 = 200
-- The byte's own ceiling; the floor is 0.
Happiness.MAX = 255
-- The HAPPINESS_* enum. Its `const_def 1` makes it ONE based, so these
-- indices line up with a 1-based Lua array without an offset -- the shift that
-- would otherwise drop the last row (HAPPINESS_GROOMING) to nil.
Happiness.EVENT = {
GAINLEVEL = 1, -- 01
USEDITEM = 2, -- 02 a vitamin
USEDXITEM = 3, -- 03 X ATTACK / X DEFEND / X SPEED / X SPECIAL
GYMBATTLE = 4, -- 04
LEARNMOVE = 5, -- 05 a TM, not an HM
FAINTED = 6, -- 06
POISONFAINT = 7, -- 07
BEATENBYSTRONGFOE = 8, -- 08
OLDERCUT1 = 9, -- 09
OLDERCUT2 = 10, -- 0a
OLDERCUT3 = 11, -- 0b
YOUNGCUT1 = 12, -- 0c
YOUNGCUT2 = 13, -- 0d
YOUNGCUT3 = 14, -- 0e
BITTERPOWDER = 15, -- 0f HEAL POWDER / ENERGYPOWDER
ENERGYROOT = 16, -- 10
REVIVALHERB = 17, -- 11
GROOMING = 18, -- 12
}
Happiness.NUM_EVENTS = 18
-- data/events/happiness_changes.asm, transcribed row for row. The three
-- columns are the three tiers below, in order.
Happiness.CHANGES = {
{ 5, 3, 2 }, -- 01 Gained a level
{ 5, 3, 2 }, -- 02 Vitamin
{ 1, 1, 0 }, -- 03 X Item
{ 3, 2, 1 }, -- 04 Battled a Gym Leader
{ 1, 1, 0 }, -- 05 Learned a move
{ -1, -1, -1 }, -- 06 Lost to an enemy
{ -5, -5, -10 }, -- 07 Fainted due to poison
{ -5, -5, -10 }, -- 08 Lost to a much stronger enemy
{ 1, 1, 1 }, -- 09 Haircut (older brother) 1
{ 3, 3, 1 }, -- 0a Haircut (older brother) 2
{ 5, 5, 2 }, -- 0b Haircut (older brother) 3
{ 1, 1, 1 }, -- 0c Haircut (younger brother) 1
{ 3, 3, 1 }, -- 0d Haircut (younger brother) 2
{ 10, 10, 4 }, -- 0e Haircut (younger brother) 3
{ -5, -5, -10 }, -- 0f Used Heal Powder or Energypowder (bitter)
{ -10, -10, -15 }, -- 10 Used Energy Root (bitter)
{ -15, -15, -20 }, -- 11 Used Revival Herb (bitter)
{ 3, 3, 1 }, -- 12 Grooming
}
-- Which of HappinessChanges' three columns a CURRENT value reads. The cart
-- builds this as `e`: 0, then +1 once the value is >= 100, then +1 again once
-- it is >= 200. Returned 1-based to index the rows above.
function Happiness.tier(value)
value = value or 0
if value < Happiness.THRESHOLD_1 then return 1 end
if value < Happiness.THRESHOLD_2 then return 2 end
return 3
end
-- Resolve an event to its index. Callers may pass the name ("GAINLEVEL"),
-- the full constant ("HAPPINESS_GAINLEVEL") or the raw number, so a hand
-- ported script and an extracted one can both say what they mean.
function Happiness.eventIndex(event)
if type(event) == "number" then
if event >= 1 and event <= Happiness.NUM_EVENTS then return event end
return nil
end
if type(event) ~= "string" then return nil end
local name = event:match("^HAPPINESS_(.+)$") or event
return Happiness.EVENT[name]
end
-- The signed step an event applies at a current value, or nil for an event
-- this table does not know. Split out so a test can assert the tier
-- boundaries without going through a mon.
function Happiness.delta(event, current)
local index = Happiness.eventIndex(event)
if not index then return nil end
local row = Happiness.CHANGES[index]
if not row then return nil end
return row[Happiness.tier(current)]
end
-- An egg is skipped, exactly as ChangeHappiness's `cp EGG / ret z` does.
-- Matches src/core/gen2/Breeding.lua's isEgg without requiring it, so this
-- module stays loadable on its own.
local function isEgg(mon)
return type(mon) == "table" and mon.isEgg == true
end
-- ChangeHappiness itself. Returns the new value, or nil when nothing moved
-- (no mon, an egg, or an event the table does not carry).
--
-- The clamps are the cart's carry checks, not a max/min bolted on: a positive
-- step that overflows the byte lands on $ff (`ld a, -1`), and a negative one
-- that underflows lands on 0 (`xor a`). Both edges are reachable in normal
-- play -- 255 from walking, 0 from a poison faint at low friendship -- so they
-- are load bearing rather than defensive.
function Happiness.change(mon, event)
if type(mon) ~= "table" or isEgg(mon) then return nil end
local current = mon.happiness or 0
local delta = Happiness.delta(event, current)
if not delta then return nil end
local value = current + delta
if value > Happiness.MAX then value = Happiness.MAX end
if value < 0 then value = 0 end
mon.happiness = value
emitChanged(mon, event, "event", current, value)
return value
end
-- The same event across a party, which is how the Gym Leader award is written
-- out longhand in engine/battle/core.asm InitEnemyTrainer:
--
-- ld a, MON_HP / call GetPartyParamLocation
-- ld a, [hli] / or [hl] / jr z, .skipfaintedmon
--
-- so a mon that is already down does not earn the leader's approval.
--
-- The OTHER party-wide site is not this loop and must not use it:
-- engine/events/poisonstep.asm walks wPoisonStepPartyFlags and awards
-- HAPPINESS_POISONFAINT to exactly the mons that just dropped, every one of
-- which is at zero HP. That caller wants Happiness.change per flagged slot,
-- or this with opts.includeFainted.
function Happiness.changeParty(party, event, opts)
opts = opts or {}
local touched = 0
for _, mon in ipairs(party or {}) do
local alive = (mon.hp or 0) > 0 or opts.includeFainted
if alive and Happiness.change(mon, event) then touched = touched + 1 end
end
return touched
end
-- StepHappiness. Its own toggle: `inc a / and 1 / ld [hl], a / ret nz` alternates
-- 1, 0, 1, 0 and only falls through on the 0, so the party gains a point every
-- SECOND time this is called. The `inc [hl] / jr nz / ld [hl], $ff` on each
-- mon is why 255 sticks rather than wrapping to 0.
--
-- Returns true on the calls that actually raised the party.
function Happiness.stepCycle(save)
if type(save) ~= "table" then return false end
save.happinessStepCount = ((save.happinessStepCount or 0) + 1) % 2
if save.happinessStepCount ~= 0 then return false end
for _, mon in ipairs(save.party or {}) do
if not isEgg(mon) then
local from = mon.happiness or 0
mon.happiness = math.min(Happiness.MAX, from + 1)
-- A mon already sitting on $ff is walked over by `inc [hl] / jr nz`
-- writing $ff back, so nothing moved and there is nothing to report.
if mon.happiness ~= from then
emitChanged(mon, nil, "step", from, mon.happiness)
end
end
end
return true
end
-- One overworld footfall, from engine/overworld/events.asm's step block:
--
-- ld hl, wStepCount / inc [hl] / jr nz, .skip_happiness / farcall StepHappiness
--
-- `inc [hl]` sets z only on the wrap, so StepHappiness runs on the step that
-- takes wStepCount from 255 back to 0 -- one call every 256 steps, and a
-- party point every 512. src/core/gen2/Breeding.lua owns that same counter
-- (`save.stepCount`, advanced by Breeding.step), so this must be called AFTER
-- Breeding.step on the same footfall or it will read the previous step's
-- value.
function Happiness.step(save)
if type(save) ~= "table" then return false end
if (save.stepCount or 0) ~= 0 then return false end
return Happiness.stepCycle(save)
end
-- How many footfalls are still owed before the party next gains a point. For
-- a driver or a test that wants to walk exactly far enough rather than 512
-- times blind.
function Happiness.stepsToGain(save)
if type(save) ~= "table" then return nil end
local cycle = 256
local toWrap = (cycle - (save.stepCount or 0)) % cycle
if toWrap == 0 then toWrap = cycle end
-- A toggle sitting at 1 means the NEXT wrap is the one that pays out.
if (save.happinessStepCount or 0) == 1 then return toWrap end
return toWrap + cycle
end
-- The three answers HappinessCheckScript (engine/events/std_scripts.asm) picks
-- between off GetFirstPokemonHappiness: `ifless 50` and `ifless 150`, so the
-- boundaries are inclusive at the top of each band.
Happiness.RATER_UNHAPPY = 50
Happiness.RATER_KINDA = 150
function Happiness.raterBand(value)
value = value or 0
if value < Happiness.RATER_UNHAPPY then return "unhappy" end
if value < Happiness.RATER_KINDA then return "kinda" end
return "happy" -- HappinessText3, the one that means "it adores you"
end
-- GetFirstPokemonHappiness: the first party slot that is NOT an egg, which is
-- what the rater and the Goldenrod NPCs read. Returns the mon and its slot.
function Happiness.firstMon(party)
for index, mon in ipairs(party or {}) do
if not isEgg(mon) then return mon, index end
end
return nil, nil
end
-- What a mon starts life on. There is no ChangeHappiness event for a TRADE in
-- Gen 2 (that arrives in Gen 3): a traded or gifted mon simply comes in
-- through the struct initialisers in engine/pokemon/move_mon.asm, every one of
-- which writes BASE_HAPPINESS. The two exceptions are a FRIEND_BALL capture
-- (engine/items/item_effects.asm writes FRIEND_BALL_HAPPINESS over it, for the
-- party AND the box copy) and a hatchling, which
-- src/core/gen2/Breeding.lua sets to its own $78.
function Happiness.forNewMon(opts)
opts = opts or {}
if opts.ball == "FRIEND_BALL" then return Happiness.FRIEND_BALL end
return Happiness.BASE
end
return Happiness
+483
View File
@@ -0,0 +1,483 @@
-- Gen 2 pack items used on a party mon outside battle: the ITEMMENU_PARTY
-- half of engine/items/pack.asm UseItem, ported per item family from
-- engine/items/item_effects.asm.
--
-- Love-free on purpose, the same split src/core/gen2/Evolution.lua makes:
-- every routine here is table math over a party record, so the whole family
-- is assertable without a window. The screens (PackMenu -> PartyMenu ->
-- MoveDeleter) only choose the target and print what comes back.
--
-- Two contracts every entry point keeps, both from UseItem_SelectMon:
-- - an EGG refuses with CantUseOnEggMessage before any effect runs
-- (`cp EGG` is the routine's first test after the pick), and
-- - a refusal costs nothing: the item is only removed by the caller when
-- `used` comes back true, which is UseDisposableItem's own placement at
-- the tail of each success path.
local Happiness = require("src.core.gen2.Happiness")
local Mon = require("src.battle.gen2.Mon")
local ItemEffects = {}
-- data/items/heal_hp.asm HealingHPAmounts. MAX_STAT_VALUE (999) is the
-- table's own "everything" byte pair; nothing reaches it before the min()
-- against the missing HP.
ItemEffects.HEAL_HP = {
FRESH_WATER = 50, SODA_POP = 60, LEMONADE = 80,
HYPER_POTION = 200, SUPER_POTION = 50, POTION = 20,
MAX_POTION = 999, FULL_RESTORE = 999, MOOMOO_MILK = 100,
BERRY = 10, GOLD_BERRY = 30, ENERGYPOWDER = 50, ENERGY_ROOT = 200,
RAGECANDYBAR = 20, BERRY_JUICE = 20,
}
-- data/items/heal_status.asm StatusHealingActions, folded to the status class
-- each row's mask names. FULL_RESTORE is deliberately absent: its status
-- half only runs from FullRestoreEffect's full-HP arm, handled in useOnMon.
ItemEffects.HEAL_STATUS = {
ANTIDOTE = "psn", BURN_HEAL = "brn", ICE_HEAL = "frz",
AWAKENING = "slp", PARLYZ_HEAL = "par",
FULL_HEAL = "all", HEAL_POWDER = "all",
PSNCUREBERRY = "psn", PRZCUREBERRY = "par", BURNT_BERRY = "frz",
ICE_BERRY = "brn", MINT_BERRY = "slp", MIRACLEBERRY = "all",
}
-- RevivePokemon's one split: `cp REVIVE / jr z, .revive_half_hp` -- only the
-- plain REVIVE halves, MAX_REVIVE and REVIVAL_HERB both take ReviveFullHP.
ItemEffects.REVIVE = {
REVIVE = "half", MAX_REVIVE = "full", REVIVAL_HERB = "full",
}
-- RestorePP's per-item amounts: `ld c, 10` for the ETHER family, `ld c, 5`
-- under `cp MYSTERYBERRY`, and the `.restore_all` arm for the MAX pair.
-- `each` marks Elixer_RestorePPofAllMoves' loop over all four slots.
ItemEffects.RESTORE_PP = {
ETHER = { amount = 10 },
MAX_ETHER = { amount = "all" },
MYSTERYBERRY = { amount = 5 },
ELIXER = { amount = 10, each = true },
MAX_ELIXER = { amount = "all", each = true },
}
-- EnergypowderEnergyRootCommon / HealPowderEffect: the herb items charge
-- happiness for tasting bitter on top of their heal.
local BITTER = {
ENERGYPOWDER = "BITTERPOWDER", ENERGY_ROOT = "ENERGYROOT",
HEAL_POWDER = "BITTERPOWDER", REVIVAL_HERB = "REVIVALHERB",
}
-- _ItemWontHaveEffectText / _ItemCantUseOnEggText (data/text/common_3.asm).
ItemEffects.TEXT_NO_EFFECT = "It won't have any\neffect."
ItemEffects.TEXT_CANT_USE_ON_EGG = "That can't be used\non an EGG."
-- _PPRestoredText (data/text/common_3.asm).
ItemEffects.TEXT_PP_RESTORED = "PP was restored."
-- PrintPartyMenuActionText's .MenuActionTexts (engine/pokemon/party_menu.asm),
-- keyed by the class GetItemHealingAction resolves. Each is the two rows the
-- cart prints: the nickname line, then the fixed line.
local STATUS_TEXT = {
psn = "%s's\ncured of poison.",
par = "%s's\nrid of paralysis.",
brn = "%s's\nburn was healed.",
frz = "%s\nwas defrosted.",
slp = "%s\nwoke up.",
all = "%s's\nhealth returned.",
}
-- The port's party records spell status several ways (the battle writes the
-- long names, the party list reads both); fold them to the class letters the
-- heal tables use. FNT is an HP fact, not a status, and is not here.
--
-- Cross-file contract: every name src/battle/gen2/Battle.lua can write into
-- mon.status (STATUS_EFFECTS, SECONDARY_EFFECTS, HELD_STATUS_CURES and the
-- STATUS_TEXT lines beside them) must have a row here, or the cure for it
-- refuses everywhere in the pack. tests/gen2_battle_pack_test.lua walks the
-- battle's own tables against this one so the two cannot drift apart.
ItemEffects.STATUS_CLASS = {
psn = "psn", poison = "psn", toxic = "psn",
brn = "brn", burn = "brn",
frz = "frz", freeze = "frz",
par = "par", paralysis = "par", paralyze = "par",
slp = "slp", sleep = "slp",
}
local STATUS_CLASS = ItemEffects.STATUS_CLASS
local function monName(mon)
return (mon and (mon.nickname or mon.name or mon.species)) or "?"
end
local function maxHpOf(mon)
return mon.maxHp or (mon.stats and mon.stats.hp) or 0
end
local function fainted(mon)
return (mon.hp or 0) <= 0
end
-- HealStatus's field half: the status byte, the toxic counter and the turn
-- counter all clear together (wPlayerSubStatus5's SUBSTATUS_TOXIC rides the
-- same wipe on the cart; this port keeps that ramp on the mon record, so it
-- goes with the byte rather than with the battler).
local function clearStatus(mon)
mon.status = nil
mon.statusTurns = nil
mon.toxicCounter = nil
end
local function bitterHappiness(itemId, mon)
local event = BITTER[itemId]
if event then Happiness.change(mon, event) end
end
-- ItemRestoreHP: fainted and full-HP targets refuse before anything is spent,
-- then RestoreHealth adds the HealingHPAmounts row capped at max HP and
-- PARTYMENUTEXT_HEAL_HP prints the delta ("<name>\nrecovered NN HP!").
local function restoreHp(itemId, mon)
local amount = ItemEffects.HEAL_HP[itemId]
local maxHp = maxHpOf(mon)
if fainted(mon) or (mon.hp or 0) >= maxHp then
return { used = false, text = ItemEffects.TEXT_NO_EFFECT }
end
local healed = math.min(maxHp, (mon.hp or 0) + amount)
local gained = healed - (mon.hp or 0)
mon.hp = healed
-- FullRestoreEffect's .FullRestore clears the status alongside the refill.
if itemId == "FULL_RESTORE" then clearStatus(mon) end
bitterHappiness(itemId, mon)
return {
used = true,
text = ("%s\nrecovered %d HP!"):format(monName(mon), gained),
}
end
-- Which StatusHealingActions class cures this status. The fold table above
-- answers for every spelling the port writes; anything it does not know is a
-- status some mod registered, and its own `statuses` record says which cure
-- answers for it (src/battle/gen2/Battle.lua STATUSES, field `healClass`) --
-- read straight off the merged table so this module does not have to require
-- the battle engine to cure a burn.
function ItemEffects.healClassOf(status, data)
local key = tostring(status or ""):lower()
local class = STATUS_CLASS[key]
if class then return class end
local statuses = data and data.gen2Statuses
local record = statuses and (statuses[status] or statuses[key])
return record and record.healClass or nil
end
-- UseStatusHealer: the status byte must intersect the item's mask ($ff for
-- the HEAL_ALL family); a clean or fainted mon refuses. Field only -- the
-- confusion arm reads wPlayerSubStatus3, which does not exist out of battle.
local function healStatus(itemId, mon, class, data)
if fainted(mon) then
return { used = false, text = ItemEffects.TEXT_NO_EFFECT }
end
local have = ItemEffects.healClassOf(mon.status, data)
if not have or (class ~= "all" and have ~= class) then
return { used = false, text = ItemEffects.TEXT_NO_EFFECT }
end
clearStatus(mon)
bitterHappiness(itemId, mon)
local shape = STATUS_TEXT[class == "all" and "all" or have]
return { used = true, text = shape:format(monName(mon)) }
end
-- RevivePokemon: only a fainted mon accepts; REVIVE stands it up at half max
-- HP (ReviveHalfHP's `srl d / rr e`), the other two at full.
local function revive(itemId, mon)
if not fainted(mon) then
return { used = false, text = ItemEffects.TEXT_NO_EFFECT }
end
local maxHp = maxHpOf(mon)
mon.hp = (ItemEffects.REVIVE[itemId] == "half")
and math.max(1, math.floor(maxHp / 2)) or maxHp
clearStatus(mon)
bitterHappiness(itemId, mon)
return {
used = true,
text = ("%s\nis revitalized."):format(monName(mon)),
}
end
-- RareCandyEffect: MAX_LEVEL refuses; otherwise the level goes up one, the
-- experience is SET to CalcExpAtLevel's threshold, the stats recompute and
-- the CURRENT HP gains the max-HP delta -- no clamp and no faint check, so a
-- fainted mon stands up with the delta, exactly as the cart's arithmetic
-- leaves it. `learned` is LearnLevelMoves' slice: the EvosAttacks rows at
-- exactly the new level, for the caller to offer.
local function rareCandy(mon, data)
if (mon.level or 0) >= Mon.MAX_LEVEL then
return { used = false, text = ItemEffects.TEXT_NO_EFFECT }
end
local def = data and data.pokemon and data.pokemon[mon.species]
-- through Mon.growthFor, so a growth_rates record a mod registered is the
-- curve a Rare Candy uses too; wiring only some of the six readers would let
-- a mod curve drive battle EXP but not the candy
local growth = Mon.growthFor(data, def and def.growthRate)
local newLevel = (mon.level or 1) + 1
mon.level = newLevel
mon.experience = Mon.experienceForLevel(growth, newLevel)
local previousMax = maxHpOf(mon)
if def and def.baseStats then
mon.stats = Mon.stats(def.baseStats, mon.dvs, newLevel, mon.statExp)
mon.maxHp = mon.stats.hp
mon.hp = (mon.hp or previousMax) + (mon.maxHp - previousMax)
end
Happiness.change(mon, "GAINLEVEL")
local learned = {}
for _, entry in ipairs((def and def.levelMoves) or {}) do
if entry.level == newLevel then learned[#learned + 1] = entry.move end
end
return {
used = true,
level = newLevel,
learned = learned,
text = ("%s grew to\nlevel %d!"):format(monName(mon), newLevel),
}
end
-- --------------------------------------------------------- held attributes
--
-- The `held_items` registry (src/mods/Schemas.lua), which is the last two
-- columns of data/items/attributes.asm lifted out of the item record:
-- ItemAttributes' HELD_* effect byte and its parameter. Gen 1 has neither, so
-- this is one of the Gen 2-only registries -- gated under Gen 1, routed to
-- data.gen2HeldItems under Gen 2.
--
-- The table is a VIEW of data.items rather than a second source of truth: the
-- extractor writes both columns onto the item record and
-- src/battle/gen2/Battle.lua:itemDef reads them from there, so the registry
-- has to end up back on data.items or a mod's write would land in a table the
-- battle never opens. Hence the three routines below and the two calls in
-- src/core/Game2.lua:load that use them:
--
-- heldItemsFrom(items) builds the merge target, BEFORE mods:load, so the
-- registry's base is the vanilla row and a mod's
-- patch stacks on top of it (register collides, the
-- way it does against any other seeded id)
-- heldSnapshot(view) the same two bytes per id, kept aside
-- applyHeldItems(...) AFTER the merge: writes back only the ids whose
-- merged value differs from that snapshot
--
-- The diff is the whole reason this is not a blind write-back. A mod may just
-- as well reach an item's held columns through the shared `items` registry;
-- that merge has already landed on data.items by the time this runs, and
-- writing every row back would revert it to the vanilla view captured before
-- the merge. Only what the held_items merge actually changed is written, so
-- the two routes compose instead of racing.
function ItemEffects.heldItemsFrom(items)
local out = {}
for id, def in pairs(items or {}) do
if type(def) == "table" and def.heldEffect ~= nil then
out[id] = { heldEffect = def.heldEffect,
heldParameter = def.heldParameter or 0 }
end
end
return out
end
function ItemEffects.heldSnapshot(view)
local out = {}
for id, row in pairs(view or {}) do
if type(row) == "table" then
out[id] = { heldEffect = row.heldEffect, heldParameter = row.heldParameter }
end
end
return out
end
-- the merged `held_items` record for an item, the item's own columns when no
-- loader ran
function ItemEffects.heldItemFor(itemId, data)
if itemId == nil then return nil end
local merged = data and data.gen2HeldItems
local row = merged and merged[itemId]
if row then return row end
local def = data and data.items and data.items[itemId]
if type(def) ~= "table" or def.heldEffect == nil then return nil end
return { heldEffect = def.heldEffect, heldParameter = def.heldParameter or 0 }
end
-- Returns the number of item records the merge changed. Zero on a mod-free
-- boot, which is the parity claim: the seeded row IS the item's own two bytes,
-- so nothing differs and nothing is written.
function ItemEffects.applyHeldItems(data, snapshot)
local items = data and data.items
local merged = data and data.gen2HeldItems
if not (items and merged) then return 0 end
local applied = 0
for id, row in pairs(merged) do
if type(row) == "table" then
local was = (snapshot or {})[id]
if not was or was.heldEffect ~= row.heldEffect
or was.heldParameter ~= row.heldParameter then
local def = items[id]
if type(def) == "table" then
def.heldEffect = row.heldEffect
def.heldParameter = row.heldParameter or 0
applied = applied + 1
end
end
end
end
-- a tombstoned id (mod.content.held_items:remove) leaves the merged table
-- without the row, which is the cart's own "holds nothing"
for id, was in pairs(snapshot or {}) do
if merged[id] == nil and type(items[id]) == "table" and was.heldEffect then
items[id].heldEffect = nil
items[id].heldParameter = nil
applied = applied + 1
end
end
return applied
end
-- the merged `item_effects` record for an item id, the module's own when no
-- loader ran; `data` is optional so the callers that only know an item id
-- (src/core/Game2.lua asks partyAction before it has picked a mon) keep
-- their signature and read the module records
function ItemEffects.recordFor(itemId, data)
if itemId == nil then return nil end
local merged = data and data.gen2ItemEffects
return (merged and merged[itemId]) or ItemEffects.RECORDS[itemId]
end
-- Which family a PACK item runs on a party mon, or nil for anything whose
-- ITEMMENU_PARTY behaviour is not ported (vitamins, PP UP, evolution stones).
-- FULL_RESTORE classifies as "heal"; its full-HP status arm lives inside the
-- record's own `use` the way FullRestoreEffect keeps both halves in one
-- routine.
function ItemEffects.partyAction(itemId, data)
local record = ItemEffects.recordFor(itemId, data)
return record and record.action or nil
end
-- The one-call families (everything but PP, which needs a move pick first).
-- Returns { used, text, learned?, level? }.
function ItemEffects.useOnMon(itemId, mon, data)
if not mon then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end
if mon.isEgg then
return { used = false, text = ItemEffects.TEXT_CANT_USE_ON_EGG }
end
local record = ItemEffects.recordFor(itemId, data)
-- The PP family has its own entry point; reaching it here is the same
-- "nothing happens" the unported items get.
if not record or not record.use or record.action == "pp" then
return { used = false, text = ItemEffects.TEXT_NO_EFFECT }
end
return record.use({ item = itemId, mon = mon, data = data })
end
-- RestorePP over one move entry: a slot already at max refuses (`cp b /
-- jr nc, .dont_restore`), "all" fills it, a number adds capped at max.
local function restoreMove(move, amount)
if type(move) ~= "table" or not move.id then return false end
local maxPp = move.maxPp or move.pp or 0
if (move.pp or 0) >= maxPp then return false end
if amount == "all" then
move.pp = maxPp
else
move.pp = math.min(maxPp, (move.pp or 0) + amount)
end
return true
end
-- RestorePPEffect's two shapes: the ETHER family lands on one chosen slot,
-- the ELIXER family (Elixer_RestorePPofAllMoves) walks every slot and counts
-- -- one restored move is enough for the item to be spent.
function ItemEffects.usePpItem(itemId, mon, slot, data)
if not mon then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end
if mon.isEgg then
return { used = false, text = ItemEffects.TEXT_CANT_USE_ON_EGG }
end
local record = ItemEffects.recordFor(itemId, data)
if not record or not record.use or record.action ~= "pp" then
return { used = false, text = ItemEffects.TEXT_NO_EFFECT }
end
return record.use({ item = itemId, mon = mon, data = data, slot = slot })
end
-- ------------------------------------------------------------- the registry
--
-- The four tables above as records, in the shape src/mods/Schemas.lua's
-- `item_effects` registry validates. Same registry NAME the Gen 1 catalog
-- carries, and the same two Gen 1 fields where Gen 2 has a meaning for them:
-- `use` (required) and `field`, which is true for every row here because this
-- module is the ITEMMENU_PARTY half of pack.asm and nothing else.
--
-- `use` is fn(ctx) -> { used, text, learned?, level? }, where ctx carries
-- { item, mon, data, slot }. Gen 1 has no call site for its own item_effects
-- records yet, so this is the first shape either generation gives them; it is
-- the one Gold's two entry points already hand around.
--
-- Two fields Gen 2 adds rather than renaming anything: `action`, the family
-- src/core/Game2.lua and src/ui/gen2/BattleState.lua branch on before they
-- know a target (it is what partyAction answers), and `needsTarget`, which
-- the same screens read as "pick a party mon first".
ItemEffects.RECORDS = {}
local function record(itemId, action, use)
ItemEffects.RECORDS[itemId] = {
use = use, action = action, field = true, needsTarget = true,
}
end
-- Built in reverse precedence order, so an id that appeared in two of the
-- source tables would resolve the way partyAction's if-chain used to: heal
-- first, then status, revive, candy and pp. Nothing overlaps today; the order
-- is what keeps that true if something ever does.
for itemId, row in pairs(ItemEffects.RESTORE_PP) do
record(itemId, "pp", function(ctx)
-- RestorePPEffect's two shapes: the ETHER family lands on the chosen slot,
-- the ELIXER family (Elixer_RestorePPofAllMoves) walks every slot and
-- counts -- one restored move is enough for the item to be spent.
local moves = ctx.mon.moves or {}
local any = false
if row.each then
for _, move in ipairs(moves) do
if restoreMove(move, row.amount) then any = true end
end
else
any = restoreMove(moves[ctx.slot], row.amount)
end
if not any then
return { used = false, text = ItemEffects.TEXT_NO_EFFECT }
end
return { used = true, text = ItemEffects.TEXT_PP_RESTORED }
end)
end
record("RARE_CANDY", "candy", function(ctx)
return rareCandy(ctx.mon, ctx.data)
end)
for itemId in pairs(ItemEffects.REVIVE) do
record(itemId, "revive", function(ctx) return revive(ctx.item, ctx.mon) end)
end
for itemId, class in pairs(ItemEffects.HEAL_STATUS) do
record(itemId, "status", function(ctx)
return healStatus(ctx.item, ctx.mon, class, ctx.data)
end)
end
for itemId in pairs(ItemEffects.HEAL_HP) do
record(itemId, "heal", function(ctx)
-- FullRestoreEffect: a full-HP target falls through to FullyHealStatus
-- rather than refusing, so a paralyzed mon at full health is still cured.
if ctx.item == "FULL_RESTORE" and not fainted(ctx.mon)
and (ctx.mon.hp or 0) >= maxHpOf(ctx.mon) then
return healStatus(ctx.item, ctx.mon, "all", ctx.data)
end
return restoreHp(ctx.item, ctx.mon)
end)
end
-- vanilla registrations, engine-owned (Schemas.ENGINE), so a mod's register of
-- one of these ids collides the way it does on Red and has to say override
function ItemEffects.registerInto(registry, _, owner)
for id, entry in pairs(ItemEffects.RECORDS) do
registry:register(id, entry, owner)
end
end
return ItemEffects
+361
View File
@@ -0,0 +1,361 @@
-- The Magnet Train ride (pokegold engine/events/magnet_train.asm).
--
-- `special MagnetTrain` is a self-contained cutscene: it takes the whole frame
-- loop away from the overworld, redraws the background out of the train
-- station tileset, and runs a seven-entry jumptable until it sets
-- JUMPTABLE_EXIT. Everything below is that routine with no love calls in it,
-- so the timing, the scroll and the frameset can be asserted headless; the
-- screen that draws it is src/ui/gen2/MagnetTrainRide.lua.
--
-- The illusion is one 32x18 background and three horizontal SCX bands:
--
-- scanlines 0-46 wMagnetTrainOffset * 2 bushes, always moving
-- scanlines 47-94 wMagnetTrainPosition the train body
-- scanlines 95-143 wMagnetTrainOffset * 2 bushes again
--
-- MagnetTrain_UpdateLYOverrides writes those three runs into
-- wLYOverridesBackup every frame and then advances the offset, so the scenery
-- never stops even while the jumptable is parked on a .WaitScene. The train
-- band is what the jumptable actually moves, and the player sprite rides it
-- through wGlobalAnimXOffset, which is why the two stay locked together.
--
-- Everything here is 8-bit and wraps, exactly as the ASM's `add` does: the
-- forward trip runs wMagnetTrainPosition from 96 down past 0 to -96, and it is
-- the byte wrap that keeps SCX legal on the way.
local MagnetTrain = {}
MagnetTrain.__index = MagnetTrain
-- constants/gfx_constants.asm
local TILE_WIDTH = 8
local SCREEN_WIDTH, SCREEN_HEIGHT = 20, 18
local TILEMAP_WIDTH = 32
local SCREEN_HEIGHT_PX = 144
-- PAL_BG_* (constants/gfx_constants.asm) as 1-based palette slots, the way
-- src/world/gen2/Palettes.lua indexes a bgSet.
MagnetTrain.PAL_BG_GRAY = 1
MagnetTrain.PAL_BG_GREEN = 3
MagnetTrain.PAL_BG_YELLOW = 5
-- SetMagnetTrainPals paints the attribute map in four ByteFills: four rows of
-- green, ten of gray, four more of green, and then six tiles of yellow at
-- (7, 8) for the window the player is framed in.
local BUSH_ROWS_TOP = 4 -- hlbgcoord 0, 0 / bc = 4 * TILEMAP_WIDTH
local TRAIN_ROWS = 10 -- hlbgcoord 0, 4 / bc = 10 * TILEMAP_WIDTH
local WINDOW_ROW = 8 -- hlbgcoord 7, 8 / bc = 6
local WINDOW_COL, WINDOW_WIDTH = 7, 6
-- DrawMagnetTrain lays MagnetTrainTilemap over BG rows 6-9.
local FG_ROW = 6
local FG_ROWS = 4
-- Every value in this file is a hardware byte.
local function b(value) return value % 256 end
MagnetTrain.byte = b
--------------------------------------------------------------------------
-- The player in the window
--------------------------------------------------------------------------
-- data/sprite_anims/framesets.asm .Frameset_MagnetTrainRed: two OAM sets on an
-- eight frame beat, the fourth of them mirrored, then `oamrestart`. The
-- object's own sequence is SPRITE_ANIM_FUNC_NULL (data/sprite_anims/
-- objects.asm), so nothing ever moves the struct: the only motion the player
-- has is wGlobalAnimXOffset, which the two MoveTrain states advance.
local FRAMESET = {
{ oamset = 1, duration = 8, xflip = false },
{ oamset = 2, duration = 8, xflip = false },
{ oamset = 1, duration = 8, xflip = false },
{ oamset = 2, duration = 8, xflip = true },
"restart",
}
-- data/sprite_anims/oam.asm: SPRITE_ANIM_OAMSET_MAGNET_TRAIN_RED_1 and _2 are
-- vtile $00 and $04 over the same .OAMData_MagnetTrainRed 2x2 block. Those
-- two vtiles are the two four-tile requests MagnetTrain_LoadGFX_PlayMusic
-- makes: ChrisSpriteGFX at vTiles0 $00, and ChrisSpriteGFX + 12 tiles at
-- vTiles0 $04. A walking overworld sprite is six 16x16 frames, so those are
-- sheet frame 0 (standing down) and sheet frame 3 (the down walk step).
local OAMSET_VTILE = { 0x00, 0x04 }
MagnetTrain.SHEET_FRAME = { [0x00] = 0, [0x04] = 3 }
-- .OAMData_MagnetTrainRed, `dbsprite x tile, y tile, x px, y px, vtile, attr`.
-- Every entry carries OAM_PRIO, so on the cart the four tiles sit BEHIND
-- background colours 1-3 and only show through the window's colour 0.
local OAM_DATA = {
{ x = b(-1 * TILE_WIDTH), y = b(-1 * TILE_WIDTH), tile = 0x00 },
{ x = b(0 * TILE_WIDTH), y = b(-1 * TILE_WIDTH), tile = 0x01 },
{ x = b(-1 * TILE_WIDTH), y = b(0 * TILE_WIDTH), tile = 0x02 },
{ x = b(0 * TILE_WIDTH), y = b(0 * TILE_WIDTH), tile = 0x03 },
}
-- AddOrSubtractX: a mirrored object flips around its own 8-pixel cell.
local function mirror(value, flip)
if not flip then return value end
return b(-(value + TILE_WIDTH))
end
--------------------------------------------------------------------------
-- The ride
--------------------------------------------------------------------------
-- `toGoldenrod` is the wScriptVar the script left behind: Goldenrod's officer
-- writes `setval FALSE` and Saffron's writes `setval TRUE`, and MagnetTrain
-- reads it as "and a / jr nz, .ToGoldenrod".
--
-- opts.bgTiles is MagnetTrainBGTiles (a 2x18 tilemap) and opts.fgTilemap is
-- MagnetTrainTilemap (20x4); both come from the extracted cache and either may
-- be missing, in which case :tilemap() answers nil and the ride still runs.
function MagnetTrain.new(opts)
opts = opts or {}
local self = setmetatable({}, MagnetTrain)
self.toGoldenrod = opts.toGoldenrod and true or false
if self.toGoldenrod then
-- .ToGoldenrod: `ld a, -1` / `lb bc, -8 tiles, -12 tiles` /
-- `lb de, (11 tiles) + (11 tiles + 4), 12 tiles`.
self.direction = b(-1)
self.holdPosition = b(-8 * TILE_WIDTH) -- b
self.initPosition = b(-12 * TILE_WIDTH) -- c
self.finalPosition = b(12 * TILE_WIDTH) -- e
self.playerSpriteInitX =
b((11 * TILE_WIDTH) + (11 * TILE_WIDTH + 4)) -- d
else
-- forwards: `ld a, 1` / `lb bc, 8 tiles, 12 tiles` /
-- `lb de, (11 tiles) - (11 tiles + 4), -12 tiles`.
self.direction = 1
self.holdPosition = b(8 * TILE_WIDTH)
self.initPosition = b(12 * TILE_WIDTH)
self.finalPosition = b(-12 * TILE_WIDTH)
self.playerSpriteInitX = b((11 * TILE_WIDTH) - (11 * TILE_WIDTH + 4))
end
-- MagnetTrain_LoadGFX_PlayMusic's tail writes wJumptableIndex and the three
-- bytes after it, so the wait counter starts life holding the init position.
-- State 0 overwrites it before any .WaitScene reads it.
self.index = 0
self.offset = self.initPosition
self.position = self.initPosition
self.waitCounter = self.initPosition
self.exited = false
self.globalX = 0
-- The sprite struct does not exist until .InitPlayerSpriteAnim runs.
self.spriteX, self.spriteY = nil, nil
self.frame, self.frameDuration = -1, 0
self.oamFrame = nil
self.bgTiles = opts.bgTiles
self.fgTilemap = opts.fgTilemap
self:updateLYOverrides(true)
return self
end
function MagnetTrain:done() return self.exited end
-- MagnetTrain's .loop, one pass: the exit bit, PlaySpriteAnimations, the
-- jumptable, then the LY overrides. Returns the sfx label the frame played,
-- which is only ever SFX_TRAIN_ARRIVED on the last one.
function MagnetTrain:update()
if self.exited then return nil end
self:stepSpriteFrame()
local sfx = self:runJumptable()
self:updateLYOverrides()
return sfx
end
-- MagnetTrain_Jumptable.Next
function MagnetTrain:next()
self.index = self.index + 1
end
-- .WaitScene: zero means "advance", anything else counts down. A counter of
-- 128 therefore holds for 129 frames, the last of which is the one that reads
-- zero and moves on.
function MagnetTrain:waitScene()
if self.waitCounter == 0 then
self:next()
return
end
self.waitCounter = self.waitCounter - 1
end
function MagnetTrain:runJumptable()
local index = self.index
if index == 0 then
-- .InitPlayerSpriteAnim: InitSpriteAnimStruct at d = (8 + 2) * 8 + 5,
-- e = wMagnetTrainPlayerSpriteInitX, then SPRITEANIMSTRUCT_TILE_ID = 0.
self.spriteY = b((8 + 2) * TILE_WIDTH + 5)
self.spriteX = self.playerSpriteInitX
self.frame, self.frameDuration, self.oamFrame = -1, 0, nil
self:next()
self.waitCounter = 128
elseif index == 1 or index == 3 or index == 5 then
self:waitScene()
elseif index == 2 then
-- .MoveTrain1: one pixel a frame until the train reaches its hold
-- position, then park for another 128 frames.
if self.position == self.holdPosition then
self:next()
self.waitCounter = 128
return nil
end
self.position = b(self.position - self.direction)
self.globalX = b(self.globalX + self.direction)
elseif index == 4 then
-- .MoveTrain2: the same, at double speed, until it leaves the screen.
if self.position == self.finalPosition then
self:next()
return nil
end
self.position = b(self.position - 2 * self.direction)
self.globalX = b(self.globalX + 2 * self.direction)
elseif index >= 6 then
-- .TrainArrived: JUMPTABLE_EXIT and SFX_TRAIN_ARRIVED, and the loop reads
-- the exit bit at the top of the next pass.
self.exited = true
return "Sfx_TrainArrived"
end
return nil
end
-- MagnetTrain_UpdateLYOverrides. The three runs are 6*8-1, 6*8 and 6*8+1
-- entries, which is 144 scanlines exactly; hSCX takes the first band's value
-- because line 0 is drawn before the LCD interrupt has fired. The offset
-- advances by two per frame (`add d` twice) AFTER the overrides are written.
--
-- `initial` builds the first frame's table without advancing, matching
-- MagnetTrain_InitLYOverrides, which ByteFills the whole array with the init
-- position before the loop starts.
function MagnetTrain:updateLYOverrides(initial)
local ly = self.ly or {}
if initial then
for line = 1, SCREEN_HEIGHT_PX do ly[line] = self.initPosition end
self.ly = ly
self.scx = self.initPosition
return ly
end
local scx = b(self.offset * 2)
self.scx = scx
local line = 1
for _ = 1, 6 * TILE_WIDTH - 1 do ly[line] = scx; line = line + 1 end
for _ = 1, 6 * TILE_WIDTH do ly[line] = self.position; line = line + 1 end
for _ = 1, 6 * TILE_WIDTH + 1 do ly[line] = scx; line = line + 1 end
self.ly = ly
self.offset = b(self.offset + 2 * self.direction)
return ly
end
-- The SCX each of the three bands is scrolled by this frame, as
-- { first scanline, last scanline (inclusive), scx }. A band is a run of
-- equal LY overrides, so this is the same information the table holds and the
-- shape a renderer wants.
function MagnetTrain:bands()
local ly = self.ly
if not ly then return {} end
local out = {}
local start, value = 0, ly[1]
for line = 1, SCREEN_HEIGHT_PX do
if ly[line] ~= value then
out[#out + 1] = { start, line - 2, value }
start, value = line - 1, ly[line]
end
end
out[#out + 1] = { start, SCREEN_HEIGHT_PX - 1, value }
return out
end
--------------------------------------------------------------------------
-- The background
--------------------------------------------------------------------------
-- DrawMagnetTrain. Rows 0-17 are MagnetTrainBGTiles' two-tile pair for that
-- row repeated across all 32 columns (`.FillAlt`, TILEMAP_WIDTH / 2 times),
-- and then MagnetTrainTilemap's four 20-tile lines are laid over rows 6-9.
--
-- Answers nil when the cache carries no tilemaps, which is what a cache built
-- before the extractor learned about them looks like.
function MagnetTrain:tilemap()
local bg = self.bgTiles
if not (bg and #bg >= SCREEN_HEIGHT * 2) then return nil end
local rows = {}
for row = 0, SCREEN_HEIGHT - 1 do
local even, odd = bg[row * 2 + 1], bg[row * 2 + 2]
local line = {}
for col = 0, TILEMAP_WIDTH - 1 do
line[col + 1] = (col % 2 == 0) and even or odd
end
rows[row + 1] = line
end
local fg = self.fgTilemap
if fg and #fg >= SCREEN_WIDTH * FG_ROWS then
for line = 0, FG_ROWS - 1 do
local row = rows[FG_ROW + line + 1]
for col = 0, SCREEN_WIDTH - 1 do
row[col + 1] = fg[line * SCREEN_WIDTH + col + 1]
end
end
end
return rows
end
-- SetMagnetTrainPals, read back as "which palette does this cell use".
-- `col` and `row` are 0-based BG map coordinates.
function MagnetTrain.paletteSlot(col, row)
if row == WINDOW_ROW and col >= WINDOW_COL
and col < WINDOW_COL + WINDOW_WIDTH then
return MagnetTrain.PAL_BG_YELLOW
end
if row < BUSH_ROWS_TOP then return MagnetTrain.PAL_BG_GREEN end
if row < BUSH_ROWS_TOP + TRAIN_ROWS then return MagnetTrain.PAL_BG_GRAY end
return MagnetTrain.PAL_BG_GREEN
end
--------------------------------------------------------------------------
-- The sprite
--------------------------------------------------------------------------
-- GetSpriteAnimFrame, cut down to one frameset that never waits, ends or
-- changes sequence. A frame with duration 8 is therefore shown nine times:
-- the pass that sets the duration, then eight that decrement it.
function MagnetTrain:stepSpriteFrame()
if not self.spriteX then return end
if self.frameDuration ~= 0 then
self.frameDuration = self.frameDuration - 1
return
end
self.frame = self.frame + 1
local entry = FRAMESET[self.frame + 1]
if entry == "restart" then
self.frame = 0
entry = FRAMESET[1]
end
self.frameDuration = entry.duration
self.oamFrame = entry
end
-- The four OAM entries the player is drawn as this frame, each
-- { x, y, tile, xflip } in SCREEN pixels (the hardware's byte minus the 8 and
-- 16 pixel OAM origins). `tile` is the vtile the OAM set resolves to, which
-- MagnetTrain.SHEET_FRAME turns into a 16x16 frame of the walking sheet.
--
-- Empty before .InitPlayerSpriteAnim has run.
function MagnetTrain:playerOam()
local entry = self.oamFrame
if not (entry and self.spriteX) then return {} end
local vtile = OAMSET_VTILE[entry.oamset]
local out = {}
for _, sprite in ipairs(OAM_DATA) do
local x = b(self.spriteX + self.globalX + mirror(sprite.x, entry.xflip))
local y = b(self.spriteY + sprite.y)
out[#out + 1] = {
x = x - 8,
y = y - 16,
tile = vtile + sprite.tile,
xflip = entry.xflip,
}
end
return out
end
return MagnetTrain
+503
View File
@@ -0,0 +1,503 @@
-- MAIL: the ten mail items, the letter a party mon carries, and the MAILBOX
-- the player's PC keeps. engine/pokemon/mail.asm and engine/pokemon/mail_2.asm,
-- with the `mailmsg` struct from macros/ram.asm as it is laid out in
-- ram/sram.asm (sPartyMail, sMailboxCount, sMailboxes).
--
-- The struct is what makes this save-format work rather than a field on a mon:
--
-- Message MAIL_MSG_LENGTH bytes, drawn as two MAIL_LINE_LENGTH lines
-- Author NAME_LENGTH - 1 characters, the player at compose time
-- AuthorID dw, wPlayerID -- what CheckPokeMail's OT half would read
-- Species db, wCurPartySpecies when the letter was written
-- Type db, the MAIL item itself, which is what picks the stationery
--
-- sPartyMail is SIX of those indexed BY PARTY SLOT, not by mon, which is the
-- whole reason `removeSlot` and `swapSlots` exist below: RemoveMonFromPartyOrBox
-- shifts the mail up behind a departing mon (engine/pokemon/move_mon.asm's
-- "Mail time!" tail) and SwitchPartyMons swaps two structs
-- (engine/pokemon/switchpartymons.asm). Hanging the letter off the mon table
-- instead would have been easier and would have been a different save.
--
-- Nothing here touches love or a screen: the four mail screens
-- (src/ui/gen2/MailCompose, MailRead, MailMenu, MailboxMenu) and the script
-- VM's `givepokemail` / `checkpokemail` all drive these routines, so the rules
-- are testable without a keyboard on screen.
local Runtime = require("src.mods.Runtime")
local Mail = {}
-- constants/item_data_constants.asm.
Mail.MAIL_MSG_LENGTH = 0x20
Mail.MAIL_LINE_LENGTH = 0x10
Mail.MAILBOX_CAPACITY = 10
-- constants/text_constants.asm NAME_LENGTH is 11 including the terminator, and
-- the struct's Author field is NAME_LENGTH - 1.
Mail.AUTHOR_LENGTH = 10
-- constants/pokemon_data_constants.asm PARTY_LENGTH; sPartyMail is this many
-- structs and no more.
Mail.PARTY_LENGTH = 6
-- data/items/mail_items.asm MailItems, in its order. ItemIsMail is a linear
-- search of exactly this list, so anything not on it is not mail however its
-- id is spelled -- which matters, because LITEBLUEMAIL and PORTRAITMAIL do not
-- end in "_MAIL" and a name test would miss both.
Mail.ITEMS = {
"FLOWER_MAIL", "SURF_MAIL", "LITEBLUEMAIL", "PORTRAITMAIL", "LOVELY_MAIL",
"EON_MAIL", "MORPH_MAIL", "BLUESKY_MAIL", "MUSIC_MAIL", "MIRAGE_MAIL",
}
-- The *_MAIL_INDEX block at the top of engine/pokemon/mail_2.asm, which is a
-- plain `const_def` (0-based) and indexes both MailGFXPointers and
-- LoadMailPalettes.MailPals. Kept as a lookup because the READ screen needs
-- it to know where the author line sits: PORTRAITMAIL_INDEX puts it at column
-- 8, MORPH_MAIL_INDEX at 6, everything else at 5 (MailGFX_PlaceMessage).
Mail.INDEX = {}
local IS_MAIL = {}
for i, id in ipairs(Mail.ITEMS) do
Mail.INDEX[id] = i - 1
IS_MAIL[id] = true
end
-- constants/script_constants.asm POKEMAIL_*, a `const_def` block, so 0-based.
Mail.POKEMAIL_WRONG_MAIL = 0
Mail.POKEMAIL_CORRECT = 1
Mail.POKEMAIL_REFUSED = 2
Mail.POKEMAIL_NO_MAIL = 3
Mail.POKEMAIL_LAST_MON = 4
-- ItemIsMail (engine/pokemon/mail_2.asm), which is the ONLY definition of
-- "this is mail" anywhere on the cart. Everything that asks -- the Day-Care,
-- the PC's deposit, the party submenu's MAIL row, the Time Capsule -- calls it.
function Mail.isMail(itemId)
return itemId ~= nil and IS_MAIL[itemId] == true
end
function Mail.monHoldsMail(mon)
return type(mon) == "table" and Mail.isMail(mon.item)
end
--------------------------------------------------------------------------
-- Storage
--------------------------------------------------------------------------
-- save.mail is the pair of SRAM regions: `party` is sPartyMail (a sparse array
-- keyed by party slot) and `box` is sMailboxes with sMailboxCount implied by
-- its length. Created on demand so a save that has never seen a letter still
-- serializes as an empty table rather than six zero-filled structs.
function Mail.state(save)
if type(save) ~= "table" then return { party = {}, box = {} } end
local state = save.mail
if type(state) ~= "table" then
state = { party = {}, box = {} }
save.mail = state
end
state.party = state.party or {}
state.box = state.box or {}
return state
end
-- One `mailmsg`. `message` keeps the line break as "\n" the way the text
-- decoder does, because GivePokeMail copies the script's bytes verbatim and a
-- `next` in there is a real character in the buffer.
function Mail.entry(itemId, message, author, authorId, species)
return {
type = itemId,
message = message or "",
author = author or "",
authorId = authorId or 0,
species = species,
}
end
-- Picking a party letter out of the record. This is also the mail.read
-- latch's re-arm (see Mail.lines): every reader asks here for the struct it is
-- about to open, so a second look at the same letter is a second event.
function Mail.get(save, slot)
Mail.armRead()
return Mail.state(save).party[slot]
end
function Mail.set(save, slot, entry)
if not (slot and slot >= 1 and slot <= Mail.PARTY_LENGTH) then return false end
Mail.state(save).party[slot] = entry
return true
end
function Mail.clear(save, slot)
Mail.state(save).party[slot] = nil
end
-- The "Mail time!" tail of RemoveMonFromPartyOrBox: every struct after the
-- departing slot moves up one, and the slot that was last is cleared. Called
-- by anything that takes a mon OUT of the party -- a deposit, a trade, the
-- CheckPokeMail handover -- because sPartyMail is keyed by slot and would
-- otherwise hand the next mon along someone else's letter.
function Mail.removeSlot(save, slot)
local party = Mail.state(save).party
if not (slot and slot >= 1) then return end
for i = slot, Mail.PARTY_LENGTH - 1 do
party[i] = party[i + 1]
end
party[Mail.PARTY_LENGTH] = nil
end
-- SwitchPartyMons copies the two structs through wSwitchMonBuffer, so a party
-- reorder carries each letter with its mon.
function Mail.swapSlots(save, a, b)
local party = Mail.state(save).party
party[a], party[b] = party[b], party[a]
end
-- IsAnyMonHoldingMail. The PC's MOVE POKéMON W/O MAIL row and the Time
-- Capsule's party check are the two callers; both refuse outright rather than
-- naming which mon.
function Mail.anyMonHoldingMail(save)
for _, mon in ipairs((save and save.party) or {}) do
if Mail.monHoldsMail(mon) then return true end
end
return false
end
--------------------------------------------------------------------------
-- Writing
--------------------------------------------------------------------------
-- mail.written, a Gen 2 invention: Gen 1 has no mail, so there is no name to
-- share. Both routines that put a NEW struct into a party slot raise it --
-- the compose screen and the `givepokemail` a gift script runs -- because from
-- a mod's side they are the same fact: a letter now exists and is pinned to a
-- mon. Moving one that already exists (MoveMailFromPCToParty, SwitchPartyMons)
-- does not, because nothing was written.
--
-- entry the `mailmsg` struct, already trimmed to MAIL_MSG_LENGTH
-- slot the party slot it landed on, 1 based
-- mon the party record it is pinned to, or nil when compose was handed
-- no mon
-- message the stored text, with its "\n" kept as one character
-- author whose name the letter carries -- the player for a composed one,
-- the giver's OT for a scripted one
-- source "compose" for ComposeMailMessage, "script" for GivePokeMail
local function emitWritten(entry, slot, mon, source)
if not Runtime.wants("mail.written") then return end
Runtime.emit("mail.written", {
entry = entry, slot = slot, mon = mon,
message = entry.message, author = entry.author, source = source,
})
end
-- ComposeMailMessage (engine/pokemon/mon_menu.asm), the tail after the
-- keyboard closes: the author is wPlayerName, the id wPlayerID, the species
-- wCurPartySpecies and the type wCurItem -- so a letter remembers who wrote it
-- and which mon it was pinned to, neither of which the reader can change
-- afterwards.
function Mail.compose(save, slot, message, mon, itemId)
if not (save and slot and itemId) then return false end
local player = save.player or {}
local entry = Mail.entry(itemId, Mail.trim(message),
(player.name or ""):sub(1, Mail.AUTHOR_LENGTH), player.id or 0,
mon and mon.species)
local ok = Mail.set(save, slot, entry)
if ok then emitWritten(entry, slot, mon, "compose") end
return ok
end
-- GivePokeMail (engine/pokemon/mail.asm): `ld a, [wPartyCount] / dec a` -- the
-- letter always lands on the LAST party member, which is the mon the
-- `givepoke` right before it just added. The author fields come from that
-- mon's OT rather than from the player, because the giver is the OT.
function Mail.give(save, itemId, message)
local party = save and save.party
if not (party and #party > 0 and Mail.isMail(itemId)) then return false end
local slot = #party
local mon = party[slot]
mon.item = itemId
local entry = Mail.entry(itemId, Mail.trim(message),
tostring(mon.otName or mon.ot or ""):sub(1, Mail.AUTHOR_LENGTH),
mon.otId or 0, mon.species)
local ok = Mail.set(save, slot, entry)
if ok then emitWritten(entry, slot, mon, "script") end
return ok
end
-- The buffer is MAIL_MSG_LENGTH bytes and the compose screen can never write
-- past it, but a script's string and a hand-built save can, so the trim is
-- here rather than at each call site. Counted in characters, not bytes: the
-- charset carries a handful of multi-byte glyphs (é, ♂, ¥, …).
function Mail.trim(message)
message = tostring(message or "")
local out, count = {}, 0
for _, ch in ipairs(Mail.characters(message)) do
if count >= Mail.MAIL_MSG_LENGTH then break end
out[#out + 1] = ch
count = count + 1
end
return table.concat(out)
end
-- UTF-8 aware split, so "é" counts as one character the way one charmap byte
-- does. A "\n" is a character too: it is the `next` byte GivePokeMail copied.
function Mail.characters(text)
local out = {}
local i, n = 1, #text
while i <= n do
local b = text:byte(i)
local width = 1
if b >= 0xF0 then width = 4
elseif b >= 0xE0 then width = 3
elseif b >= 0xC0 then width = 2 end
out[#out + 1] = text:sub(i, i + width - 1)
i = i + width
end
return out
end
-- The two rows MailGFX_PlaceMessage draws. A message written on the compose
-- screen has no break in it -- the cart stores '<NEXT>' at offset
-- MAIL_LINE_LENGTH and PlaceString obeys it -- so the split is by width; a
-- script's message carries its own break and is split on that instead.
-- mail.read, a Gen 2 invention: Gen 1 has no mail, so there is no name to
-- share. MailGFX_PlaceMessage is the cart's own "the player is looking at
-- this letter" moment and Mail.lines is the port's transcription of it, so the
-- event rides here rather than on the reader screen -- one seam serves the
-- party reader, the MAILBOX reader and anything a mod opens itself.
--
-- The latch is what makes it one event per opened letter instead of one per
-- frame: src/ui/gen2/MailRead.lua redraws the page every frame while it is up.
-- It is re-armed by Mail.get and Mail.mailbox, which are how a reader picks
-- the letter it is about to open (src/ui/gen2/MailMenu.lua:read,
-- src/ui/gen2/MailboxMenu.lua:readMail), so opening the SAME letter twice is
-- two events rather than one.
--
-- entry the `mailmsg` struct being read
-- message its stored text
-- author the name printed under it, "" when the struct carries none
-- top, bottom the two rows as MailGFX_PlaceMessage lays them out
local lastRead = nil
function Mail.armRead()
lastRead = nil
end
local function emitRead(entry, top, bottom)
if type(entry) ~= "table" then return end
if not Runtime.wants("mail.read") then
lastRead = nil
return
end
if lastRead == entry then return end
lastRead = entry
Runtime.emit("mail.read", {
entry = entry, message = entry.message, author = entry.author,
top = top, bottom = bottom,
})
end
function Mail.lines(entry)
local message = (type(entry) == "table" and entry.message) or ""
local top, bottom = message:match("^(.-)\n(.*)$")
if not top then
local chars = Mail.characters(message)
if #chars <= Mail.MAIL_LINE_LENGTH then
top, bottom = message, ""
else
top = table.concat(chars, "", 1, Mail.MAIL_LINE_LENGTH)
bottom = table.concat(chars, "", Mail.MAIL_LINE_LENGTH + 1)
end
end
emitRead(entry, top, bottom)
return top, bottom
end
--------------------------------------------------------------------------
-- The MAILBOX
--------------------------------------------------------------------------
function Mail.mailboxCount(save)
return #Mail.state(save).box
end
-- sMailboxes itself. Re-arms the mail.read latch for the same reason
-- Mail.get does: the MAILBOX reader picks its letter out of this list.
function Mail.mailbox(save)
Mail.armRead()
return Mail.state(save).box
end
function Mail.mailboxFull(save)
return Mail.mailboxCount(save) >= Mail.MAILBOX_CAPACITY
end
-- SendMailToPC. Carry (false here) on either "this mon is not holding mail"
-- or "the MAILBOX is full" -- MonMailAction prints the same .MailboxFullText
-- for both, because the first can only happen if the menu row lied. On
-- success the struct moves, the party slot is zero-filled AND the mon's held
-- item is cleared, all three in the one routine.
function Mail.sendToPc(save, slot)
local mon = save and save.party and save.party[slot]
if not (mon and Mail.monHoldsMail(mon)) then return false end
if Mail.mailboxFull(save) then return false end
local state = Mail.state(save)
local entry = state.party[slot]
if not entry then
-- A mon carrying a mail ITEM with no struct behind it (an older save, or a
-- letter the extractor could not resolve): the cart would copy 47 zero
-- bytes, so send a blank letter rather than dropping the item on the floor.
entry = Mail.entry(mon.item, "", "", 0, mon.species)
end
state.box[#state.box + 1] = entry
state.party[slot] = nil
mon.item = nil
return true
end
-- DeleteMailFromPC: the shift-up that keeps sMailboxes dense and decrements
-- sMailboxCount.
function Mail.deleteFromPc(save, index)
local box = Mail.state(save).box
if not box[index] then return nil end
return table.remove(box, index)
end
-- MoveMailFromPCToParty, ATTACH MAIL's own half. The struct is copied into
-- the party slot, the mail's TYPE byte becomes the mon's held item -- which is
-- how a letter and its stationery stay together -- and only then is the
-- mailbox entry deleted.
function Mail.moveFromPcToParty(save, index, slot)
local box = Mail.state(save).box
local entry = box[index]
local mon = save and save.party and save.party[slot]
if not (entry and mon) then return false end
Mail.set(save, slot, entry)
mon.item = entry.type
table.remove(box, index)
return true
end
--------------------------------------------------------------------------
-- CheckPokeMail
--------------------------------------------------------------------------
-- CheckPokeMail (engine/pokemon/mail.asm), the `checkpokemail` opcode's whole
-- body once the party list has answered. `slot` is nil for the B press.
--
-- The order is the cart's and it matters: a REFUSED never looks at the mon at
-- all, NO_MAIL beats WRONG_MAIL, and LAST_MON is checked AFTER the message
-- compares equal -- so handing over the right mon with the right letter while
-- it is your last conscious one still loses you the reward and keeps the mon.
--
-- `expected` is the raw string the script points at, terminated by '@' on the
-- cart; the comparison runs until that terminator, so a stored message LONGER
-- than the expected one still matches. The removal on CORRECT is
-- RemoveMonFromPartyOrBox with REMOVE_PARTY, which is why the mail shift rides
-- along with it.
function Mail.checkPokeMail(save, slot, expected)
if not slot then return Mail.POKEMAIL_REFUSED end
local mon = save and save.party and save.party[slot]
if not (mon and Mail.monHoldsMail(mon)) then return Mail.POKEMAIL_NO_MAIL end
local entry = Mail.get(save, slot)
local got = (entry and entry.message) or ""
if type(expected) ~= "string" or expected == "" then
-- No expected message resolved (a cache built before the extractor
-- followed the operand). The cart compares against real bytes; with none,
-- WRONG_MAIL is the answer that changes nothing and keeps the mon.
return Mail.POKEMAIL_WRONG_MAIL
end
if got:sub(1, #expected) ~= expected then
return Mail.POKEMAIL_WRONG_MAIL
end
-- CheckCurPartyMonFainted: carry when this is the last conscious mon.
local healthy = 0
for i, member in ipairs(save.party) do
if i ~= slot and (member.hp or 0) > 0 then healthy = healthy + 1 end
end
if (mon.hp or 0) > 0 and healthy == 0 then return Mail.POKEMAIL_LAST_MON end
table.remove(save.party, slot)
Mail.removeSlot(save, slot)
return Mail.POKEMAIL_CORRECT
end
--------------------------------------------------------------------------
-- Save hygiene
--------------------------------------------------------------------------
-- A quarantine pass with the same discipline src/core/gen2/Save.lua's
-- scrubScriptMem has: a struct play would nil-index or draw off the screen
-- never reaches the game, and whatever had to be dropped is reported.
--
-- What can go wrong here that nothing else can vouch for: a party key outside
-- 1..6 (sPartyMail is six structs), a mailbox past MAILBOX_CAPACITY, a `type`
-- that is not one of the ten mail items (so the READ screen would have no
-- stationery and MoveMailFromPCToParty would hang a non-item on a mon), and a
-- message longer than the buffer, which is trimmed rather than dropped because
-- the first MAIL_MSG_LENGTH characters are still the player's letter.
local function cleanEntry(entry)
if type(entry) ~= "table" then return nil, "not a struct" end
if not Mail.isMail(entry.type) then return nil, "not a MAIL item" end
local message = tostring(entry.message or "")
local trimmed = Mail.trim(message)
local author = tostring(entry.author or ""):sub(1, Mail.AUTHOR_LENGTH)
local authorId = tonumber(entry.authorId) or 0
if authorId ~= math.floor(authorId) or authorId < 0 or authorId > 0xFFFF then
authorId = 0
end
return {
type = entry.type,
message = trimmed,
author = author,
authorId = authorId,
species = entry.species,
}, (trimmed ~= message) and "message trimmed" or nil
end
-- report.lostMail collects { where = "party"|"box", slot, why }.
function Mail.validate(save, report)
local lost = report and report.lostMail or {}
if report then report.lostMail = lost end
if type(save) ~= "table" then return lost end
local raw = save.mail
if raw ~= nil and type(raw) ~= "table" then
lost[#lost + 1] = { where = "mail", why = "not a table" }
save.mail = nil
end
local state = Mail.state(save)
local party = {}
for key, entry in pairs(state.party) do
local slot = tonumber(key)
if not (slot and slot == math.floor(slot)
and slot >= 1 and slot <= Mail.PARTY_LENGTH) then
lost[#lost + 1] = { where = "party", slot = key, why = "slot out of range" }
else
local clean, why = cleanEntry(entry)
if clean then
party[slot] = clean
if why then
lost[#lost + 1] = { where = "party", slot = slot, why = why }
end
else
lost[#lost + 1] = { where = "party", slot = slot, why = why }
end
end
end
state.party = party
local box = {}
for _, entry in ipairs(state.box) do
local clean, why = cleanEntry(entry)
if not clean then
lost[#lost + 1] = { where = "box", slot = #box + 1, why = why }
elseif #box >= Mail.MAILBOX_CAPACITY then
lost[#lost + 1] = { where = "box", slot = #box + 1, why = "MAILBOX full" }
else
box[#box + 1] = clean
if why then
lost[#lost + 1] = { where = "box", slot = #box, why = why }
end
end
end
state.box = box
return lost
end
return Mail
+244
View File
@@ -0,0 +1,244 @@
-- Mom spends the money she is saving for you.
--
-- engine/events/mom_phone.asm, with data/items/mom_phone.asm beside it. This
-- is the other end of Bank of Mom: the quarter WinTrainerBattle skims off
-- every won trainer battle (src/battle/gen2/Prize.lua) piles up in
-- wMomsMoney, and MomTriesToBuySomething is what she does with it. Nothing
-- else in the game spends her savings.
--
-- Two shopping lists, and they behave completely differently:
--
-- MomItems_2 is a LADDER, walked once in order by wWhichMomItem. Each row
-- carries the savings balance that unlocks it, so the four DOLLS -- the
-- only way a Gold player gets a CHARMANDER, CLEFAIRY or PIKACHU doll or
-- the BIG SNORLAX at all -- arrive at 10000, 30000, 50000 and 100000
-- saved. A row is bought once and the index moves on.
-- MomItems_1 is a RANDOM consolation buy that fires only when the savings
-- land EXACTLY on a multiple of MOM_MONEY (2300) that
-- wMomItemTriggerBalance has not already passed. It never advances
-- wWhichMomItem, so it cannot cost the player a rung of the ladder.
--
-- love-free and save-shaped: takes the Gold save (src/core/gen2/Save.lua) and
-- the event bitfield (src/world/gen2/Events.lua), so World, and the tests,
-- drive the same routine.
local Strings = require("src.core.Strings")
local Decorations = require("src.core.gen2.Decorations")
local MomShopping = {}
-- constants/misc_constants.asm.
local MOM_MONEY = 2300
MomShopping.MOM_MONEY = MOM_MONEY
-- constants/misc_constants.asm again; the same cap Prize and Save carry.
local MAX_MONEY = 999999
-- The `momitem kind` const_def 1 block at the top of mom_phone.asm.
local MOM_ITEM, MOM_DOLL = 1, 2
-- wNumPCItems: PC_ITEM_CAPACITY stacks of at most 99, which is what
-- ReceiveItem enforces for the PC list the way it does for the bag.
local PC_ITEM_CAPACITY = 50
local MAX_STACK = 99
-- data/items/mom_phone.asm, both tables verbatim and in order. `trigger` is
-- MOMITEM_TRIGGER, `cost` MOMITEM_COST, `kind` MOMITEM_KIND and `item` is
-- MOMITEM_ITEM -- an item id for a MOM_ITEM row and a DECO_* id for a
-- MOM_DOLL one, because Mom_GiveItemOrDoll reaches the doll through
-- DecorationFlagAction_c, which takes the decoration itself rather than a
-- DECOFLAG_*. The DECO numbers are the same ones
-- src/core/gen2/Decorations.lua indexes its ATTRIBUTES table by.
local function momitem(trigger, cost, kind, item)
return { trigger = trigger, cost = cost, kind = kind, item = item }
end
local DECO_BIG_SNORLAX_DOLL = 26
local DECO_PIKACHU_DOLL = 30
local DECO_CLEFAIRY_DOLL = 32
local DECO_CHARMANDER_DOLL = 35
MomShopping.ITEMS_1 = {
momitem(0, 600, MOM_ITEM, "SUPER_POTION"),
momitem(0, 90, MOM_ITEM, "ANTIDOTE"),
momitem(0, 180, MOM_ITEM, "POKE_BALL"),
momitem(0, 450, MOM_ITEM, "ESCAPE_ROPE"),
momitem(0, 500, MOM_ITEM, "GREAT_BALL"),
}
MomShopping.ITEMS_2 = {
momitem(900, 600, MOM_ITEM, "SUPER_POTION"),
momitem(4000, 270, MOM_ITEM, "REPEL"),
momitem(7000, 600, MOM_ITEM, "SUPER_POTION"),
momitem(10000, 1800, MOM_DOLL, DECO_CHARMANDER_DOLL),
momitem(15000, 3000, MOM_ITEM, "MOON_STONE"),
momitem(19000, 600, MOM_ITEM, "SUPER_POTION"),
momitem(30000, 4800, MOM_DOLL, DECO_CLEFAIRY_DOLL),
momitem(40000, 900, MOM_ITEM, "HYPER_POTION"),
momitem(50000, 8000, MOM_DOLL, DECO_PIKACHU_DOLL),
momitem(100000, 22800, MOM_DOLL, DECO_BIG_SNORLAX_DOLL),
}
-- data/text/common_1.asm, transcribed the way Specials' MOM_TEXT transcribes
-- the bank's own bank. Mom never names what she bought, in either script.
-- `cont` folds into the same `\n` as `line`.
local MOM_HI = Strings.source("Hi, {PLAYER}!\nHow are you?")
local FOUND_AN_ITEM = Strings.source(
"I found a useful\nitem shopping, so")
local FOUND_A_DOLL = Strings.source(
"While shopping\ntoday, I saw this\nadorable doll, so")
local BOUGHT_WITH_YOUR_MONEY = Strings.source(
"I bought it with\nyour money. Sorry!")
local ITS_IN_PC = Strings.source("It's in your PC.\nYou'll like it!")
local ITS_IN_YOUR_ROOM = Strings.source("It's in your room.\nYou'll love it!")
--------------------------------------------------------------------------
-- State
--------------------------------------------------------------------------
-- wWhichMomItem and wMomItemTriggerBalance, both seeded by NewGame
-- (engine/menus/intro_menu.asm): the ladder starts at its first rung and the
-- consolation threshold starts at MOM_MONEY. Filled in lazily so a save made
-- before this existed gets the same two defaults rather than an unlocked
-- ladder.
function MomShopping.state(save)
local mom = save and save.mom
if type(mom) ~= "table" then return nil end
if mom.whichItem == nil then mom.whichItem = 0 end
if mom.triggerBalance == nil then mom.triggerBalance = MOM_MONEY end
return mom
end
local function savedMoney(save)
local mom = save and save.mom
return (mom and mom.savedMoney) or 0
end
--------------------------------------------------------------------------
-- CheckBalance_MomItem2
--------------------------------------------------------------------------
-- Answers the row Mom is about to buy, as { row, set }: set 2 is the ladder
-- and set 1 the random consolation buy. nil is the routine's `xor a / ret`,
-- i.e. she buys nothing this time.
--
-- `random(n)` returns 0..n-1, the way RandomRange does; injected so the test
-- is deterministic.
function MomShopping.pick(save, random)
local mom = MomShopping.state(save)
if not mom then return nil end
local saved = savedMoney(save)
-- `cp (MomItems_2.End - MomItems_2) / MOMITEM_SIZE / jr nc, .nope`: a
-- ladder that has run out falls through to the consolation test rather
-- than reading off the end of the table.
local row = MomShopping.ITEMS_2[mom.whichItem + 1]
if row and saved >= row.trigger then
return { row = row, set = 2 }
end
-- .check_have_2300, which is a WHILE and not an IF: the balance is walked
-- up in MOM_MONEY steps until it reaches or passes the savings, and only an
-- EXACT landing buys anything. Overshooting is `.less_than`, which returns
-- with no carry and leaves the balance where the walk left it -- so the
-- next call starts from the rung above and the same 2300 cannot pay twice.
while mom.triggerBalance < saved do
mom.triggerBalance = mom.triggerBalance + MOM_MONEY
end
if mom.triggerBalance ~= saved then return nil end
mom.triggerBalance = mom.triggerBalance + MOM_MONEY
local roll = 0
if random then roll = math.floor(random(#MomShopping.ITEMS_1) or 0) end
return { row = MomShopping.ITEMS_1[roll + 1], set = 1 }
end
--------------------------------------------------------------------------
-- Mom_GiveItemOrDoll
--------------------------------------------------------------------------
-- The PC half of ReceiveItem, over save.pcItems. Returns false for a full
-- PC, which is the no-carry Mom_GiveItemOrDoll passes straight back up: the
-- purchase does not happen and nothing is deducted.
local function receiveItemToPc(save, id, data)
if type(save) ~= "table" then return false end
save.pcItems = save.pcItems or {}
local pc = save.pcItems
local held = pc[id] or 0
if held == 0 then
local cap = (data and data.field and data.field.pcItemCap) or PC_ITEM_CAPACITY
local stacks = 0
for _ in pairs(pc) do stacks = stacks + 1 end
if stacks >= cap then return false end
elseif held + 1 > MAX_STACK then
return false
end
pc[id] = held + 1
return true
end
--------------------------------------------------------------------------
-- MomTriesToBuySomething
--------------------------------------------------------------------------
-- opts:
-- events the src/world/gen2/Events.lua bitfield, for a doll's flag
-- data the cache, for the PC's stack cap
-- random(n) 0..n-1, RandomRange
-- phoneService GetMapPhoneService: false on a map with no reception, and
-- the routine `ret`s before it looks at the balance at all
--
-- Returns the purchase, or nil. A purchase is
-- { kind = "item" | "doll", item, cost, set, saved }, and MomShopping.pages
-- turns it into the four lines the phone call speaks.
function MomShopping.tryBuy(save, opts)
opts = opts or {}
if opts.phoneService == false then return nil end
local mom = MomShopping.state(save)
if not mom then return nil end
-- wWhichMomItemSet is cleared before the balance check and only written by
-- the consolation arm, which is what makes .ASMFunction's `and a / jr nz`
-- advance wWhichMomItem for a LADDER buy alone.
local pick = MomShopping.pick(save, opts.random)
if not (pick and pick.row) then return nil end
local row = pick.row
if row.kind == MOM_DOLL then
-- DecorationFlagAction_c with b = SET_FLAG, and the arm ends `scf`: a
-- doll cannot fail, there is nowhere for it to not fit.
Decorations.give(opts.events, row.item)
elseif not receiveItemToPc(save, row.item, opts.data) then
return nil
end
-- MomBuysItem_DeductFunds: TakeMoney out of wMomsMoney, which floors at
-- zero rather than borrowing.
mom.savedMoney = math.max(0, math.min(savedMoney(save), MAX_MONEY) - row.cost)
if pick.set == 2 then mom.whichItem = mom.whichItem + 1 end
return {
kind = (row.kind == MOM_DOLL) and "doll" or "item",
item = row.item,
cost = row.cost,
set = pick.set,
saved = mom.savedMoney,
}
end
-- Mom_GetScriptPointer's two scripts, .ItemScript and .DollScript: four
-- writetexts each, differing only in the middle line and the last.
--
-- The SOURCE strings, not looked-up ones: the caller feeds them to `rawtext`,
-- which is where the Strings lookup happens (src/script/gen2/Vm.lua). A
-- module-level template resolved here would freeze the English before
-- Strings.load has a catalog, which is exactly what Strings.source exists to
-- avoid.
function MomShopping.pages(purchase)
if not purchase then return {} end
if purchase.kind == "doll" then
return { MOM_HI, FOUND_A_DOLL, BOUGHT_WITH_YOUR_MONEY, ITS_IN_YOUR_ROOM }
end
return { MOM_HI, FOUND_AN_ITEM, BOUGHT_WITH_YOUR_MONEY, ITS_IN_PC }
end
return MomShopping
+162
View File
@@ -0,0 +1,162 @@
-- Where does this species live? engine/overworld/wildmons.asm FindNest, which
-- is the data behind the Pokedex's AREA page and the Pokegear MAP card's
-- "<MON>'S NEST" overlay.
--
-- FindNest takes a species and a region (e: 0 Johto, 1 Kanto) and fills the
-- tilemap with LANDMARK indices -- one per map whose wild data contains the
-- species. It reads exactly three sources, in this order:
--
-- .FindGrass JohtoGrassWildMons / KantoGrassWildMons, all NUM_GRASSMON * 3
-- slots, so morning, day AND night count
-- .FindWater JohtoWaterWildMons / KantoWaterWildMons
-- .RoamMon1/2/3 the three roamers' CURRENT map, Johto only
--
-- and nothing else. Headbutt trees, fishing groups, the Bug Contest and swarms
-- are all absent from it, so a HEADBUTT-only species legitimately has no nest
-- and the page stays blank -- that is the cart's answer, not a gap.
--
-- Region is decided here by LANDMARK INDEX rather than by which of the two
-- tables a map came from: the extractor emits one `grass`/`water` table keyed
-- by map, and Johto's landmarks are the run below LANDMARK_PALLET_TOWN with
-- Kanto's above it (constants/landmark_constants.asm). Same split, different
-- spelling.
local Nests = {}
-- constants/landmark_constants.asm. LANDMARK_SPECIAL is 0 and never a nest.
Nests.LANDMARK_PALLET_TOWN = 0x2e
Nests.LANDMARK_FAST_SHIP = 0x5e
function Nests.regionOf(landmark)
if not landmark or landmark <= 0 then return nil end
if landmark >= Nests.LANDMARK_FAST_SHIP then return nil end
return (landmark < Nests.LANDMARK_PALLET_TOWN) and "johto" or "kanto"
end
-- ---------------------------------------------------------- the landmarks
--
-- data/maps/landmarks.asm, which on Gold is one index space shared by every
-- map header's `landmark` byte, the Pokegear MAP card and the #DEX AREA page
-- this file feeds. It is the `landmarks` registry (src/mods/Schemas.lua), one
-- of the Gen 2-only six: Red's town map is a different table with a different
-- id space, so the name is gated under Gen 1 and routed to
-- gen2Landmarks.landmarks under Gen 2 -- straight onto the cache's own table,
-- which means the merge lands in the very table the map card draws from and no
-- Builtins seeding is needed (the same arrangement gen2Maps has).
--
-- Two reads go through here rather than through landmarks.order, which is a
-- plain ordered list the extractor writes and a registered landmark is
-- therefore absent from: src/core/Game2.lua:currentLandmark and
-- src/ui/gen2/MapRadio.lua's region test. `index` on a record is the byte the
-- map header carries, so the lookup is by that and the order list stays the
-- fallback for a dataset whose records predate it.
-- memoized per landmark table (weak keys, so a second dataset in one process
-- does not pin the first); built on first read, which is after the merge --
-- nothing asks for a landmark before the overworld exists
local byIndex = setmetatable({}, { __mode = "k" })
-- Two records may claim one index -- a mod that registers a landmark at a byte
-- the cart already uses -- and pairs() would decide which one answers per
-- process. The cache's own row wins its own slot (landmarks.order is that
-- list), and between two newcomers the lower id wins, so the answer is the
-- same on every boot. A mod that means to MOVE a vanilla landmark patches
-- that record rather than shadowing its index.
local function indexTable(landmarks)
local hit = byIndex[landmarks]
if hit then return hit end
local map, order = {}, landmarks.order or {}
for id, record in pairs(landmarks.landmarks or {}) do
local index = type(record) == "table" and record.index
if index then
local held = map[index]
if held == nil or order[index + 1] == id
or (order[index + 1] ~= held and id < held) then
map[index] = id
end
end
end
byIndex[landmarks] = map
return map
end
-- The LANDMARK_* id at a map header's landmark byte, or nil.
function Nests.landmarkId(data, index)
local landmarks = data and data.gen2Landmarks
if not (landmarks and index) then return nil end
local hit = indexTable(landmarks)[index]
if hit then return hit end
return landmarks.order and landmarks.order[index + 1] or nil
end
-- The record behind that byte: the two-line name and the map-card position.
function Nests.landmark(data, index)
local landmarks = data and data.gen2Landmarks
local id = Nests.landmarkId(data, index)
return id and landmarks.landmarks and landmarks.landmarks[id] or nil
end
local function landmarkOfMap(data, mapId)
local def = data and data.maps and data.maps[mapId]
return def and def.landmark
end
-- Every slot of one encounter table entry, across all times of day: the cart
-- walks `NUM_GRASSMON * 3` bytes without caring which third it is in.
local function tableHasSpecies(entry, species)
if type(entry) ~= "table" then return false end
local slots = entry.slots
if type(slots) ~= "table" then return false end
for _, list in pairs(slots) do
if type(list) == "table" then
for _, slot in ipairs(list) do
if slot and slot.species == species then return true end
end
end
end
return false
end
-- The landmark indices where `species` can be met, in ascending order.
--
-- `region` is "johto" or "kanto"; nil means both, which no cart screen asks
-- for but is the useful answer for a test.
function Nests.find(data, species, region, save)
local out, seen = {}, {}
local function add(landmark)
if not landmark or landmark <= 0 or seen[landmark] then return end
local where = Nests.regionOf(landmark)
if not where then return end
if region and where ~= region then return end
seen[landmark] = true
out[#out + 1] = landmark
end
local enc = data and data.encounters
for _, key in ipairs({ "grass", "water" }) do
for mapId, entry in pairs((enc and enc[key]) or {}) do
if tableHasSpecies(entry, species) then
add(landmarkOfMap(data, mapId))
end
end
end
-- .RoamMon1/2/3: the roamer's CURRENT map, and only while it is still out
-- there -- a caught or defeated one keeps its slot but loses its species and
-- map. Johto-only on the cart, which the region filter above enforces.
--
-- The test is Roamers.active, NOT "has HP": a roamer starts life at hp 0
-- (`xor a ; generate new stats`) and only gets a real value once you have met
-- it, so an HP test would hide all three until first contact -- exactly the
-- ones the page is most useful for.
local Roamers = require("src.core.gen2.Roamers")
for _, slot in ipairs((save and save.roamers) or {}) do
if Roamers.active(slot) and slot.species == species then
add(landmarkOfMap(data, slot.map))
end
end
table.sort(out)
return out
end
return Nests
+165
View File
@@ -0,0 +1,165 @@
-- The in-game trades (engine/events/npc_trade.asm, data/events/npc_trades.asm).
--
-- Six of them, one per NPC_TRADE_* constant, reached by the `trade` script
-- command. Each row names the mon the NPC wants, the mon it hands over, and
-- everything that mon arrives wearing: its nickname, its DVs, its held item,
-- its original trainer's name and ID, and which gender of the requested mon it
-- will accept.
--
-- love-free: the conversation is src/ui/gen2/TradeMenu.lua, this is the rules.
--
-- Facts worth keeping:
--
-- * NPCTRADE_GIVEMON is what YOU hand over and NPCTRADE_GETMON what you get,
-- which is the opposite way round from the macro's own argument comment
-- ("requested mon, offered mon"). GetTradeAttr reads them by name, so the
-- comment is the only thing that is backwards.
-- * The row's DVs are TWO RAW BYTES, not a number: attack/defense in the
-- high and low nibbles of the first, speed/special of the second. The
-- mon's gender and shininess fall straight out of them, which is why every
-- one of these trades hands over the same mon to every player.
-- * The OT ID is stored little-endian in the table and byte-swapped into the
-- party struct (Trade_CopyTwoBytesReverseEndian), so the number the table
-- holds IS the ID the player sees.
-- * `trade` writes no wScriptVar. Every outcome -- the refusal, the wrong
-- mon, the completed trade -- prints its line and returns, and the script
-- after it carries on either way.
-- * The trade is one-shot, tracked in wTradeFlags by the trade's own id. A
-- second visit prints TRADE_DIALOG_AFTER and nothing else, which is the
-- check that happens BEFORE the intro line.
-- * ComputeNPCTrademonStats runs at the END, on the mon that just landed in
-- the last party slot: the received mon keeps the LEVEL of the one handed
-- over and recomputes its stats from the new species' bases.
local Mail = require("src.core.gen2.Mail")
local Mon = require("src.battle.gen2.Mon")
local NpcTrade = {}
-- constants/npc_trade_constants.asm
NpcTrade.NUM_NPC_TRADES = 6
NpcTrade.TRADE_GENDER_EITHER = "TRADE_GENDER_EITHER"
NpcTrade.TRADE_GENDER_MALE = "TRADE_GENDER_MALE"
NpcTrade.TRADE_GENDER_FEMALE = "TRADE_GENDER_FEMALE"
-- The outcomes, which are also the TRADE_DIALOG_* rows PrintTradeText picks.
NpcTrade.DIALOG_INTRO = "TRADE_DIALOG_INTRO"
NpcTrade.DIALOG_CANCEL = "TRADE_DIALOG_CANCEL"
NpcTrade.DIALOG_WRONG = "TRADE_DIALOG_WRONG"
NpcTrade.DIALOG_COMPLETE = "TRADE_DIALOG_COMPLETE"
NpcTrade.DIALOG_AFTER = "TRADE_DIALOG_AFTER"
-- data/generated/events.lua `trades`, 1-based over the 0-based NPC_TRADE_*.
function NpcTrade.row(eventTables, id)
local rows = type(eventTables) == "table" and eventTables.trades
if type(rows) ~= "table" then return nil end
return rows[(tonumber(id) or 0) + 1]
end
-- wTradeFlags, a bit per trade id. Save-side it is a plain set.
function NpcTrade.done(save, id)
local flags = save and save.tradeFlags
return (flags and flags[tonumber(id) or -1]) == true
end
function NpcTrade.markDone(save, id)
if not save then return end
save.tradeFlags = save.tradeFlags or {}
save.tradeFlags[tonumber(id) or 0] = true
end
-- The row's two DV bytes as the port's named-DV table. `dn attack, defense`
-- then `dn speed, special` -- the same packing wild mons use.
function NpcTrade.dvs(row)
local raw = (row and row.dvs) or {}
local dvs = {
attack = math.floor((raw[1] or 0) / 16),
defense = (raw[1] or 0) % 16,
speed = math.floor((raw[2] or 0) / 16),
special = (raw[2] or 0) % 16,
}
dvs.hp = Mon.hpDV(dvs)
return dvs
end
-- NPCTRADE_ITEM is an item id BYTE (data/events/npc_trades.asm's `db \5, \6,
-- \7` tail), and DoNPCTrade copies that byte straight into wPartyMon1Item of
-- the last party slot, so the received mon wears it like any other held item.
-- Everywhere else in this port a held item is a KEY of data/generated/items.lua
-- -- wild base data, trainer party mons and `givepokemail` are named at
-- extraction, and `givepoke` names its own byte at runtime through World's
-- itemByIndex -- so the byte is named here too and nothing downstream has to
-- know the row is raw. A row item of 0 is NO_ITEM. A cache that already
-- carries the name passes straight through.
function NpcTrade.item(data, row)
local raw = row and row.item
if raw == nil or raw == 0 then return nil end
if type(raw) == "string" then return raw end
local items = data and data.items
if type(items) == "table" then
for id, def in pairs(items) do
if type(def) == "table" and def.index == raw then return id end
end
end
local order = data and data.constants and data.constants.itemOrder
return (order and order[raw]) or nil
end
-- CheckTradeGender. EITHER takes anything; the other two run GetGender on the
-- mon the player picked and refuse on a mismatch. A genderless species
-- ("unknown") satisfies neither, which is the `jr nz` / `jr z` pair falling to
-- .not_matching.
function NpcTrade.genderOk(row, mon)
local want = row and row.gender
if not want or want == NpcTrade.TRADE_GENDER_EITHER then return true end
local gender = mon and mon.gender
if want == NpcTrade.TRADE_GENDER_MALE then return gender == "male" end
return gender == "female"
end
-- The three refusals NPCTrade checks in order, before any swap happens.
-- Answers the TRADE_DIALOG_* the conversation should print, or nil for "go
-- ahead".
function NpcTrade.check(row, mon)
if not row then return NpcTrade.DIALOG_CANCEL end
if not mon then return NpcTrade.DIALOG_CANCEL end
if mon.species ~= row.give then return NpcTrade.DIALOG_WRONG end
if not NpcTrade.genderOk(row, mon) then return NpcTrade.DIALOG_WRONG end
return nil
end
-- DoNPCTrade: the mon at `index` leaves the party and the row's mon takes the
-- last slot, at the SAME level, with the row's DVs, nickname, held item, OT
-- name and OT ID. Answers the two mons, given away first.
--
-- RemoveMonFromPartyOrBox runs before TryAddMonToParty, so the incoming mon
-- lands in the slot vacated by the outgoing one only when that was the last
-- slot -- otherwise the party closes up and the new mon goes on the end. That
-- reordering is visible in the party list, so it is reproduced rather than
-- tidied into an in-place swap.
function NpcTrade.perform(data, save, row, index)
local party = save and save.party
local given = party and party[index]
if not (data and given and row) then return nil end
local received = Mon.new(data, row.get, given.level, {
dvs = NpcTrade.dvs(row),
nickname = row.nickname,
item = NpcTrade.item(data, row),
})
if not received then return nil end
-- `ot` is what Breeding reads and `otName` what the summary screen prints;
-- both are set rather than picking one, because the two halves of the port
-- already disagree and a traded mon has to answer both.
received.ot, received.otName = row.otName, row.otName
received.otId = row.otId
table.remove(party, index)
-- RemoveMonFromPartyOrBox's "Mail time!" tail. NPCTrade itself has no mail
-- check -- unlike the Day-Care and the PC, it will trade a mon holding a
-- letter away -- so the shift here is what stops the mon that closes up into
-- that slot inheriting it (src/core/gen2/Mail.lua).
Mail.removeSlot(save, index)
party[#party + 1] = received
return given, received
end
return NpcTrade
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
-- Script_ReceivePhoneCall (engine/phone/phone.asm), as the row list the VM
-- runs around a caller script:
--
-- Script_ReceivePhoneCall:
-- reanchormap
-- callasm RingTwice_StartCall
-- memcall wCallerContact + PHONE_CONTACT_SCRIPT2_BANK
-- waitbutton
-- callasm HangUp
-- closetext
-- callasm InitCallReceiveDelay
-- end
--
-- The memcall is the caller's own bank $41 script, which the extractor
-- reaches through PhoneContacts / SpecialPhoneCallList and the call
-- descriptor names by its scripts.lua key. Around it:
--
-- * RingTwice_StartCall dispatches through src/script/gen2/CallAsm.lua for
-- both of its halves: SFX_CALL, and the caller-ID box it draws through
-- .CallerTextboxWithName -> Phone_TextboxWithName (:466, :474, :582) --
-- the phone icon, the caller's name, the class under it -- which is
-- src/ui/gen2/CallerBox.lua, pushed under this call's text pages and taken
-- down again by the InitCallReceiveDelay row at the tail. What the port
-- cannot keep is the FLASH: the cart blinks the box against
-- Phone_Wait20Frames six times, and this port's textbox holds for A rather
-- than returning the way PrintText does, so the box goes up with the first
-- ring and stays. The rawtext page below is the beat that hold needs; it
-- names the caller too, so a player who is looking at the bottom of the
-- screen reads the same thing the box says.
-- The RINGS themselves are real, though: RingTwice_StartCall is `call
-- .Ring` falling through into .Ring (engine/phone/phone.asm:458-469), so
-- it rings TWICE, and each pass opens on Phone_StartRinging's `call
-- WaitSFX` before its PlaySFX (:564-567). The handler is one ring, so
-- both halves are rows here: waitsfx, ring, the three Phone_Wait20Frames
-- that separate the passes (:576-580), waitsfx, ring. The wait is not
-- decoration -- SFX_CALL is $6a (constants/sfx_constants.asm:109), low
-- enough that the PlaySFX priority gate DROPS it outright while a louder
-- sound (SFX_READ_TEXT_2 $08, the A-press beep of the textbox that
-- queued the call) is still on the channels, so without it the player
-- can hear no ring at all.
-- * HangUp is the VM's own `hangup` op: SFX_HANG_UP under the Click! page,
-- transcribed once there rather than twice.
-- * InitCallReceiveDelay dispatches through CallAsm too, so a hung-up call
-- restarts the same receive countdown a map load does.
--
-- Mom's shopping call (engine/events/mom_phone.asm MomTriesToBuySomething)
-- ends `farsjump Script_ReceivePhoneCall` with her pages queued in
-- wCallerContact, which is why `scriptKey` may be an inline row list: the
-- VM's runList takes either.
--
-- Kept apart from src/core/gen2/Phone.lua on purpose: the model stays
-- dependency-free, and this file is the one that needs Strings.
local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local PhoneRing = {}
local RING_PAGE = Strings.source("RING!…RING!…\n%s")
-- GetCallerClassAndName: a trainer contact is "<name>:" with the class name
-- beside it, a non-trainer its NonTrainerCallerNames row and the colon alone.
function PhoneRing.callerId(name, className)
local line = (name or "") .. ":"
if className and className ~= "" then
line = line .. " " .. className
end
return line
end
-- `call` is a Phone.loadCallerScript / Phone.checkSpecialCall descriptor.
-- `delay` on it is the `pause 30` Script_SpecialElmCall's siblings run before
-- the ring; a random call carries none.
function PhoneRing.script(call, name, className)
-- phone.call_received, a Gen 2 invention: Gen 1 has no Pokegear and so no
-- name to share. It lives here rather than beside the two model-side
-- deciders (Phone.tryRandomCall and Phone.checkSpecialCall) because this is
-- the point every incoming call actually reaches the player: all three World
-- sites that ring the phone -- the random call, the queued special call and
-- Mom's shopping call -- build their rows through this one function, and a
-- descriptor the world decided not to run never gets here.
--
-- call the descriptor, exactly as Phone.loadCallerScript built it
-- contact the PHONE_* contact id, 0 for the wrong-number script
-- name the caller's name as the caller-ID box prints it
-- className the trainer class under it, nil for a non-trainer caller
-- special the SPECIALCALL_* id for a scripted call, nil for a random
-- scriptKey the "bank:addr" key of the caller's own bank $41 script
--
-- Observation only: the rows are built after it, so a listener cannot veto
-- the call. The veto seam is the VM's own script.started, which fires when
-- these rows run.
if Runtime.wants("phone.call_received") then
Runtime.emit("phone.call_received", {
call = call,
contact = call and call.contact or 0,
name = name, className = className,
special = call and call.special,
scriptKey = call and call.scriptKey,
})
end
local rows = {}
if call and call.delay then
rows[#rows + 1] = { op = "pause", frames = call.delay }
end
rows[#rows + 1] = { op = "reanchormap" }
-- RingTwice_StartCall's two .Ring passes, each opening on
-- Phone_StartRinging's WaitSFX (engine/phone/phone.asm:458-469, :564-567)
-- and spaced by its three Phone_Wait20Frames (:576-580).
-- The box goes up inside the FIRST of these (idempotent, so the second pass
-- does not stack a duplicate) and comes down inside the InitCallReceiveDelay
-- row at the bottom -- no row of its own, because Script_ReceivePhoneCall has
-- none: the cart's box is tilemap that nothing erases.
rows[#rows + 1] = { op = "waitsfx" }
rows[#rows + 1] = { op = "callasm", label = "RingTwice_StartCall" }
rows[#rows + 1] = { op = "pause", frames = 60 }
rows[#rows + 1] = { op = "waitsfx" }
rows[#rows + 1] = { op = "callasm", label = "RingTwice_StartCall" }
rows[#rows + 1] = { op = "rawtext",
text = Strings(RING_PAGE, PhoneRing.callerId(name, className)) }
rows[#rows + 1] = { op = "farscall", script = call and call.scriptKey }
rows[#rows + 1] = { op = "waitbutton" }
rows[#rows + 1] = { op = "hangup" }
rows[#rows + 1] = { op = "closetext" }
rows[#rows + 1] = { op = "callasm", label = "InitCallReceiveDelay" }
rows[#rows + 1] = { op = "end" }
return rows
end
return PhoneRing
+290
View File
@@ -0,0 +1,290 @@
-- Pokerus: engine/events/pokerus/pokerus.asm, check_pokerus.asm and
-- apply_pokerus_tick.asm.
--
-- One byte per party slot (box_struct's PokerusStatus, macros/ram.asm), and
-- both nybbles matter:
--
-- high nybble the strain, 0..8. Never cleared once set.
-- low nybble days remaining, 1..4. Counted down by the daily tick.
--
-- So a byte reads three ways, and every routine here picks a different one:
--
-- $00 never infected. Can catch it.
-- $34 infected: strain 3, three days left. Spreads, doubles stat exp.
-- $30 cured: the strain stays behind as the immune marker. Does NOT
-- spread and cannot be reinfected, but STILL doubles stat exp --
-- GiveExperiencePoints tests the whole byte (`ld a, [hl] / and a`)
-- rather than the day count, which is why a cured mon is the one
-- people train on.
--
-- Two quirks below are the cart's and are ported deliberately:
--
-- * .randomPokerusLoop can roll strain 0 (a byte whose high nybble came up
-- zero takes the `jr z, .load_pkrs` arm with a = 0), producing $01. That
-- mon cures to $00 and is therefore infectable again.
-- * the spread walk stops on a neighbour whose byte has its low two bits
-- clear (`and $3 / ret z`). That is meant to be "stop at a cured mon", but
-- it also stops at a four-day infection, because 4 and 8 and 12 are all
-- $3-clear.
--
-- The de novo roll is gated on ENGINE_REACHED_GOLDENROD, so a save that has not
-- walked into Goldenrod City cannot catch it at all -- the flag is set by
-- GoldenrodCity's map callback (maps/GoldenrodCity.asm), which the port runs
-- like any other.
local BugContest = require("src.core.gen2.BugContest")
local Runtime = require("src.mods.Runtime")
local Pokerus = {}
-- pokerus.infected, a Gen 2 invention: Gen 1 has no Pokerus byte, so there is
-- no name to share. Raised from the two -- and only two -- writes that turn a
-- clean byte into an infected one, so a mod that wants to notice the virus
-- does not have to poll the party:
--
-- party the party the write landed in
-- slot the 1-based party index that caught it
-- mon that party record, already carrying the new byte
-- strain the high nybble, 1..8 (0 is the "cured, immune" strain)
-- days the low nybble's countdown, 1..4 days
-- source "spread" for .TrySpreadPokerus walking off an infected
-- neighbour, "contracted" for the 3-in-65536 de novo roll
local function emitInfected(party, slot, source)
if not Runtime.wants("pokerus.infected") then return end
local mon = party and party[slot]
Runtime.emit("pokerus.infected", {
party = party, slot = slot, mon = mon,
strain = Pokerus.strain(mon), days = Pokerus.days(mon),
source = source,
})
end
-- constants/engine_flags.asm index 21, ENGINE_REACHED_GOLDENROD, backed by
-- wStatusFlags2 bit STATUSFLAGS2_REACHED_GOLDENROD_F.
Pokerus.ENGINE_REACHED_GOLDENROD = 21
-- `percent` is `* $ff / 100` (macros/data.asm), so these are 85 and 128 out of
-- 256 rather than 33 and 50 out of 100.
Pokerus.SPREAD_CHANCE = math.floor(33 * 0xff / 100) + 1
Pokerus.BACKWARD_CHANCE = math.floor(50 * 0xff / 100) + 1
-- `call Random` gives one byte; the convention is the same as
-- BugContest.random's, a function of no arguments returning 0..255, so a test
-- can pin every roll.
function Pokerus.random()
if love and love.math and love.math.random then
return love.math.random(0, 255)
end
return math.random(0, 255)
end
local function byte(random)
return (random or Pokerus.random)()
end
-- ------------------------------------------------------------- reading a mon
function Pokerus.byteOf(mon)
local value = tonumber(mon and mon.pokerus) or 0
if value < 0 then return 0 end
return math.floor(value) % 256
end
function Pokerus.strain(mon)
return math.floor(Pokerus.byteOf(mon) / 16)
end
function Pokerus.days(mon)
return Pokerus.byteOf(mon) % 16
end
-- An active infection: the low nybble is what _CheckPokerus and the party scan
-- in GivePokerusAndConvertBerries both test.
function Pokerus.isInfected(mon)
return Pokerus.days(mon) ~= 0
end
-- Cured, and carrying the strain as the immune marker. This is the dot the
-- stats screen prints beside the level (src/ui/gen2/SummaryMenu.lua).
function Pokerus.isImmune(mon)
local value = Pokerus.byteOf(mon)
return value ~= 0 and value % 16 == 0
end
-- GiveExperiencePoints' `ld a, MON_POKERUS / call GetPartyParamLocation /
-- ld a, [hl] / and a`: the WHOLE byte, so immune counts.
function Pokerus.doublesStatExp(mon)
return Pokerus.byteOf(mon) ~= 0
end
-- _CheckPokerus: carry when any party member has an active infection. The
-- CheckPokerus special and the Pokemon Center nurse both go through this.
function Pokerus.inParty(party)
for _, mon in ipairs(party or {}) do
if Pokerus.isInfected(mon) then return true end
end
return false
end
-- ------------------------------------------------------------- the daily tick
-- ApplyPokerusTick: subtract `days` from every active counter, clamped at zero,
-- leaving the strain nybble alone. That clamp is the whole immunity mechanic:
-- a counter that reaches zero keeps its strain, and every routine that could
-- reinfect the mon tests the strain first. Returns the slots that cured on
-- this tick.
function Pokerus.applyTick(party, days)
local cured = {}
days = math.max(0, math.floor(tonumber(days) or 0))
for index, mon in ipairs(party or {}) do
local value = Pokerus.byteOf(mon)
local left = value % 16
if left ~= 0 then
left = left - days
if left < 0 then left = 0 end
mon.pokerus = (value - value % 16) + left
if left == 0 then cured[#cured + 1] = index end
end
end
return cured
end
-- CheckPokerusTick (engine/overworld/time.asm), the `.do_daily` arm of the
-- player-event chain. CalcDaysSince ADVANCES the stored day to today as it
-- reads it (`ld [hl], c ; current days`), so the tick subtracts "days since the
-- last poll" and not "days since the timer started" -- and a clock wound
-- backwards wraps into a large jump forward rather than going negative, which
-- cures a party outright.
--
-- wTimerEventStartDay is written once at new game (_InitializeStartDay) and
-- only ever by this routine after that; a save from before this landed has no
-- day stamped, so the first poll stamps today and ticks nothing.
function Pokerus.checkTick(save, now)
if type(save) ~= "table" then return false end
if save.pokerusStartDay == nil then
save.pokerusStartDay = (now or BugContest.now()).day
return false
end
local stamp = { day = save.pokerusStartDay }
local since = BugContest.elapsedSince(stamp, now, "day")
save.pokerusStartDay = stamp.day
if since.days == 0 then return false end
Pokerus.applyTick(save.party or {}, since.days)
return true
end
-- ----------------------------------------------------------- catching it
-- .infectMon: the new slot takes the strain of the byte the walk last looked at
-- (register c, which is the carrier on the first step and the neighbour walked
-- over after that) and a fresh counter from that strain's low two bits:
-- `swap a / and $3 / inc a`, so 1..4 days.
local function infect(party, slot, carrier)
local mon = party[slot]
if not mon then return nil end
local strainBits = carrier - carrier % 16
local days = (math.floor(carrier / 16) % 4) + 1
mon.pokerus = strainBits + days
emitInfected(party, slot, "spread")
return slot
end
-- .TrySpreadPokerus, entered with `index` at the first infected slot. Register
-- b is the number of slots from that one to the end of the party inclusive,
-- which is how the cart knows whether there is anything left to walk to; both
-- loops keep it in that shape rather than counting slots directly.
local function spread(party, index, random)
local count = #party
if byte(random) >= Pokerus.SPREAD_CHANCE then return nil end
if count == 1 then return nil end
local b = count - index + 1
local carrier = Pokerus.byteOf(party[index])
local slot = index
-- `ld a, b / cp 2 / jr c` : the last slot has nothing after it, so it always
-- walks backwards. Otherwise it is a coin flip.
local forward = b >= 2 and byte(random) >= Pokerus.BACKWARD_CHANCE
if forward then
while true do
slot = slot + 1
local value = Pokerus.byteOf(party[slot])
if value == 0 then return infect(party, slot, carrier) end
carrier = value
if value % 4 == 0 then return nil end
b = b - 1
if b == 1 then return nil end
end
end
while true do
-- `ld a, [wPartyCount] / cp b / ret z`: b back up at the party count means
-- the walk is at slot one and there is nothing before it.
if b == count then return nil end
slot = slot - 1
local value = Pokerus.byteOf(party[slot])
if value == 0 then return infect(party, slot, carrier) end
carrier = value
if value % 4 == 0 then return nil end
b = b + 1
end
end
-- GivePokerusAndConvertBerries, the Pokerus half (the Shuckle berry half is a
-- separate routine and is not this module's). Runs on a battle WIN, once.
--
-- The party scan comes first and it is not a formality: while ANY slot is
-- infected the whole routine becomes a spread roll, so a party with an active
-- infection can never contract a second one. Returns the slot that changed, or
-- nil when nothing did.
--
-- opts.random pins the rolls, opts.reachedGoldenrod is
-- ENGINE_REACHED_GOLDENROD.
function Pokerus.give(party, opts)
opts = opts or {}
local random = opts.random
local count = #(party or {})
if count == 0 then return nil end
for index, mon in ipairs(party) do
if Pokerus.isInfected(mon) then
return spread(party, index, random)
end
end
if not opts.reachedGoldenrod then return nil end
-- 3 in 65536: hRandomAdd must be zero and hRandomSub under 3. One `call
-- Random` fills both, which is two bytes off this module's roller.
if byte(random) ~= 0 then return nil end
if byte(random) >= 3 then return nil end
-- `and $7 / cp b / jr nc`: reroll until the slot is inside the party.
local slot
repeat
slot = byte(random) % 8
until slot < count
local mon = party[slot + 1]
local value = Pokerus.byteOf(mon)
-- `and $f0 / ret nz`: a strain in the high nybble means infected or immune,
-- and either way this mon is done catching it.
if value - value % 16 ~= 0 then return nil end
-- .randomPokerusLoop samples strain and duration from ONE non-zero byte.
local roll
repeat
roll = byte(random)
until roll ~= 0
local strain = 0
if roll >= 16 then strain = (roll % 8) + 1 end
mon.pokerus = strain * 16 + (strain % 4) + 1
emitInfected(party, slot + 1, "contracted")
return slot + 1
end
-- The call-site shape: ExitBattle runs this off the save, so the Goldenrod
-- flag comes out of the engine-flag table the scripts write
-- (src/world/gen2/World.lua setEngineFlag) rather than being passed in.
function Pokerus.giveAfterBattle(save, party, opts)
if type(save) ~= "table" then return nil end
opts = opts or {}
local flags = save.engineFlags or {}
return Pokerus.give(party or save.party or {}, {
random = opts.random,
reachedGoldenrod = flags[Pokerus.ENGINE_REACHED_GOLDENROD] == true,
})
end
return Pokerus
+573
View File
@@ -0,0 +1,573 @@
-- The two pieces of Gen 2 world state that override where a wild mon comes
-- from: the three roaming legendaries, and swarms.
--
-- Both live in one module because they are the same KIND of thing -- a
-- persistent record that sits in front of a map's own encounter table -- and
-- because ChooseWildEncounter (engine/overworld/wildmons.asm) consults them in
-- one breath: _GrassWildmonLookup checks the swarm table before the Johto one,
-- and ChooseWildEncounter checks the roamers before it rolls a slot at all.
--
-- Roamers engine/overworld/wildmons.asm InitRoamMons, CheckEncounterRoamMon,
-- UpdateRoamMons, JumpRoamMons, _BackUpMapIndices
-- engine/battle/core.asm BattleEnd_HandleRoamMons
-- data/wild/roammon_maps.asm RoamMaps
-- Swarms engine/events/specials.asm StoreSwarmMapIndices, SetSwarmFlag,
-- CheckSwarmFlag, ActivateFishingSwarm
-- engine/overworld/wildmons.asm _SwarmWildmonCheck
-- data/wild/swarm_grass.asm, swarm_water.asm
--
-- Everything here is save state. A roamer that forgets where it is or how
-- hurt it is between two encounters is not a roamer, it is a random spawn.
--
-- MAP IDENTITY: the cart carries a roamer's position as a (group, number)
-- pair; the port carries map ids ("ROUTE_42"), which are the same fact with
-- the indirection removed, so every comparison below that reads as `cp d /
-- cp e` in the asm is one string compare here. GROUP_N_A / MAP_N_A -- the
-- pair InitRoamMons never writes and BattleEnd_HandleRoamMons writes when a
-- beast is caught or beaten -- is nil.
local Mon = require("src.battle.gen2.Mon")
local Runtime = require("src.mods.Runtime")
local Roamers = {}
-- roamer.moved, a Gen 2 invention: Gen 1 has no roaming legendary, so there is
-- no name to share. One event per beast that actually changed route, raised
-- from both routines that move them -- UpdateRoamMons (the one-route walk a
-- map connection or a door triggers) and JumpRoamMons (the scatter a fly or a
-- teleport triggers) -- so a tracker mod sees every hop.
--
-- index the roamer slot, 1 Raikou / 2 Entei / 3 Suicune
-- slot that roamer record, already carrying the new map
-- species the beast's species id
-- from the map id it left
-- to the map id it is on now
-- reason "connection" for UpdateRoamMons, "jump" for JumpRoamMons
--
-- A beast whose roll left it on the map it was already on raises nothing:
-- .Update's `jr z` re-rolls rather than standing still, so "moved" means moved.
local function emitMoved(index, slot, from, reason)
if from == slot.map then return end
if not Runtime.wants("roamer.moved") then return end
Runtime.emit("roamer.moved", {
index = index, slot = slot, species = slot.species,
from = from, to = slot.map, reason = reason,
})
end
--------------------------------------------------------------------------
-- The beasts
--------------------------------------------------------------------------
-- InitRoamMons, written out. The species order IS the slot order -- Raikou 1,
-- Entei 2, Suicune 3 -- because CheckEncounterRoamMon indexes the structs by a
-- random 0..2 and GetRoamMonHP walks them by species, so renumbering them
-- would send Suicune's damage to Raikou's byte.
Roamers.SPECIES = {
{ species = "RAIKOU", level = 40, map = "ROUTE_42" },
{ species = "ENTEI", level = 40, map = "ROUTE_37" },
{ species = "SUICUNE", level = 40, map = "ROUTE_38" },
}
Roamers.COUNT = 3
Roamers.LEVEL = 40
-- data/wild/roammon_maps.asm, entry for entry and in order. The order matters
-- twice over: `.Update` picks a connection by a two-bit index into the list, so
-- shuffling one row changes which route a beast walks to, and JumpRoamMon picks
-- an ENTRY by a four-bit index, so shuffling the rows changes where a
-- teleport drops it.
--
-- Route 40 and Route 41 are deliberately absent (they are water routes, and
-- CheckEncounterRoamMon refuses to fire while the player is surfing anyway).
Roamers.NUM_MAPS = 16
Roamers.MAPS = {
{ map = "ROUTE_29", to = { "ROUTE_30", "ROUTE_46" } },
{ map = "ROUTE_30", to = { "ROUTE_29", "ROUTE_31" } },
{ map = "ROUTE_31", to = { "ROUTE_30", "ROUTE_32", "ROUTE_36" } },
{ map = "ROUTE_32", to = { "ROUTE_36", "ROUTE_31", "ROUTE_33" } },
{ map = "ROUTE_33", to = { "ROUTE_32", "ROUTE_34" } },
{ map = "ROUTE_34", to = { "ROUTE_33", "ROUTE_35" } },
{ map = "ROUTE_35", to = { "ROUTE_34", "ROUTE_36" } },
{ map = "ROUTE_36", to = { "ROUTE_35", "ROUTE_31", "ROUTE_32", "ROUTE_37" } },
{ map = "ROUTE_37", to = { "ROUTE_36", "ROUTE_38", "ROUTE_42" } },
{ map = "ROUTE_38", to = { "ROUTE_37", "ROUTE_39", "ROUTE_42" } },
{ map = "ROUTE_39", to = { "ROUTE_38" } },
{ map = "ROUTE_42", to = { "ROUTE_43", "ROUTE_44", "ROUTE_37", "ROUTE_38" } },
{ map = "ROUTE_43", to = { "ROUTE_42", "ROUTE_44" } },
{ map = "ROUTE_44", to = { "ROUTE_42", "ROUTE_43", "ROUTE_45" } },
{ map = "ROUTE_45", to = { "ROUTE_44", "ROUTE_46" } },
{ map = "ROUTE_46", to = { "ROUTE_45", "ROUTE_29" } },
}
-- RomExtractorGen2 emits `encounters.roamMaps` in exactly the shape above, so
-- the table above is the fallback for a cache built before it did (and for a
-- caller with no cache at all, which every unit test here is).
function Roamers.mapTable(encounters)
local extracted = encounters and encounters.roamMaps
if type(extracted) == "table" and #extracted > 0 then return extracted end
return Roamers.MAPS
end
-- `.Update`'s search: walk RoamMaps for the entry whose START map is `mapId`.
-- The asm ends on `cp -1 / ret z`, which leaves b and c untouched -- a roamer
-- standing somewhere RoamMaps does not list simply does not move.
function Roamers.entryFor(mapId, encounters)
if not mapId then return nil end
for _, row in ipairs(Roamers.mapTable(encounters)) do
if row.map == mapId then return row end
end
return nil
end
-- 0 .. n-1, the same convention src/battle/gen2/Encounter.lua and the battle
-- engine use for an injected random, so one seeded generator drives them all.
local function rand(random, n)
if random then return random(n) end
if love and love.math and love.math.random then
return love.math.random(n) - 1
end
return math.random(n) - 1
end
-- JumpRoamMon: a completely random RoamMaps entry, re-rolled while it lands on
-- the map the PLAYER is standing on. (The asm's `maskbits NUM_ROAMMON_MAPS /
-- cp NUM_ROAMMON_MAPS / jr nc` retry is dead code -- there are exactly 16
-- entries and the mask is four bits -- so it is not modelled.)
--
-- The retry is unbounded on the cart; `tries` caps it here so a caller that
-- hands in a degenerate random (a stub that always returns 0) cannot hang the
-- overworld. Falling out of the loop leaves the beast where it was, which is
-- the same outcome `.Update`'s not-found path has.
function Roamers.jumpOne(playerMapId, random, encounters)
local table_ = Roamers.mapTable(encounters)
local count = #table_
if count == 0 then return nil end
for _ = 1, 32 do
local row = table_[rand(random, count) + 1]
if row and row.map ~= playerMapId then return row.map end
end
return nil
end
-- `.Update`: one roamer's move on a map change.
--
-- The single random byte does double duty, which is the part a paraphrase
-- always loses:
--
-- and %00011111 -> zero (1 in 32) means "jump to a completely random map"
-- and %11 -> otherwise the LOW TWO BITS of that same masked value are
-- the connection index
--
-- so the choice of connection is not independent of the choice to jump. An
-- index at or past the entry's connection count re-rolls, and so does a
-- connection that equals wRoamMons_LastMapGroup/Number -- the map the player
-- was on BEFORE the one they are on now, which is what keeps a beast from
-- following the player back and forth down one pair of routes.
function Roamers.moveOne(mapId, lastMapId, playerMapId, random, encounters)
local entry = Roamers.entryFor(mapId, encounters)
if not entry then return mapId end
local list = entry.to or {}
for _ = 1, 64 do
local value = rand(random, 256) % 32
if value == 0 then
return Roamers.jumpOne(playerMapId, random, encounters) or mapId
end
local index = value % 4
if index < #list then
local candidate = list[index + 1]
if candidate ~= lastMapId then return candidate end
end
end
return mapId
end
--------------------------------------------------------------------------
-- The save record
--------------------------------------------------------------------------
--
-- save.roamers is a three-slot array in the order above, written first by the
-- InitRoamMons special (src/script/gen2/Specials.lua) when the Burned Tower
-- basement script fires. Each slot:
--
-- species nil once the beast has been caught or beaten (GetRoamMonSpecies
-- writes 0 there, which is what stops it ever appearing again)
-- level 40, and it never changes: the roam struct has no experience
-- map the map id it occupies, nil for GROUP_N_A / MAP_N_A
-- hp its remaining HP, 0 meaning "not yet rolled". ONE byte on the
-- cart, because Raikou and Entei have under 256 HP at level 40 --
-- the port keeps the same ceiling rather than quietly widening it
-- dvs rolled at the FIRST encounter and kept, so a beast you chase all
-- game is the same individual
--
-- There is deliberately no status field: the seven-byte roam_struct
-- (macros/ram.asm) has no room for one, and LoadEnemyMon zeroes
-- wEnemyMonStatus for every wild mon, so a beast you paralyse walks it off the
-- moment it flees. Storing status would be a buff the cart does not grant.
Roamers.MAX_STORED_HP = 255
function Roamers.list(save)
return (type(save) == "table" and save.roamers) or nil
end
function Roamers.slot(save, index)
local list = Roamers.list(save)
return list and list[index] or nil
end
-- InitRoamMons. Safe to call twice: the Burned Tower script is behind a scene
-- flag, but a re-init would hand the player three fresh beasts, so this only
-- writes when there is nothing there.
function Roamers.init(save, opts)
if type(save) ~= "table" then return nil end
if save.roamers and not (opts and opts.force) then return save.roamers end
local list = {}
for _, row in ipairs(Roamers.SPECIES) do
list[#list + 1] = {
species = row.species,
level = row.level,
map = row.map,
-- `xor a ; generate new stats` -- the asm comments its own zero.
hp = 0,
}
end
save.roamers = list
return list
end
-- Is this beast still out there? A caught or defeated one keeps its slot but
-- has neither a species nor a map, and every path below refuses it.
function Roamers.active(slot)
return type(slot) == "table" and slot.species ~= nil and slot.map ~= nil
end
-- _BackUpMapIndices: Cur shifts into Last, then the player's current map
-- becomes Cur. It runs at the END of both UpdateRoamMons and JumpRoamMons, so
-- the "last map" a move avoids is the one the player left BEFORE the one they
-- are standing on.
function Roamers.backUpMapIndices(save, playerMapId)
if type(save) ~= "table" then return end
local marks = save.roamerMaps or {}
marks.last = marks.current
marks.current = playerMapId
save.roamerMaps = marks
end
function Roamers.lastMap(save)
return (type(save) == "table" and save.roamerMaps and save.roamerMaps.last)
or nil
end
-- UpdateRoamMons: each live beast moves along a connection, then the map
-- indices are backed up. Runs on a map CONNECTION and on a door / fall warp
-- (data/maps/setup_scripts.asm MapSetupScript_Connection and
-- MapSetupScript_Door), not on a plain warp.
function Roamers.update(save, playerMapId, random, encounters)
local list = Roamers.list(save)
if not list then return false end
local lastMapId = Roamers.lastMap(save)
for index, slot in ipairs(list) do
if Roamers.active(slot) then
local from = slot.map
slot.map = Roamers.moveOne(from, lastMapId, playerMapId, random,
encounters)
emitMoved(index, slot, from, "connection")
end
end
Roamers.backUpMapIndices(save, playerMapId)
return true
end
-- JumpRoamMons: every live beast teleports to a random roam map. This is the
-- Teleport setup script (MapSetupScript_Teleport), which is why flying or
-- teleporting across Johto scatters them instead of nudging them one route.
function Roamers.jumpAll(save, playerMapId, random, encounters)
local list = Roamers.list(save)
if not list then return false end
for index, slot in ipairs(list) do
if Roamers.active(slot) then
local from = slot.map
slot.map = Roamers.jumpOne(playerMapId, random, encounters) or slot.map
emitMoved(index, slot, from, "jump")
end
end
Roamers.backUpMapIndices(save, playerMapId)
return true
end
--------------------------------------------------------------------------
-- Meeting one
--------------------------------------------------------------------------
-- CheckEncounterRoamMon, which ChooseWildEncounter calls BEFORE it rolls a
-- slot -- so a roamer replaces the map's own encounter rather than adding to
-- it, and only on a map that has an encounter table at all.
--
-- One random byte, three gates:
-- cp 100 / jr nc -> 100 of 256 get past
-- and %11 / jr z -> three quarters of those get past
-- dec a -> 1, 2 or 3 becomes slot 0, 1 or 2
-- which is 75/256, about 29%, split evenly between the three beasts. A slot
-- whose map is not the player's map fails outright: there is NO re-roll onto
-- another beast, so two beasts sharing your route still only get one roll
-- each encounter.
--
-- Surfing refuses before anything else (`call CheckOnWater / jr z`), which is
-- what keeps Suicune out of the water on Route 42.
function Roamers.checkEncounter(save, mapId, onWater, random)
if onWater then return nil end
local list = Roamers.list(save)
if not list then return nil end
local value = rand(random, 256)
if value >= 100 then return nil end
local index = value % 4
if index == 0 then return nil end
local slot = list[index]
if not Roamers.active(slot) then return nil end
if slot.map ~= mapId then return nil end
local hit = { index = index, slot = slot, species = slot.species,
level = slot.level }
-- roamer.encountered, a Gen 2 invention, raised on the roll that REPLACES
-- the map's own encounter -- CheckEncounterRoamMon runs ahead of
-- ChooseWildEncounter, so by the time the shared encounter.species hook sees
-- anything the beast has already won the slot. This is the only notice a
-- mod gets that the wild mon about to appear is the roamer.
--
-- index / slot / species / level the same four fields the caller takes
-- mapId the map the player is standing on, which is also the beast's
--
-- Observation only: the shared encounter.species hook still runs downstream
-- and is where a mod changes what appears.
if Runtime.wants("roamer.encountered") then
Runtime.emit("roamer.encountered", {
index = index, slot = slot, species = slot.species,
level = slot.level, mapId = mapId,
})
end
return hit
end
-- Build the enemy for a roaming battle, out of the ONE party-member builder
-- (src/battle/gen2/Mon.lua) so the beast arrives with a real Gen 2 moveset.
--
-- LoadEnemyMon's two roam branches, in the order it runs them:
-- * DVs: `and a` on the stored HP decides. Zero means the struct has never
-- been used, so fresh DVs are rolled and kept; anything else reuses them.
-- * HP: zero takes .InitRoamHP, which writes the mon's FULL HP back into the
-- struct. So the first encounter both rolls and banks it.
function Roamers.beginBattle(save, index, data)
local slot = Roamers.slot(save, index)
if not Roamers.active(slot) then return nil end
local fresh = (slot.hp or 0) == 0
local mon = Mon.new(data, slot.species, slot.level or Roamers.LEVEL, {
dvs = (not fresh) and slot.dvs or nil,
})
if not mon then return nil end
slot.dvs = mon.dvs
if fresh then
-- .InitRoamHP: the struct takes the mon's max HP now, not at the end of
-- the battle.
slot.hp = math.min(Roamers.MAX_STORED_HP, mon.maxHp or 0)
else
mon.hp = math.min(slot.hp, mon.maxHp or slot.hp)
end
return mon, slot
end
-- BattleEnd_HandleRoamMons.
--
-- `outcome` is the port's battle outcome; the cart reads the low nibble of
-- wBattleResult, where WIN is 0 and both a wild flee and a player run write
-- DRAW (WildFled_EnemyFled_LinkBattleCanceled, and TryToRunAwayFromBattle's
-- .can_escape). So "caught" and "win" clear the beast for good, and
-- everything else banks its HP and moves it.
--
-- The `.not_roaming` tail is the other half of this routine and belongs to
-- ordinary wild battles: a 1-in-16 roll moves the beasts anyway, which is why
-- they drift while you grind and not only while you walk.
function Roamers.endBattle(save, index, outcome, hp, playerMapId, random,
encounters)
local slot = Roamers.slot(save, index)
if not Roamers.active(slot) then return false end
if outcome == "win" or outcome == "caught" then
slot.species = nil
slot.map = nil
slot.hp = 0
return true
end
slot.hp = math.max(0, math.min(Roamers.MAX_STORED_HP, hp or 0))
Roamers.update(save, playerMapId, random, encounters)
return true
end
-- BattleEnd_HandleRoamMons `.not_roaming`: after ANY other wild battle,
-- `call BattleRandom / and $f / ret nz` gives one chance in sixteen that the
-- beasts move.
function Roamers.afterWildBattle(save, playerMapId, random, encounters)
if not Roamers.list(save) then return false end
if rand(random, 256) % 16 ~= 0 then return false end
return Roamers.update(save, playerMapId, random, encounters)
end
-- data/wild/flee_mons.asm. TryEnemyFlee walks AlwaysFleeMons first and takes
-- the carry straight to `.Flee`, which is why a beast never gets a second turn
-- -- the roaming battle is one attack long unless it is trapped. The other
-- two lists are the same routine's 50% and 10% gates and live here so the
-- battle engine has one place to read them from.
Roamers.ALWAYS_FLEE = { RAIKOU = true, ENTEI = true, SUICUNE = true }
Roamers.OFTEN_FLEE = {
CUBONE = true, ARTICUNO = true, ZAPDOS = true, MOLTRES = true,
QUAGSIRE = true, DELIBIRD = true, PHANPY = true, TEDDIURSA = true,
}
Roamers.SOMETIMES_FLEE = {
MAGNEMITE = true, GRIMER = true, TANGELA = true, MR__MIME = true,
EEVEE = true, PORYGON = true, DRATINI = true, DRAGONAIR = true,
TOGETIC = true, UMBREON = true, UNOWN = true, SNUBBULL = true,
HERACROSS = true,
}
--------------------------------------------------------------------------
-- Swarms
--------------------------------------------------------------------------
local Swarm = {}
Roamers.Swarm = Swarm
-- The state, on the save:
-- save.swarmMap wSwarmMapGroup / wSwarmMapNumber, as a map id
-- save.dailyFlags.swarm DAILYFLAGS1_SWARM_F
-- save.dailyFlags.fishingSwarm wFishingSwarmFlag (FISHSWARM_* 0/1/2)
-- save.dailyResetDay the day wDailyResetTimer was last restarted
--
-- src/world/gen2/World.lua:setSwarm and the ActivateFishingSwarm special
-- already write the first three; this module is where they are READ and where
-- they expire.
-- constants/script_constants.asm, ActivateFishingSwarm setval arguments.
Swarm.FISH_NONE = 0
Swarm.FISH_QWILFISH = 1
Swarm.FISH_REMORAID = 2
-- StoreSwarmMapIndices, which FALLS THROUGH into SetSwarmFlag: one command
-- writes the map pair AND the daily flag. A port that stored only the map
-- would leave the Dunsparce call live for the rest of the game, because
-- CheckSwarmFlag answers off the flag and clears the pair itself.
function Swarm.set(save, mapId)
if type(save) ~= "table" then return false end
save.dailyFlags = save.dailyFlags or {}
save.dailyFlags.swarm = true
save.swarmMap = mapId
return true
end
-- ActivateFishingSwarm: wScriptVar into wFishingSwarmFlag, then the same
-- fallthrough into SetSwarmFlag -- note it does NOT touch the map pair, so a
-- fishing swarm rides whatever map a grass swarm left behind.
function Swarm.setFishing(save, kind)
if type(save) ~= "table" then return false end
save.dailyFlags = save.dailyFlags or {}
save.dailyFlags.fishingSwarm = kind or Swarm.FISH_NONE
save.dailyFlags.swarm = true
return true
end
function Swarm.active(save)
return type(save) == "table" and save.dailyFlags ~= nil
and save.dailyFlags.swarm == true
end
function Swarm.mapId(save)
if not Swarm.active(save) then return nil end
return save.swarmMap
end
function Swarm.fishing(save)
if not Swarm.active(save) then return Swarm.FISH_NONE end
return (save.dailyFlags and save.dailyFlags.fishingSwarm) or Swarm.FISH_NONE
end
-- CheckSwarmFlag. Returns the value it leaves in wScriptVar: 0 while the flag
-- is up, 1 once it is not -- and on that 1 it clears the fishing flag and the
-- map pair, which is the ONLY thing that ever ends a swarm. Note the polarity:
-- an `iffalse` after this special means "the swarm is still on".
function Swarm.check(save)
if type(save) ~= "table" then return 1 end
if Swarm.active(save) then return 0 end
if save.dailyFlags then save.dailyFlags.fishingSwarm = nil end
save.swarmMap = nil
return 1
end
-- CheckDailyResetTimer (engine/overworld/time.asm): a one-day countdown that,
-- when it runs out, zeroes wDailyFlags1 AND wDailyFlags2 and restarts itself.
-- `day` is a day number that only has to be monotonic and comparable -- the
-- port's save carries os.date("%j") in save.rtc.day.
function Swarm.checkDailyReset(save, day)
if type(save) ~= "table" or not day then return false end
if save.dailyResetDay == nil then
save.dailyResetDay = day
return false
end
if save.dailyResetDay == day then return false end
save.dailyFlags = {}
save.dailyResetDay = day
return true
end
-- CheckTimeEvents' `.do_daily` block, in its order: the reset timer first, then
-- CheckSwarmFlag -- which is precisely why a swarm dies a day after it was set
-- rather than needing its own timer. Returns true when the swarm ended on
-- this call.
function Swarm.timeEvents(save, day)
local reset = Swarm.checkDailyReset(save, day)
local hadMap = save and save.swarmMap ~= nil
Swarm.check(save)
return reset and hadMap and (save.swarmMap == nil)
end
-- _SwarmWildmonCheck: the swarm table is searched BEFORE the Johto/Kanto one,
-- and only when the player is standing on the swarm's own map. A swarm map
-- that is not in the swarm table falls through to the normal lookup
-- (`call LookUpWildmonsForMapDE / jr nc, .noSwarm`), which is what keeps a
-- fishing swarm from blanking the grass on Route 32.
--
-- The reader below looks for `encounters.swarmGrass` and
-- `encounters.swarmWater`, keyed by map id in exactly the shape
-- `encounters.grass` / `encounters.water` already use -- which is what
-- RomExtractorGen2 writes, since the cart's swarm tables ARE grass and water
-- records. A cache built before it did simply has no swarm rows and every
-- lookup here falls through to the map's own list.
function Swarm.entry(save, encounters, mapId, kind)
if not encounters then return nil end
if Swarm.mapId(save) ~= mapId then return nil end
local table_ = (kind == "water") and encounters.swarmWater
or encounters.swarmGrass
return table_ and table_[mapId] or nil
end
-- An `encounters` view with the swarm's rows in front of the map's own, for a
-- caller that wants to keep using src/battle/gen2/Encounter.lua unchanged.
-- Returns the ORIGINAL table when no swarm applies, so the common step pays
-- nothing.
function Swarm.tables(save, encounters, mapId)
if not encounters then return encounters end
local grass = Swarm.entry(save, encounters, mapId, "grass")
local water = Swarm.entry(save, encounters, mapId, "water")
if not (grass or water) then return encounters end
local view = {}
for key, value in pairs(encounters) do view[key] = value end
if grass then
local rows = {}
for key, value in pairs(encounters.grass or {}) do rows[key] = value end
rows[mapId] = grass
view.grass = rows
end
if water then
local rows = {}
for key, value in pairs(encounters.water or {}) do rows[key] = value end
rows[mapId] = water
view.water = rows
end
return view
end
return Roamers
+792
View File
@@ -0,0 +1,792 @@
-- Gen 2 save file.
--
-- Deliberately separate from src/core/SaveData.lua rather than a branch inside
-- it: that module's shape is Gen 1's SRAM (Kanto badges, 12 boxes of 20,
-- pikachu happiness, the Gen 1 party struct with one `special` stat), and its
-- validate/migration chain asserts against that shape. A Gold save has a
-- different party struct (SpA/SpD, held item, happiness, pokerus), different
-- boxes, a phone book, a Pokedex with two orderings, and an RTC.
--
-- What IS shared, on purpose:
-- * SaveSerializer, so both generations' files are the same Lua-table format
-- and the standalone save editor can read either
-- * the save-file naming convention (GameVersion.saveSuffix -> save_gold.lua
-- plus .bak / .tmp), so Gold sits beside Red/Blue/Yellow without touching
-- them, and the same atomic write dance protects it
-- * options.lua, which is version-independent and survives New Game
--
-- Layout notes taken from the cart: a New Game starts at SPAWN_HOME
-- (PLAYERS_HOUSE_2F 3,3 -- engine/menus/intro_menu.asm NewGame), the money cap
-- is 999999, and playtime is kept as h/m/s/frames the way wGameTime* is.
local GameVersion = require("src.core.GameVersion")
local HallOfFame = require("src.core.gen2.HallOfFame")
local Mail = require("src.core.gen2.Mail")
local MomShopping = require("src.core.gen2.MomShopping")
local Logger = require("src.core.Logger")
-- The mod hook bus. Same module the Gen 1 save reaches for
-- (src/core/SaveData.lua), because the hook NAMES are shared across
-- generations: a mod that wraps save.new_game reshapes either game's skeleton
-- without knowing which one it is running under. Null objects until a loader
-- installs the live buses, so a mod-free boot and every headless test pay
-- nothing for the call.
local Runtime = require("src.mods.Runtime")
local SaveSerializer = require("src.core.SaveSerializer")
local function rand(a, b)
if love and love.math and love.math.random then
return love.math.random(a, b)
end
return math.random(a, b)
end
local Save = {}
-- Bumped whenever a field's meaning changes; migrations key off it.
--
-- 1 -> 2 the Hall of Fame roster (sHallOfFame + wHallOfFameCount) and
-- wSpawnAfterChampion. A format-1 save predates the endgame, so it
-- has neither and the migration is the empty roster.
-- 2 -> 3 scriptMem, the script VM's sparse WRAM store. A format-2 save
-- kept those bytes per-session, so the migration is the empty table:
-- every address reads back as 0, which is what the cart's own
-- zero-filled WRAM gives a save that never touched one.
-- 3 -> 4 mail: sPartyMail (six `mailmsg` structs keyed by PARTY SLOT) and
-- sMailboxes + sMailboxCount (the PC's MAILBOX). A format-3 save
-- has neither and cannot have a letter anywhere, so the upgrade is
-- the empty pair -- and a mon in one of those saves carrying a MAIL
-- item is exactly the case Mail.sendToPc's blank-struct fallback
-- covers.
-- 4 -> 5 `events` (wEventFlags) and `mapScenes` (the w<Map>SceneID block)
-- became LOAD BEARING. Both fields existed in format 4 and both
-- were written on every save, but nothing ever read them back --
-- World:loadPlayerData is what does now -- so a format-4 file's
-- copies had never been validated by anything. The migration is
-- the pair of tables; Save.validate is what scrubs them from here
-- on, the same way it always has for scriptMem.
-- 5 -> 6 `playerState` (wPlayerState), which sits in the same sPlayerData
-- block the two fields above do and had never been written at all:
-- a format-5 save made on the BICYCLE or aboard a Lapras came back
-- on foot. There is nothing to carry across, so the upgrade is
-- PLAYER_NORMAL -- the cart's own zero byte, and what those files
-- have effectively been loading as all along.
-- 6 -> 7 `mom.whichItem` (wWhichMomItem) and `mom.triggerBalance`
-- (wMomItemTriggerBalance), the two bytes MomTriesToBuySomething
-- walks (src/core/gen2/MomShopping.lua). NewGame seeds them in
-- engine/menus/intro_menu.asm, so a file made before Mom could
-- spend anything upgrades to exactly those seeds: the ladder on its
-- first rung and the consolation threshold at MOM_MONEY. A save
-- that already has savings banked therefore starts buying from the
-- bottom of the list, which is what a cartridge whose owner had
-- saved that much would also do.
Save.FORMAT = 7
Save.MAX_MONEY = 999999
Save.MAX_COINS = 9999
-- constants/pokemon_data_constants.asm: 6 party slots, 14 boxes of 20.
Save.PARTY_SIZE = 6
Save.NUM_BOXES = 14
Save.MONS_PER_BOX = 20
-- wEventFlags is `flag_array NUM_EVENTS` (ram/wram.asm) and NUM_EVENTS is
-- $800, so the bitfield is 256 bytes and a byte index past the last one cannot
-- have come from the cart.
Save.EVENT_BYTES = 256
-- wPlayerState (constants/ram_constants.asm), kept by NAME rather than as the
-- raw byte so a save that round-trips one stays readable. These are the four
-- strings src/world/gen2/FieldMoves.lua names the states by, and this is the
-- set World:loadPlayerData tests a restored value against as well, so the two
-- ends of the round trip cannot drift apart. PLAYER_SKATE has no entry for
-- the same reason FieldMoves has no name for it: nothing in Gold writes it.
Save.PLAYER_NORMAL = "normal"
Save.PLAYER_STATES = {
normal = true, bike = true, surf = true, surf_pika = true,
}
local function saveNames(version)
version = version or "gold"
-- Resolve the ACTIVE SLOT the same way SaveData does, and only fall back to
-- the flat save_<version>.lua when no slot is registered.
--
-- The launcher's slot system (src/core/SaveData.lua) migrates a flat
-- save_gold.lua into saves/<version>/<slot>.lua the first time it lists the
-- version's slots -- and DELETES the flat file. This module read the flat
-- name unconditionally, so after that migration the Gold title screen found
-- no save and dropped CONTINUE (and a following SAVE wrote a second copy to
-- the flat path the launcher no longer looks at). Reading through the same
-- slot resolution keeps the in-game load/save and the launcher on one file.
local ok, SaveData = pcall(require, "src.core.SaveData")
local slot = ok and SaveData.activeSlot and SaveData.activeSlot(version) or nil
if slot then
local main = "saves/" .. version .. "/" .. slot .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
Save.filenames = saveNames
local function fs()
return love.filesystem
end
-- A fresh Gold save. `opts` carries what the intro collected: player name,
-- rival name, and the options the OPTION screen was left on.
function Save.newGame(opts)
opts = opts or {}
local save = {
format = Save.FORMAT,
version = "gold",
generation = 2,
player = {
name = opts.playerName or "GOLD",
-- _ResetWRAM rolls wPlayerID out of hRandomSub/hRandomAdd
-- (engine/menus/intro_menu.asm:41-49).
id = opts.trainerId or rand(0, 65535),
gender = opts.gender or "male",
money = 3000,
coins = 0,
badges = {},
kantoBadges = {},
},
-- NewGame seeds wRivalName with "???", not with SILVER: _ResetWRAM calls
-- InitializeNPCNames (engine/menus/intro_menu.asm:131, :193-214), whose
-- .Rival row is literally `db "???@"`. SILVER is NameRival's InitName
-- FALLBACK (engine/events/specials.asm:80-91), copied in only after the
-- naming screen has closed on a blank entry, so the rival is "???" for
-- every {RIVAL} line and every RIVAL1 battle before the officer scene --
-- the Cherrygrove fight included.
rival = { name = opts.rivalName or "???" },
-- wMomSavingMoney's two bits BankOfMom actually flips (MOM_ACTIVE_F,
-- MOM_SAVING_SOME_MONEY_F -- src/script/gen2/Specials.lua H.BankOfMom):
-- `active` is "the bank conversation has happened at least once", which
-- gates whether a later visit opens on InitializeBank or on
-- IsThisAboutYourMoney; `savingMoney` is only meaningful once active.
--
-- `whichItem` and `triggerBalance` are wWhichMomItem and
-- wMomItemTriggerBalance, both written by NewGame itself
-- (engine/menus/intro_menu.asm): the MomItems_2 ladder starts on its
-- first rung and the consolation threshold starts at MOM_MONEY.
mom = { name = opts.momName or "MOM", active = false, savingMoney = false,
savedMoney = 0, whichItem = 0,
triggerBalance = MomShopping.MOM_MONEY },
-- Where the world resumes. nil means "use SPAWN_HOME".
position = nil,
-- Last Pokecenter, for a whiteout warp.
spawn = "SPAWN_HOME",
-- wPlayerState. A New Game starts on foot; the BICYCLE and SURF are what
-- write it, and it rides the save because the sprite, the step duration
-- and the tiles a step may land on all follow from it
-- (World:loadPlayerData).
playerState = Save.PLAYER_NORMAL,
-- sHallOfFame + wHallOfFameCount: `count` is how many times the champion
-- has been beaten (capped at HOF_MASTER_COUNT) and `teams` is the roster,
-- newest first, NUM_HOF_TEAMS deep. src/core/gen2/HallOfFame.lua owns
-- every read and write of it.
hallOfFame = { count = 0, teams = {} },
-- wSpawnAfterChampion, a one-shot: set by the induction, consumed by the
-- next CONTINUE (HallOfFame.consumePostGameSpawn). nil means "resume
-- where the save says", which is every ordinary load.
spawnAfterChampion = nil,
party = {},
boxes = {},
currentBox = 1,
boxNames = {},
inventory = {},
-- Gen 2 splits the bag into four pockets (ITEM / KEY_ITEM / BALL / TM_HM);
-- `inventory` stays the flat id->count map Gen 1's Bag uses, and PackMenu
-- buckets it by each item's extracted `pocket`.
--
-- wWhichRegisteredItem/wRegisteredItem (engine/overworld/select_menu.asm):
-- the item the SELECT button dispatches, set from the PACK
-- (World:registerItem) and re-validated against the live inventory on
-- every SELECT press (World:registeredItemId). nil means nothing is
-- registered, the same as the cart's byte being 0.
registeredItem = nil,
-- MAIL, both SRAM regions (src/core/gen2/Mail.lua): `party` is sPartyMail
-- keyed by party slot and `box` is sMailboxes, with sMailboxCount implied
-- by its length.
mail = { party = {}, box = {} },
pcItems = {},
phoneContacts = {},
tradeFlags = {},
pokedex = { seen = {}, caught = {} },
-- wUnownDex: the distinct Unown FORMS caught, in catching order. A second
-- record beside the #DEX because the #DEX knows only the species
-- (src/core/gen2/Unown.lua).
unownDex = {},
-- wFirstUnownSeen (ram/wram.asm:2703): the form letter of the FIRST Unown
-- the player ever met, latched once (engine/battle/core.asm:7894-7902) and
-- read back by the #DEX entry. 0 means "none yet", the cart's zero byte.
firstUnownSeen = 0,
events = {},
flags = {},
mapScenes = {},
-- The script VM's sparse WRAM store (src/script/gen2/Vm.lua `mem`):
-- address -> byte, for the addresses Script_readmem / Script_writemem
-- (engine/overworld/scripting.asm) poke that the port has nowhere else to
-- keep -- wUndergroundSwitchPositions in the Goldenrod underground and
-- wMooMooBerries at the Route 39 barn. Sparse on purpose: the cart's WRAM
-- is 8K and a save has no business carrying a dense image of it, only the
-- handful of bytes a script actually wrote.
scriptMem = {},
playTime = { hours = 0, minutes = 0, seconds = 0, frames = 0 },
-- RTC bookkeeping: which real day the save last saw, so daily events can
-- roll over (engine/rtc/rtc.asm StageRTCTimeForSave).
rtc = { day = tonumber(os.date("%j")) or 1, hour = tonumber(os.date("%H")) or 0,
minute = tonumber(os.date("%M")) or 0 },
options = nil, -- lives in options.lua; see SaveData.saveOptions
createdAt = os.time(),
}
-- Same hook, same name, same contract as Gen 1's SaveData.newGame: a total
-- conversion reshapes the skeleton (spawn, party, money) before anything
-- reads it. Unhooked this returns save unchanged, and it is the SAME table
-- so a caller holding the literal is never left behind.
return Runtime.call("save.new_game", function(s) return s end, save)
end
-- Gen 2's OPTION screen (engine/menus/options_menu.asm StringOptions).
-- Values are stored as names so a save stays readable and a changed enum
-- ordering cannot silently repoint an option.
Save.DEFAULT_OPTIONS = {
textSpeed = "MID", -- FAST / MID / SLOW
battleScene = true, -- animations on
battleStyle = "SHIFT", -- SHIFT / SET
sound = "MONO", -- MONO / STEREO
print = "NORMAL", -- LIGHTEST..DARKEST
menuAccount = true, -- show the start menu's description box
frame = 1, -- textbox frame 1-8
-- Port options, not the cart's. These are the same keys the Gen 1 save
-- uses (src/core/SaveData.lua) and they drive the same shared modules, so
-- a player's display and speed choices mean the same thing in both games.
speed = 1, -- GameSpeed.LEVELS multiplier, logic only
zoom = 0, -- Zoom offset from the window's fit scale
tilt = 0, -- Tilt.LEVELS degrees, 0 = off
gbcfx = 0, -- GBCFX ladder, 0 = off
-- COLOR: GbcPalette.MODES. "gbc" is the cart's own palettes and the
-- default -- this is a Game Boy Color game, so colour is ON out of the box
-- and the other two rungs are the deliberate step DOWN to a grey or green
-- Game Boy. The Gen 1 save's equivalent key is `colors` (SGB packs), which
-- means something different, hence the different name.
color = "gbc",
musicVol = 7, -- 0-7, like the GB's NR50 master volume
sfxVol = 7, -- 0-7
musicFilter = 0, -- low-pass steps, 0 = off
}
function Save.defaultOptions()
local out = {}
for key, value in pairs(Save.DEFAULT_OPTIONS) do out[key] = value end
return out
end
-- ------- options.lua
--
-- Gold's options live in the shared options.lua under their own `gold` key,
-- not on the flat path the Gen 1 keys use. Several names collide across the
-- two generations with DIFFERENT types -- battleStyle is "shift" in Gen 1 and
-- "SHIFT" here, textSpeed a frame delay there and a label here -- so sharing
-- the flat namespace would have each game quietly corrupting the other's
-- settings. The file itself is shared, which is what lets the launcher's
-- gear edit these before the game starts (src/import/LauncherSettings.lua).
Save.OPTIONS_KEY = "gold"
function Save.loadOptions(fs)
local options = Save.defaultOptions()
local ok, SaveData = pcall(require, "src.core.SaveData")
if not ok then return options end
local loaded = SaveData.loadOptions(fs)
local stored = loaded and loaded[Save.OPTIONS_KEY]
if type(stored) == "table" then
for key, value in pairs(stored) do options[key] = value end
end
return options
end
-- Read-modify-write, so writing Gold's block never drops the Gen 1 keys (or
-- the slot registry, or modOptions) sitting beside it.
function Save.saveOptions(options, fs)
if type(options) ~= "table" then return false end
local ok, SaveData = pcall(require, "src.core.SaveData")
if not ok then return false end
local file = SaveData.loadOptions(fs) or {}
local block = {}
for key, value in pairs(options) do block[key] = value end
file[Save.OPTIONS_KEY] = block
SaveData.saveOptions(file, fs)
return true
end
-- MON_PKRS is one byte in the party and box structs, and every reader of it
-- (src/core/gen2/Pokerus.lua) splits it into two nybbles -- so a file that
-- somehow grew a float, a negative or a value past 255 there would hand out a
-- strain and a day count that no cartridge could produce. Folded rather than
-- dropped, the way the money and coin caps above are clamped rather than reset.
local function normalizePokerus(mons)
for _, mon in ipairs(mons or {}) do
if type(mon) == "table" and mon.pokerus ~= nil then
local value = tonumber(mon.pokerus) or 0
if value < 0 then value = 0 end
mon.pokerus = math.floor(value) % 256
end
end
end
-- Fill in anything a save (or an older save) is missing, so callers can index
-- freely. Runs on both newGame and load.
function Save.normalize(save)
if type(save) ~= "table" then return nil end
save.format = save.format or Save.FORMAT
save.version = "gold"
save.generation = 2
save.player = save.player or {}
save.player.name = save.player.name or "GOLD"
save.player.id = save.player.id or rand(0, 65535)
save.player.money = math.max(0, math.min(save.player.money or 0, Save.MAX_MONEY))
save.player.coins = math.max(0, math.min(save.player.coins or 0, Save.MAX_COINS))
save.player.badges = save.player.badges or {}
save.player.kantoBadges = save.player.kantoBadges or {}
-- Same InitializeNPCNames seed as newGame: a save carrying no rival field is
-- a save that has not reached the officer, so it reads "???" rather than
-- NameRival's post-screen default.
save.rival = save.rival or { name = "???" }
save.mom = save.mom or {}
save.mom.name = save.mom.name or "MOM"
-- An older save (or one normalized before H.BankOfMom existed) has a `mom`
-- table with no `active`/`savingMoney` at all; both default to unset the
-- same way a cartridge that has never run BankOfMom reads wMomSavingMoney
-- as zero -- the bank has never been talked to and nothing is being saved.
if save.mom.active == nil then save.mom.active = false end
if save.mom.savingMoney == nil then save.mom.savingMoney = false end
save.mom.savedMoney = math.max(0, math.min(
tonumber(save.mom.savedMoney) or 0, Save.MAX_MONEY))
-- wWhichMomItem indexes MomItems_2 and wMomItemTriggerBalance is a money
-- field, so both are folded the way every other counter here is: an index
-- past the end of the ladder is what CheckBalance_MomItem2's own `cp
-- (MomItems_2.End - MomItems_2) / MOMITEM_SIZE` treats as "no rung left",
-- which is a legal resting state and not a value to clamp away.
save.mom.whichItem = math.max(0, math.floor(tonumber(save.mom.whichItem) or 0))
save.mom.triggerBalance = math.max(0, math.min(
math.floor(tonumber(save.mom.triggerBalance) or MomShopping.MOM_MONEY),
Save.MAX_MONEY + MomShopping.MOM_MONEY))
save.party = save.party or {}
save.boxes = save.boxes or {}
save.boxNames = save.boxNames or {}
save.currentBox = save.currentBox or 1
save.inventory = save.inventory or {}
-- Mail.state creates both SRAM regions on demand, so an older save (or one a
-- driver built by hand) can be indexed freely from the first letter on.
Mail.state(save)
save.pcItems = save.pcItems or {}
save.phoneContacts = save.phoneContacts or {}
-- wTradeFlags: one bit per NPC_TRADE_*, so a trade only ever happens once.
-- A set here, keyed by the trade's own id (src/core/gen2/NpcTrade.lua).
save.tradeFlags = save.tradeFlags or {}
save.pokedex = save.pokedex or {}
save.pokedex.seen = save.pokedex.seen or {}
save.pokedex.caught = save.pokedex.caught or {}
-- wUnownDex is NUM_UNOWN bytes; a file that somehow grew past that is
-- trimmed for the same reason an over-long party is.
save.unownDex = save.unownDex or {}
while #save.unownDex > 26 do table.remove(save.unownDex) end
-- wFirstUnownSeen is one byte holding a letter index 1..NUM_UNOWN, or 0
-- before any Unown has been met; anything else is a file that was edited.
local firstUnown = tonumber(save.firstUnownSeen) or 0
firstUnown = math.floor(firstUnown)
if firstUnown < 0 or firstUnown > 26 then firstUnown = 0 end
save.firstUnownSeen = firstUnown
-- wEventFlags, as the SERIALIZED BITFIELD src/world/gen2/Events.lua writes:
-- byte index -> byte value, sparse, keyed by NUMBER and not by name. Empty
-- means the file predates InitializeEventsScript ever running, which is what
-- World:loadPlayerData falls back to the seed on.
save.events = save.events or {}
save.flags = save.flags or {}
-- The w<Map>SceneID block (ram/wram.asm), as map id -> scene id. A map with
-- no entry is on scene 0, the same as the cart's zero-filled byte.
save.mapScenes = save.mapScenes or {}
-- wPlayerState. A file that predates the field reads as PLAYER_NORMAL, the
-- same as the cart's zero byte; Save.validate is what rejects a name no
-- cartridge could have produced.
save.playerState = save.playerState or Save.PLAYER_NORMAL
save.scriptMem = save.scriptMem or {}
save.playTime = save.playTime
or { hours = 0, minutes = 0, seconds = 0, frames = 0 }
save.rtc = save.rtc or {}
-- HallOfFame.record fills in the count and the roster list, and trims a
-- roster that a corrupt file grew past NUM_HOF_TEAMS -- the same guard the
-- party gets below, for the same reason.
local hof = HallOfFame.record(save)
while #hof.teams > HallOfFame.NUM_TEAMS do
table.remove(hof.teams)
end
-- Trim an over-long party rather than letting a corrupt file feed a
-- seventh mon into battle.
while #save.party > Save.PARTY_SIZE do
table.remove(save.party)
end
normalizePokerus(save.party)
for _, box in pairs(save.boxes) do
if type(box) == "table" then normalizePokerus(box) end
end
-- move_mon.asm:143-149: a mon the player owns carries wPlayerID; saves
-- written before the stamp existed get it here.
local Mon = require("src.battle.gen2.Mon")
for _, mon in ipairs(save.party) do Mon.stampOT(save, mon) end
for _, box in pairs(save.boxes) do
if type(box) == "table" then
for _, mon in ipairs(box) do Mon.stampOT(save, mon) end
end
end
return save
end
-- Migrations, oldest first. Each entry upgrades a save at `from` to `from+1`.
Save.MIGRATIONS = {
-- 1 -> 2: the endgame landed. A format-1 save was written before the Hall
-- of Fame existed, so it has no roster and cannot have been inducted; the
-- upgrade is the empty block, and wSpawnAfterChampion stays nil so the first
-- load after the upgrade is an ordinary CONTINUE rather than a warp to New
-- Bark Town.
[1] = function(save)
save.hallOfFame = save.hallOfFame or { count = 0, teams = {} }
save.spawnAfterChampion = nil
end,
-- 2 -> 3: the script VM's readmem / writemem bytes started riding the save.
-- Nothing to carry across (a format-2 file never wrote them down), so the
-- upgrade is the empty store and every address reads back 0.
[2] = function(save)
save.scriptMem = save.scriptMem or {}
end,
-- 3 -> 4: MAIL. A format-3 file predates sPartyMail and sMailboxes
-- entirely, so there is nothing to carry across and the upgrade is the empty
-- pair. A mon in one of those saves may still be HOLDING a mail item
-- (`givepokemail` used to hand one over with no struct behind it), and that
-- mon reads back as holding a blank letter rather than as holding nothing --
-- which is what the cart's own zero-filled struct would say too.
[3] = function(save)
save.mail = save.mail or { party = {}, box = {} }
end,
-- 4 -> 5: the world state started being read back. A format-4 file already
-- carries both tables (the snapshot has always written them), so this is not
-- a conversion -- it is the point at which they stop being write-only, and a
-- file that never had them gets the empty pair. An empty `events` is the
-- honest answer for a save whose world never ran: World:loadPlayerData reads
-- it as "InitializeEventsScript has not happened yet" and applies the seed,
-- which is the same branch PlayersHouse2FInitializeRoomCallback takes.
[4] = function(save)
save.events = save.events or {}
save.mapScenes = save.mapScenes or {}
end,
-- 5 -> 6: wPlayerState. Unlike the pair above, this field was never written
-- by anything, so there is genuinely nothing to carry across and every
-- format-5 file upgrades to PLAYER_NORMAL -- which is exactly what those
-- saves already came back as, because a world that read no state started on
-- foot. The `or` is for a file some other tool put a state in.
[5] = function(save)
save.playerState = save.playerState or Save.PLAYER_NORMAL
end,
-- 6 -> 7: Mom's shopping pair. Nothing could have written either byte
-- before MomTriesToBuySomething existed, so the upgrade is NewGame's own
-- seed and a file that somehow carries one keeps it.
[6] = function(save)
save.mom = save.mom or {}
if save.mom.whichItem == nil then save.mom.whichItem = 0 end
if save.mom.triggerBalance == nil then
save.mom.triggerBalance = MomShopping.MOM_MONEY
end
end,
}
function Save.migrate(save)
local format = tonumber(save.format) or 1
while format < Save.FORMAT do
local step = Save.MIGRATIONS[format]
if not step then break end
step(save)
format = format + 1
save.format = format
end
return save
end
-- ------- validation and quarantine
--
-- Same discipline as src/core/SaveData.lua's validate: a value play would
-- nil-index or wrap on never reaches the game, and whatever had to be dropped
-- is reported rather than vanishing silently. scriptMem is the field that
-- needs it most, because its keys are raw WRAM addresses rather than ids out
-- of a table this port owns, so nothing else can vouch for them.
-- Script_readmem / Script_writemem (engine/overworld/scripting.asm) read a
-- two-byte address and move a single byte through wScriptVar, so an entry
-- outside 0..$ffff / 0..255 cannot have come from a script running here.
-- Keys survive the serializer as strings in some files, hence the tonumber.
local function scrubScriptMem(save, report)
local mem = save.scriptMem
if type(mem) ~= "table" then
if mem ~= nil then
report.lostScriptMem[#report.lostScriptMem + 1] =
{ addr = nil, value = mem }
end
save.scriptMem = {}
return
end
local clean = {}
for key, value in pairs(mem) do
local addr, byte = tonumber(key), tonumber(value)
local okAddr = addr and addr == math.floor(addr)
and addr >= 0 and addr <= 0xFFFF
local okByte = byte and byte == math.floor(byte)
and byte >= 0 and byte <= 255
if okAddr and okByte then
clean[addr] = byte
else
report.lostScriptMem[#report.lostScriptMem + 1] =
{ addr = key, value = value }
end
end
save.scriptMem = clean
end
-- wEventFlags is 256 bytes of bitfield and nothing else in the save vouches
-- for a byte index, so it gets the same treatment scriptMem does: an index
-- past the last byte or a value that is not a byte could not have come from
-- the cart's array, and handing one to Events:restore would put a flag id no
-- object can ever name into the live bitfield. Keys survive the serializer as
-- strings in some files, hence the tonumber.
local function scrubEvents(save, report)
local flags = save.events
if type(flags) ~= "table" then
if flags ~= nil then
report.lostEvents[#report.lostEvents + 1] = { byte = nil, value = flags }
end
save.events = {}
return
end
local clean = {}
for key, value in pairs(flags) do
local index, byte = tonumber(key), tonumber(value)
local okIndex = index and index == math.floor(index)
and index >= 0 and index < Save.EVENT_BYTES
local okByte = byte and byte == math.floor(byte)
and byte >= 0 and byte <= 255
if okIndex and okByte then
clean[index] = byte
else
report.lostEvents[#report.lostEvents + 1] = { byte = key, value = value }
end
end
save.events = clean
end
-- The w<Map>SceneID block: one BYTE per map, keyed here by the map id the
-- cache uses rather than by the WRAM address, because that is what
-- World:mapSceneOf looks up. An id this cache does not know is left alone --
-- nothing ever reads it, the same way an unused scene byte sits in WRAM -- but
-- a key that is not a map id at all, or a scene that is not a byte, is dropped
-- rather than handed to a scene-script lookup that would index past its arms.
local function scrubMapScenes(save, report)
local scenes = save.mapScenes
if type(scenes) ~= "table" then
if scenes ~= nil then
report.lostMapScenes[#report.lostMapScenes + 1] =
{ map = nil, scene = scenes }
end
save.mapScenes = {}
return
end
local clean = {}
for key, value in pairs(scenes) do
local scene = tonumber(value)
local okMap = type(key) == "string" and key ~= ""
local okScene = scene and scene == math.floor(scene)
and scene >= 0 and scene <= 255
if okMap and okScene then
clean[key] = scene
else
report.lostMapScenes[#report.lostMapScenes + 1] =
{ map = key, scene = value }
end
end
save.mapScenes = clean
end
-- wPlayerState is one byte on the cart and one of four names here, so anything
-- else -- a raw byte out of a hand-edited file, a state this port has never
-- had -- is dropped back to PLAYER_NORMAL. Left alone it would give the
-- player a sprite lookup with no row of its own and step rules that belong to
-- nobody: neither isBiking nor isSurfing would answer true, so they would walk
-- at walking pace over land while the save insisted they were somewhere else.
local function scrubPlayerState(save, report)
local state = save.playerState
if Save.PLAYER_STATES[state] then return end
if state ~= nil then
report.lostPlayerState[#report.lostPlayerState + 1] = { state = state }
end
save.playerState = Save.PLAYER_NORMAL
end
function Save.validate(save)
local report = { lostScriptMem = {}, lostMail = {}, lostEvents = {},
lostMapScenes = {}, lostPlayerState = {} }
if type(save) ~= "table" then return report end
scrubScriptMem(save, report)
-- The world state World:loadPlayerData hands back to the live game: the
-- event bitfield, the per-map scene ids and wPlayerState. All three are
-- read on every load, so all three have to be trustworthy before the first
-- map comes up.
scrubEvents(save, report)
scrubMapScenes(save, report)
scrubPlayerState(save, report)
-- The `mailmsg` structs get the same treatment for the same reason: their
-- `type` byte is an item id nothing else in the save vouches for, and a
-- party key outside 1..6 or a MAILBOX past MAILBOX_CAPACITY is a region the
-- cart could not have written. Mail.validate owns the rules; this is only
-- where the ledger is collected (src/core/gen2/Mail.lua).
Mail.validate(save, report)
return report
end
-- True when nothing was quarantined, so a vanilla save loads without a word.
function Save.emptyReport(report)
if type(report) ~= "table" then return true end
return #(report.lostScriptMem or {}) == 0
and #(report.lostMail or {}) == 0
and #(report.lostEvents or {}) == 0
and #(report.lostMapScenes or {}) == 0
and #(report.lostPlayerState or {}) == 0
end
-- Does a Gold save exist? This is what decides whether the intro menu offers
-- CONTINUE (engine/menus/main_menu.asm MainMenu_GetWhichMenu reads
-- wSaveFileExists for exactly this).
function Save.exists(version)
local main, backup = saveNames(version)
local f = fs()
if not f then return false end
return (f.getInfo(main) ~= nil) or (f.getInfo(backup) ~= nil)
end
local function readTable(path)
local f = fs()
if not f or not f.getInfo(path) then return nil, "missing" end
local raw = f.read(path)
if not raw then return nil, "unreadable" end
local ok, value = pcall(SaveSerializer.decode, raw)
if not ok or type(value) ~= "table" then
return nil, "corrupt: " .. tostring(value)
end
return value
end
-- Returns save, recovered ("bak"/"tmp" when a staged or backup copy had to be
-- promoted), err, report (the quarantine ledger from Save.validate; empty for
-- any save this port wrote itself).
function Save.load(version)
local main, backup, tmp = saveNames(version)
local data, err = readTable(main)
local recovered
if not data then
local staged = readTable(tmp)
if staged then
data, recovered = staged, "tmp"
else
local prev = readTable(backup)
if prev then data, recovered = prev, "bak" end
end
end
if not data then return nil, nil, err end
Save.migrate(data)
Save.normalize(data)
local report = Save.validate(data)
if not Save.emptyReport(report) then
-- Gold has no report screen of its own yet; the log is what keeps a
-- quarantine from being invisible, the way Game.lua falls back for Gen 1.
Logger.warn(
"gold load report: %d script memory byte(s), %d MAIL struct(s), " ..
"%d event byte(s), %d map scene(s) and %d player state(s) dropped",
#report.lostScriptMem, #report.lostMail, #report.lostEvents,
#report.lostMapScenes, #report.lostPlayerState)
end
return data, recovered, nil, report
end
-- Atomic-ish write, matching SaveData.save: back the old file up, stage a
-- .tmp, replace, drop the .tmp. love.filesystem has no rename, so the .tmp
-- copy is the witness that survives a crash mid-replace.
function Save.save(save)
if type(save) ~= "table" then return false, "no save" end
local main, backup, tmp = saveNames(save.version)
local f = fs()
if not f then return false, "no filesystem" end
-- saveNames may now return a saves/<version>/<slot>.lua path, and
-- love.filesystem.write does not create missing parent directories.
local dir = main:match("^(.*)/[^/]+$")
if dir and f.createDirectory then f.createDirectory(dir) end
Save.normalize(save)
save.savedAt = os.time()
local encoded = SaveSerializer.encode(save)
if f.getInfo(main) then
local prev = f.read(main)
if prev then f.write(backup, prev) end
end
local ok, err = f.write(tmp, encoded)
if not ok then
Logger.error("gold save failed: %s", tostring(err))
return false, err
end
f.remove(main)
ok, err = f.write(main, encoded)
if not ok then
Logger.error("gold save failed: %s", tostring(err))
return false, err
end
f.remove(tmp)
Logger.info("saved gold game")
return true
end
-- The three lines Gold's CONTINUE panel shows before you confirm
-- (DisplaySaveInfoOnContinue): who, how many badges, how much of the dex, and
-- how long. Returned as data so the screen can lay it out.
function Save.summary(save)
if type(save) ~= "table" then return nil end
local badges = 0
for _, has in pairs(save.player and save.player.badges or {}) do
if has then badges = badges + 1 end
end
local caught = 0
for _, has in pairs(save.pokedex and save.pokedex.caught or {}) do
if has then caught = caught + 1 end
end
local time = save.playTime or {}
return {
name = save.player and save.player.name or "?",
badges = badges,
caught = caught,
hours = time.hours or 0,
minutes = time.minutes or 0,
map = save.position and save.position.map or save.spawn,
}
end
-- Advance the play clock one logic tick. Called from the fixed step, so 60
-- calls is one second, the same rate wGameTimeFrames counts at.
function Save.tickPlayTime(save)
local t = save and save.playTime
if not t then return end
t.frames = (t.frames or 0) + 1
if t.frames < 60 then return end
t.frames = 0
t.seconds = (t.seconds or 0) + 1
if t.seconds < 60 then return end
t.seconds = 0
t.minutes = (t.minutes or 0) + 1
if t.minutes < 60 then return end
t.minutes = 0
-- The cart caps at 999:59 and stops counting; do the same rather than
-- letting the trainer card overflow its field.
t.hours = math.min((t.hours or 0) + 1, 999)
end
return Save
+274
View File
@@ -0,0 +1,274 @@
-- The in-game trade animation (engine/movie/trade_animation.asm
-- TradeAnimation), the cable-and-ball sequence NPCTrade runs between
-- DoNPCTrade and TradedForText.
--
-- love-free: this file is the script and its clock, src/ui/gen2/TradeAnim.lua
-- is the half that draws. Nothing about the trade's outcome depends on any of
-- it -- DoNPCTrade has already swapped the two mons by the time the first
-- frame runs -- which is why the screen can be skipped without a branch.
--
-- The cart drives this from a byte script (`tradeanim` rows into
-- DoTradeAnimation.Jumptable), one command per frame, and each command either
-- runs a piece of setup and advances the pointer or sits on wFrameCounter
-- until it drains. The commands that only set something up (a palette, a
-- window position, a sprite struct) cost no frames, so the whole script
-- flattens to the list of WAITS below, with the setup a command did folded
-- into the `cue` of the beat that follows it. Two examples, since the folding
-- is the one place this stops being a transcription:
--
-- * TradeAnim_Poof sets wFrameCounter to 16 and advances immediately, so the
-- poof is still on screen while TradeAnim_EnterLinkTube2 slides the cable
-- in over its first 40 frames. It gets no beat of its own; `tube_in`
-- carries the "poof" cue and the drawing side gives the puff 16 frames.
-- * TradeAnim_RockingBall's 64 frames are only spent later, by the
-- TradeAnim_WaitAnim that follows EnterLinkTube2's own 40 + 80 -- the two
-- commands in between never touch wFrameCounter. That wait is `ball_rock`.
--
-- The frame counts are the cart's own: `ld c, 80 / call DelayFrames`,
-- `ld a, 92 / ld [wFrameCounter], a`, and the scrolls' step per frame.
--
-- Only the player-1 script is here. TradeAnimationPlayer2 is the same beats
-- in the other order and is reached from the cable club, which the port does
-- not have.
local TradeAnim = {}
-- Scroll steps, in pixels per frame.
--
-- TradeAnim_DoGivemonScroll moves hWX and hSCX 4 a frame until the window is
-- home; TradeAnim_EnterLinkTube2 / TradeAnim_ExitLinkTube move hSCX 4 a frame
-- over the tube's own $a0; the two Game Boy pans move hSCX 2 a frame.
TradeAnim.SCROLL_STEP = 4
TradeAnim.PAN_STEP = 2
-- hSCX starts at $88 for the frontpic scroll and hWX at $8f, i.e. both are
-- $88 from home.
TradeAnim.GIVEMON_SCROLL = 0x88
-- The link tube enters and leaves across $a0.
TradeAnim.TUBE_SCROLL = 0xa0
-- The Game Boy pan is a full wrap of the 256-pixel BG map: hSCX runs
-- 0 -> $50 -> $a0 -> $100, redrawing the tilemap at each boundary in the part
-- of the map the window has already left, so the three states read as one
-- continuous scene 256 pixels long. TradeAnim_InitTubeAnim's own
-- `hlbgcoord 20, 3 / ld bc, 12 / ld a, $60 / ByteFill` is what keeps the cable
-- unbroken across the seam.
TradeAnim.PAN_TOTAL = 0x100
-- The script. `frames` is how long the beat holds, `cue` fires on its first
-- frame.
--
-- The two Game Boy pans are one beat per hSCX target rather than one long one
-- because the cart really does stop at $50 and $a0 to swap the tilemap, and a
-- beat boundary is where the drawing side gets to notice.
TradeAnim.SCRIPT = {
-- ShowGivemonData, then TradeAnim_DoGivemonScroll's $88 at 4 a frame.
{ id = "givemon_scroll", frames = 34, cue = "show_give" },
{ id = "givemon_hold", frames = 80 },
-- Poof, RockingBall, EnterLinkTube1: the mon becomes a ball and the cable
-- slides in over it.
{ id = "tube_in", frames = 40, cue = "poof" },
-- EnterLinkTube2's `ld c, 80 / call DelayFrames` once hSCX is home.
{ id = "tube_hold", frames = 80 },
-- The WaitAnim spending RockingBall's 64.
{ id = "ball_rock", frames = 64 },
{ id = "bulge", frames = 128, cue = "bulge" },
-- GiveTrademonSFX, then TubeToOT2/3/4.
{ id = "send_pan_a", frames = 40, cue = "give_sfx" },
{ id = "send_pan_b", frames = 40 },
{ id = "send_pan_c", frames = 48 },
-- TubeToOT5 spends the 92 TubeToOT1 set, TubeToOT6/7 the 128 after it.
{ id = "send_wait", frames = 92 },
{ id = "send_hold", frames = 128 },
-- SentToOTText: the empty _MonNameSentToText holds an open box for 189
-- frames before the line itself, which then gets 80 + 128.
{ id = "sent_blank", frames = 189, cue = "clear" },
{ id = "sent_text", frames = 208 },
-- OTSendsText1's two pages, the second carrying its trailing `ld c, 14`.
{ id = "ot_sends_a", frames = 80 },
{ id = "ot_sends_b", frames = 94 },
-- OTBidsFarewell's two.
{ id = "farewell_a", frames = 80 },
{ id = "farewell_b", frames = 80 },
-- GetTrademonSFX, then TubeToPlayer2 waits its 92 BEFORE the pan (the
-- mirror of the send, where the wait comes after).
{ id = "get_wait", frames = 92, cue = "get_sfx" },
{ id = "get_pan_a", frames = 40 },
{ id = "get_pan_b", frames = 40 },
{ id = "get_pan_c", frames = 48 },
{ id = "get_hold", frames = 128 },
-- EnterLinkTube again, then DropBall / ExitLinkTube.
{ id = "tube_in2", frames = 40, cue = "tube" },
{ id = "tube_hold2", frames = 80 },
{ id = "tube_out", frames = 40, cue = "drop" },
{ id = "ball_wait", frames = 56 },
-- ShowGetmonData, then Poof's 16.
{ id = "getmon_poof", frames = 16, cue = "show_get" },
-- FrontpicScrollStart brings the stats window back up for Wait80.
{ id = "getmon_hold", frames = 80 },
{ id = "take_care", frames = 80 },
}
-- Which unrolled pan position a beat starts at, and which way it moves. The
-- send pans forward across the 256, the get pans back: TubeToPlayer3/4/5
-- SUBTRACT 2 a frame, starting from the wrap.
local PAN = {
send_pan_a = { base = 0x00, step = TradeAnim.PAN_STEP },
send_pan_b = { base = 0x50, step = TradeAnim.PAN_STEP },
send_pan_c = { base = 0xa0, step = TradeAnim.PAN_STEP },
send_wait = { base = 0x100, step = 0 },
send_hold = { base = 0x100, step = 0 },
get_wait = { base = 0x100, step = 0 },
get_pan_a = { base = 0x100, step = -TradeAnim.PAN_STEP },
get_pan_b = { base = 0xb0, step = -TradeAnim.PAN_STEP },
get_pan_c = { base = 0x60, step = -TradeAnim.PAN_STEP },
get_hold = { base = 0x00, step = 0 },
}
-- The beats that print a line, and the text label each one prints. The empty
-- _MonNameSentToText is not here: it draws an open box and nothing else, which
-- is what `sent_blank` having no entry means.
TradeAnim.TEXT = {
sent_text = "_MonWasSentToText",
ot_sends_a = "_ForYourMonSendsText",
ot_sends_b = "_OTSendsText",
farewell_a = "_BidsFarewellToMonText",
farewell_b = "_MonNameBidsFarewellText",
take_care = "_TakeGoodCareOfMonText",
}
TradeAnim.TOTAL = 0
for _, beat in ipairs(TradeAnim.SCRIPT) do
TradeAnim.TOTAL = TradeAnim.TOTAL + beat.frames
end
-- The beat a frame index (0-based) lands in, and how far into it that is.
-- Past the end answers the last beat, so a caller that overruns by a frame
-- draws the final picture rather than nothing.
function TradeAnim.beatAt(frame)
frame = math.max(0, math.floor(tonumber(frame) or 0))
local start = 0
for index, beat in ipairs(TradeAnim.SCRIPT) do
if frame < start + beat.frames then
return beat, frame - start, index
end
start = start + beat.frames
end
local last = TradeAnim.SCRIPT[#TradeAnim.SCRIPT]
return last, last.frames, #TradeAnim.SCRIPT
end
-- The frame index a beat starts on, for tests and for a caller that wants to
-- jump.
function TradeAnim.startOf(id)
local start = 0
for _, beat in ipairs(TradeAnim.SCRIPT) do
if beat.id == id then return start end
start = start + beat.frames
end
return nil
end
-- hSCX during the two scrolls that bring the give-mon panel home. Both the
-- background and the window are $88 out and close at 4 a frame.
function TradeAnim.givemonOffset(t)
return math.max(0, TradeAnim.GIVEMON_SCROLL - TradeAnim.SCROLL_STEP * t)
end
-- hSCX for the link tube. Entering, it closes from $a0; leaving, it opens
-- back out to $a0. The tube's tilemap sits at hlcoord 8, 2, so the drawing
-- side subtracts this from that x -- SCX scrolls the BACKGROUND, and a
-- positive value moves the picture LEFT.
function TradeAnim.tubeOffset(id, t)
local step = TradeAnim.SCROLL_STEP * t
if id == "tube_out" then
return math.min(TradeAnim.TUBE_SCROLL, step)
end
return math.max(0, TradeAnim.TUBE_SCROLL - step)
end
-- How far along the 256-pixel Game Boy scene the window is, unrolled: 0 is the
-- player's Game Boy at the left, 256 is the other one. nil for a beat that is
-- not part of a pan.
function TradeAnim.pan(id, t)
local row = PAN[id]
if not row then return nil end
local value = row.base + row.step * (tonumber(t) or 0)
if value < 0 then return 0 end
if value > TradeAnim.PAN_TOTAL then return TradeAnim.PAN_TOTAL end
return value
end
-- The trademon object's two ends, in screen pixels: TubeToOT1's
-- `depixel 5, 11, 4, 0` and TubeToPlayer1's `depixel 9, 18, 4, 4`, OAM-adjusted.
local ICON_NEAR_X, ICON_NEAR_Y = 80, 28
local ICON_FAR_X, ICON_FAR_Y = 140, 60
-- .MoveRight's `cp $94` / .MoveLeft's `cp $58` and .MoveDown's `cp $4c` /
-- .MoveUp's `cp $2c`, one pixel a frame.
local ICON_RUN = ICON_FAR_X - ICON_NEAR_X
local ICON_DROP = ICON_FAR_Y - ICON_NEAR_Y
-- .WaitTimer1 and .WaitTimer2 hold it still for their $80 apiece.
local ICON_PARKED = {
send_pan_a = true, send_pan_b = true, send_pan_c = true,
get_pan_a = true, get_pan_b = true, get_pan_c = true,
}
-- Where TradeAnim_AnimateTrademonInTube has the icon and its bubble on a pan
-- beat, or nil once .done_move_down / .WaitTimer2 zero SPRITEANIMSTRUCT_INDEX.
function TradeAnim.tubeIcon(id, t)
t = math.max(0, math.floor(tonumber(t) or 0))
if ICON_PARKED[id] then return ICON_NEAR_X, ICON_NEAR_Y end
if id == "send_wait" then
local run = math.min(ICON_RUN, t)
local drop = math.min(ICON_DROP, math.max(0, t - ICON_RUN))
return ICON_NEAR_X + run, ICON_NEAR_Y + drop
end
if id == "get_wait" then
local drop = math.min(ICON_DROP, t)
local run = math.min(ICON_RUN, math.max(0, t - ICON_DROP))
return ICON_FAR_X - run, ICON_FAR_Y - drop
end
return nil
end
-- The two trademon records TradeAnimation reads, built the way DoNPCTrade
-- fills them: the PLAYER's is the mon that just left the party (its own DVs,
-- OT and ID, under the player's name as sender), the OT's is the row's mon
-- (the row's OT name doubling as the sender). Called with the two records
-- NpcTrade.perform answered, so the given mon is the one that walked in, not
-- whatever now sits in that party slot.
function TradeAnim.records(data, save, row, given, received)
local pokemon = (data and data.pokemon) or {}
local player = (save and save.player) or {}
local function speciesOf(id)
local def = id and pokemon[id]
return {
species = id,
name = (def and def.name) or id or "?",
dex = (def and def.dex) or 0,
}
end
local give = speciesOf(given and given.species)
local get = speciesOf(received and received.species
or (row and row.get))
give.senderName = player.name or "GOLD"
give.otName = (given and (given.otName or given.ot)) or give.senderName
give.id = (given and given.otId) or player.id or 0
give.shiny = given and given.shiny or false
-- The DVs ride along because TradeAnim_GetFrontpic runs `predef
-- GetUnownLetter` before GetBaseData (engine/movie/trade_animation.asm:
-- 795-804): without them a traded Unown draws as letter A. unownLetter is
-- carried too, since Unown.monLetter prefers the stored form.
give.dvs = given and given.dvs
give.unownLetter = given and given.unownLetter
get.senderName = (row and row.otName) or (received and received.otName) or "?"
get.otName = get.senderName
get.id = (received and received.otId) or (row and row.otId) or 0
get.shiny = received and received.shiny or false
get.dvs = received and received.dvs
get.unownLetter = received and received.unownLetter
return give, get
end
return TradeAnim
+328
View File
@@ -0,0 +1,328 @@
-- Unown: the letter a set of DVs spells, which letters the Ruins of Alph have
-- unlocked, and the #DEX's own catching-order list of them.
--
-- Sources, all of them small and all of them in different files on the cart:
--
-- GetUnownLetter engine/gfx/load_pics.asm -- the DVs -> 1..26 map
-- CheckUnownLetter engine/battle/core.asm -- is that form unlocked
-- UnlockedUnownLetterSets data/wild/unlocked_unowns.asm -- the four sets
-- UpdateUnownDex engine/pokedex/unown_dex.asm -- wUnownDex
-- PrintUnownWord engine/pokedex/unown_dex.asm -- the word per form
-- CountUnown engine/events/specials.asm -- how many so far
--
-- They live together here because every one of them is about the FORM rather
-- than the species, and the port's save keys its #DEX by species (a single
-- UNOWN flag). The form list is a second, parallel record: `save.unownDex`,
-- a list of letter numbers in the order they were first caught, exactly the
-- shape of wUnownDex.
--
-- Letters are NUMBERS here, 1 = A .. 26 = Z, because that is what the cart
-- stores and what UnownWords / UnownPicPointers index by. `Unown.name` is
-- the only place a number becomes a character.
local Runtime = require("src.mods.Runtime")
local Unown = {}
-- constants/pokemon_constants.asm: NUM_UNOWN EQU 26.
Unown.NUM_UNOWN = 26
Unown.SPECIES = "UNOWN"
Unown.ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
-- 1 -> "A". Anything off the end answers nil rather than an empty string, so
-- a caller that got its number from bad data notices.
function Unown.name(letter)
if type(letter) ~= "number" then return nil end
if letter < 1 or letter > Unown.NUM_UNOWN then return nil end
return Unown.ALPHABET:sub(letter, letter)
end
-- "A" -> 1, and a number passes straight through. Handy for tests and for the
-- pic table, whose keys in pokemon.lua are the letters themselves.
function Unown.index(letter)
if type(letter) == "number" then return letter end
if type(letter) ~= "string" or #letter ~= 1 then return nil end
local at = Unown.ALPHABET:find(letter:upper(), 1, true)
return at
end
-- ------------------------------------------------------------ the letter
--
-- GetUnownLetter takes the MIDDLE two bits of each of the four DVs and packs
-- them in the order atk, def, spd, spc:
--
-- ; atk def spd spc
-- ; .ww..xx. .yy..zz.
--
-- The asm reads the two packed DV bytes, so its masks are $60 / $06 on each --
-- bits 1 and 2 of every nibble. With the DVs already unpacked into fields the
-- same two bits are `(dv >> 1) % 4`, which is what the shifts add up to.
--
-- Then `ld a, $ff / NUM_UNOWN + 1` is 10, Divide gives 0..25, and `inc a`
-- makes it 1..26. The divisor is integer-truncated on the cart ($ff / 26 is
-- 9), so it is 10 and not 255/26: a value of 250..255 still lands on Z.
local function middleBits(dv)
return math.floor((dv or 0) / 2) % 4
end
-- No DVs means the caller has a mon this module cannot read, which is a bug in
-- the caller rather than a letter A: Mon.new always rolls DVs (opts.dvs or
-- Mon.randomDVs, src/battle/gen2/Mon.lua:198) and a catch appends that same
-- record by reference, so every real party or box Unown has them. Answering 1
-- here is how a missing plumbing hop reads as a legitimate Unown A.
function Unown.letterFromDVs(dvs)
if not dvs then return nil end
local packed = middleBits(dvs.attack) * 64
+ middleBits(dvs.defense) * 16
+ middleBits(dvs.speed) * 4
+ middleBits(dvs.special)
return math.floor(packed / 10) + 1
end
-- ------------------------------------------------------- the unlocked sets
--
-- data/wild/unlocked_unowns.asm. Each solved puzzle sets one ENGINE_* flag and
-- that flag unlocks one contiguous run of letters; the runs are uneven because
-- they were cut to the four chamber puzzles, not to equal thirds.
--
-- The ids are constants/engine_flags.asm indices, counted the same way
-- src/core/gen2/Apricorns.lua counts its daily flags (const_def, 0-based, with
-- const_skip advancing). ENGINE_UNLOCKED_UNOWNS_A_TO_K is 42.
-- ENGINE_UNOWN_DEX, the flag RuinsOfAlphResearchCenterGetUnownDexScript sets.
-- It is what gates the #DEX's UNOWN MODE (Pokedex_CheckUnlockedUnownMode reads
-- it as wStatusFlags bit STATUSFLAGS_UNOWN_DEX_F), and it is a different thing
-- from having caught an Unown.
Unown.ENGINE_UNOWN_DEX = 12
Unown.UNLOCK_SETS = {
{ flag = 42, name = "ENGINE_UNLOCKED_UNOWNS_A_TO_K", first = 1, last = 11 },
{ flag = 43, name = "ENGINE_UNLOCKED_UNOWNS_L_TO_R", first = 12, last = 18 },
{ flag = 44, name = "ENGINE_UNLOCKED_UNOWNS_S_TO_W", first = 19, last = 23 },
{ flag = 45, name = "ENGINE_UNLOCKED_UNOWNS_X_TO_Z", first = 24, last = 26 },
}
-- The four puzzles, in UNOWNPUZZLE_* order (constants/script_constants.asm),
-- with the flag each chamber's .PuzzleComplete arm sets. `setval
-- UNOWNPUZZLE_KABUTO / special UnownPuzzle` is how the screen is told which
-- picture to slice, so the id the script passes indexes this list from 0.
Unown.PUZZLES = {
[0] = { id = "KABUTO", flag = 42, event = "EVENT_SOLVED_KABUTO_PUZZLE" },
[1] = { id = "OMANYTE", flag = 43, event = "EVENT_SOLVED_OMANYTE_PUZZLE" },
[2] = { id = "AERODACTYL", flag = 44,
event = "EVENT_SOLVED_AERODACTYL_PUZZLE" },
[3] = { id = "HO_OH", flag = 45, event = "EVENT_SOLVED_HO_OH_PUZZLE" },
}
-- CheckUnownLetter: walk the four sets, skip a set whose bit is clear, and
-- answer true as soon as the letter turns up in one that is set. Returns
-- carry on the cart, which is "NOT unlocked", so the sense is flipped here to
-- read the way the call sites want it.
function Unown.letterUnlocked(letter, engineFlags)
local index = Unown.index(letter)
if not index then return false end
for _, set in ipairs(Unown.UNLOCK_SETS) do
if engineFlags and engineFlags[set.flag] then
if index >= set.first and index <= set.last then return true end
end
end
return false
end
-- ChooseWildEncounter's `ld a, [wUnlockedUnowns] / and a / jr z,
-- .nowildbattle`: with no puzzle solved at all an Unown slot is not an
-- encounter, it is no encounter. The whole byte is tested, so the four unused
-- bits would count too; nothing ever sets them.
function Unown.anyUnlocked(engineFlags)
if not engineFlags then return false end
for _, set in ipairs(Unown.UNLOCK_SETS) do
if engineFlags[set.flag] then return true end
end
return false
end
-- Every letter currently reachable, in order. The puzzle screen has nothing
-- to say about this; it is here because the researcher's dialogue and the
-- encounter roll both want the same list.
function Unown.unlockedLetters(engineFlags)
local out = {}
for _, set in ipairs(Unown.UNLOCK_SETS) do
if engineFlags and engineFlags[set.flag] then
for letter = set.first, set.last do out[#out + 1] = letter end
end
end
table.sort(out)
return out
end
-- LoadEnemyMon's .GenerateDVs loop: roll DVs, take the letter, and roll again
-- while the letter is locked. The cart's loop is unbounded, and the comment
-- above it says so ("If combined with forced shiny battletype, causes an
-- infinite loop") -- here the retries are capped and the fallback picks an
-- unlocked letter directly, so a caller that hands in a degenerate RNG gets a
-- legal mon instead of a hang.
--
-- `randomDVs` is src/battle/gen2/Mon.randomDVs, passed in rather than required
-- so this module stays free of the party builder.
local DV_RETRIES = 256
function Unown.wildDVs(engineFlags, randomDVs)
local dvs = randomDVs()
if not Unown.anyUnlocked(engineFlags) then return dvs end
local tries = 0
while not Unown.letterUnlocked(Unown.letterFromDVs(dvs), engineFlags) do
tries = tries + 1
if tries >= DV_RETRIES then
return Unown.dvsForLetter(Unown.unlockedLetters(engineFlags)[1] or 1)
end
dvs = randomDVs()
end
return dvs
end
-- The inverse of GetUnownLetter, for the fallback above and for a test that
-- wants a mon of a named form. Letter n covers packed values 10*(n-1) ..
-- 10*(n-1)+9, so the lowest one in the band is the tidy representative; the
-- two middle bits of each DV are set from it and the outer bits left at zero.
function Unown.dvsForLetter(letter)
local index = Unown.index(letter) or 1
local packed = (index - 1) * 10
local function dv(shift)
return (math.floor(packed / shift) % 4) * 2
end
return {
attack = dv(64),
defense = dv(16),
speed = dv(4),
special = dv(1),
}
end
-- ------------------------------------------------------------- the #DEX
--
-- wUnownDex is 26 bytes of letter numbers in the order they were first caught,
-- zero-terminated. UpdateUnownDex walks it: a letter already in the list
-- returns at once, and the first zero is where a new one lands.
function Unown.dex(save)
if not save then return {} end
save.unownDex = save.unownDex or {}
return save.unownDex
end
function Unown.updateDex(save, letter)
local index = Unown.index(letter)
if not (save and index) then return false end
local list = Unown.dex(save)
for _, seen in ipairs(list) do
if seen == index then return false end
end
if #list >= Unown.NUM_UNOWN then return false end
list[#list + 1] = index
-- unown.unlocked, a Gen 2 invention: Gen 1 has one sprite per species and no
-- form list at all, so there is no name to share and pokemon.caught would be
-- the wrong one (this fires for a box deposit too, and not for the second
-- Unown of a letter already listed). UpdateUnownDex's early return IS the
-- gate: the event marks the moment a FORM becomes something the #DEX's UNOWN
-- MODE and the ALPH RUINS STAMP machine can show, which happens exactly once
-- per letter.
--
-- letter 1..26, the same number wUnownDex stores (A is 1)
-- name "A".."Z", for a mod that would rather print than index
-- word data/pokemon/unown_words.asm's word for the form
-- count how many forms are listed now, which is also VAR_UNOWNCOUNT
if Runtime.wants("unown.unlocked") then
Runtime.emit("unown.unlocked", {
letter = index, name = Unown.name(index), word = Unown.word(index),
count = #list,
})
end
return true
end
function Unown.caught(save, letter)
local index = Unown.index(letter)
if not index then return false end
for _, seen in ipairs(Unown.dex(save)) do
if seen == index then return true end
end
return false
end
-- The two places the cart calls `predef GetUnownLetter / callfar UpdateUnownDex`
-- are AddPartyMon's `.registerunowndex` and SendMonIntoBox (both
-- engine/pokemon/move_mon.asm), i.e. every route a caught Unown can take. The
-- port's equivalents are the battle's catch handler and `givepoke`, and both
-- call this rather than reaching into the list themselves.
--
-- Anything that is not an Unown falls straight through, so a call site does not
-- have to check the species first.
function Unown.registerCatch(save, mon)
local letter = Unown.monLetter(mon)
if not (save and letter) then return false end
return Unown.updateDex(save, letter)
end
-- CountUnown: `ld b, 0 / loop / ret z` -- the count of non-zero entries, which
-- with the list above is just its length. This is also VAR_UNOWNCOUNT
-- (engine/overworld/variables.asm .UnownCaught).
function Unown.count(save)
return #Unown.dex(save)
end
-- ------------------------------------------------------------- the words
--
-- data/pokemon/unown_words.asm. Each form has one word, printed under its
-- picture on the #DEX's UNOWN MODE page by PrintUnownWord at hlcoord 4, 15.
-- X really is "XXXXX" on the cart.
Unown.WORDS = {
"ANGRY", "BEAR", "CHASE", "DIRECT", "ENGAGE", "FIND", "GIVE", "HELP",
"INCREASE", "JOIN", "KEEP", "LAUGH", "MAKE", "NUZZLE", "OBSERVE", "PERFORM",
"QUICKEN", "REASSURE", "SEARCH", "TELL", "UNDO", "VANISH", "WANT", "XXXXX",
"YIELD", "ZOOM",
}
function Unown.word(letter)
local index = Unown.index(letter)
return index and Unown.WORDS[index] or nil
end
-- The letter a party/box mon is, or nil for anything that is not an Unown.
-- Reads the stored form first: a mon built before this existed still has DVs,
-- and the two always agree because the stored value comes from the DVs.
function Unown.monLetter(mon)
if not mon or mon.species ~= Unown.SPECIES then return nil end
if mon.unownLetter then return Unown.index(mon.unownLetter) end
return Unown.letterFromDVs(mon.dvs)
end
-- pokemon.lua's UNOWN entry carries `letters.A .. letters.Z`, each with its own
-- spriteFront / spriteBack: the pics come out of UnownPicPointers, not the
-- species' own row (pokegold engine/gfx/load_pics.asm GetFrontpic swaps the
-- pointer table for UnownPicPointers and indexes it by wUnownLetter). A cache
-- built before that landed has no `letters` table, and every caller here
-- degrades to the species' own pics -- which ARE letter A's, since that is
-- what GetUnownLetter defaults to.
function Unown.forms(pokemon)
local def = pokemon and pokemon[Unown.SPECIES]
return def and def.letters or nil
end
function Unown.formSprite(pokemon, letter, back)
local def = pokemon and pokemon[Unown.SPECIES]
if not def then return nil end
-- A caller that cannot name the letter answers nil, never letter A. Every
-- screen here is handed the mon and reads Unown.monLetter off it; a site that
-- resolved the pic from the SPECIES instead (as SummaryMenu:picFor once did)
-- has no letter to give, and coercing that to 1 is exactly what made a caught
-- Unown D show up in the party as an A with nothing logged.
local index = Unown.index(letter)
if not index then return nil end
local name = Unown.name(index)
local form = def.letters and name and def.letters[name]
if form then
return back and form.spriteBack or form.spriteFront
end
return back and def.spriteBack or def.spriteFront
end
return Unown