mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 08:11:35 +02:00
G2 support
This commit is contained in:
+10
-3
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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]
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user