initial commit

This commit is contained in:
bryanthaboi
2026-07-17 20:30:02 -04:00
commit a5d2e77e7d
298 changed files with 100561 additions and 0 deletions
+822
View File
@@ -0,0 +1,822 @@
local bit = require("bit")
local ChipAudio = {}
local SAMPLE_RATE = 22050
local TICKS_PER_SECOND = 15360
local FRAME_TICKS = 256
-- Desktop/mobile playback should tolerate render stalls such as window
-- resizing. The original queue was only about 0.37s deep; this gives the
-- queue roughly six seconds of headroom without changing the synthesized
-- Game Boy timing or pitch.
local MUSIC_BUFFER_SAMPLES = 4096
local MUSIC_BUFFER_COUNT = 32
local GB_CLOCK = 4194304
local PITCHES = {
0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23,
0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA,
}
local DUTY = { [0] = 0.125, [1] = 0.25, [2] = 0.5, [3] = 0.75 }
local WAVE_LEVEL = { [0] = 0, [1] = 1, [2] = 0.5, [3] = 0.25 }
local NOISE_DIVISORS = {
[0] = 8, [1] = 16, [2] = 32, [3] = 48,
[4] = 64, [5] = 80, [6] = 96, [7] = 112,
}
local function snapTicks(ticks)
return math.floor((ticks * 735 + 256) / 512)
end
local cachedProgramFile
local cachedBanks
local currentMusic
local function loadBanks(data)
local audio = data.audio
if cachedProgramFile == audio.programFile and cachedBanks then
return cachedBanks
end
local raw, readError = love.filesystem.read(audio.programFile)
if not raw then error("could not read sound programs: " .. tostring(readError)) end
local banks = {}
for index, bank in ipairs(audio.bankOrder) do
local first = (index - 1) * 0x4000 + 1
banks[bank] = raw:sub(first, first + 0x3FFF)
end
cachedProgramFile, cachedBanks = audio.programFile, banks
return banks
end
local function romByte(banks, bank, address)
local bytes = assert(banks[bank], "uncached audio bank " .. tostring(bank))
local value = bytes:byte(address - 0x4000 + 1)
if not value then
error(("audio read outside bank %02X:%04X"):format(bank, address))
end
return value
end
local function romWord(banks, bank, address)
return romByte(banks, bank, address)
+ romByte(banks, bank, address + 1) * 0x100
end
local function headerChannels(banks, header)
local channels = {}
local address = header.address
local first = romByte(banks, header.bank, address)
local count = bit.rshift(bit.band(first, 0xF0), 6) + 1
for _ = 1, count do
local descriptor = romByte(banks, header.bank, address)
channels[#channels + 1] = {
number = bit.band(descriptor, 0x0F) + 1,
address = romWord(banks, header.bank, address + 1),
}
address = address + 3
end
return channels
end
local function fadeValue(nibble)
if bit.band(nibble, 8) ~= 0 then return -bit.band(nibble, 7) end
return nibble
end
local Channel = {}
Channel.__index = Channel
function Channel.new(engine, spec, options)
options = options or {}
local hardware = (spec.number - 1) % 4 + 1
local isSfxChannel = spec.number > 4
return setmetatable({
engine = engine,
bank = options.bank,
address = spec.address,
number = spec.number,
hardware = hardware,
wave = hardware == 3,
noise = hardware == 4,
sfx = isSfxChannel,
executeMusic = not isSfxChannel,
allowLoops = options.allowLoops ~= false,
frequencyOffset = options.frequencyOffset or 0,
frameTicks = options.frameTicks or FRAME_TICKS,
speed = 12,
volume = 12,
fade = 0,
duty = 0.5,
octave = 4,
waveInstrument = 0,
waveLevel = 1,
perfectPitch = false,
vibrato = nil,
pendingSlide = nil,
sweep = nil,
callStack = {},
loopCounts = {},
event = nil,
ended = false,
phase = 0,
noiseLfsr = 0x7FFF,
noiseClock = 0,
timeTicks = 0,
}, Channel)
end
function Channel:byte()
local value = romByte(self.engine.banks, self.bank, self.address)
self.address = self.address + 1
return value
end
function Channel:word()
local value = romWord(self.engine.banks, self.bank, self.address)
self.address = self.address + 2
return value
end
function Channel:frequency(note, octave)
local signed = PITCHES[note + 1] - 0x10000
local register = bit.band(
bit.arshift(signed, math.max(0, (octave or self.octave) - 1)), 0x7FF)
if self.perfectPitch then register = bit.band(register + 1, 0x7FF) end
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)
or self.speed
return length * speed * tempo
end
function Channel:timedEvent(event, ticks)
local first = snapTicks(self.timeTicks)
self.timeTicks = self.timeTicks + ticks
event.duration = ticks / TICKS_PER_SECOND
event.samples = snapTicks(self.timeTicks) - first
event.sample = 0
event.elapsed = 0
return event
end
function Channel:pan()
local mask = bit.lshift(1, self.hardware - 1)
return bit.band(bit.rshift(self.engine.pan, 4), mask) ~= 0,
bit.band(self.engine.pan, mask) ~= 0
end
function Channel:tone(ticks, register, volume, fade)
if register >= 0x800 then
return self:timedEvent({ silence = true }, ticks)
end
local duration = ticks / TICKS_PER_SECOND
local panLeft, panRight = self:pan()
local slide
if self.pendingSlide then
slide = {
target = self.pendingSlide.target,
frames = math.max(1, duration * 60 - self.pendingSlide.length),
}
self.pendingSlide = nil
end
return self:timedEvent({
register = register,
volume = volume == nil and self.volume or volume,
fade = fade == nil and self.fade or fade,
duty = self.duty,
wave = self.wave,
waveInstrument = self.waveInstrument,
waveLevel = self.waveLevel,
vibrato = slide and nil or self.vibrato,
slide = slide,
sweep = self.sfx and self.hardware == 1 and self.sweep or nil,
panLeft = panLeft,
panRight = panRight,
}, ticks)
end
function Channel:noiseEvent(ticks, volume, fade, parameter)
local panLeft, panRight = self:pan()
return self:timedEvent({
noise = true,
volume = volume or self.volume,
fade = fade or 0,
noiseParameter = parameter,
panLeft = panLeft, panRight = panRight,
}, ticks)
end
function Channel:drumEvent(ticks, instrument)
local panLeft, panRight = self:pan()
return self:timedEvent({
noise = true,
drum = self.engine:noiseInstrument(instrument),
panLeft = panLeft,
panRight = panRight,
}, ticks)
end
function Channel:silenceEvent(ticks)
return self:timedEvent({ silence = true }, ticks)
end
function Channel:nextEvent()
if self.ended then return nil end
for _ = 1, 100000 do
local commandAddress = self.address
local command = self:byte()
if (self.executeMusic or not self.sfx) and command < 0xC0 then
local note = bit.rshift(command, 4)
local length = bit.band(command, 0x0F) + 1
if self.noise then
local instrument = note
if command >= 0xB0 then instrument = self:byte() end
return self:drumEvent(self:durationTicks(length), instrument)
end
return self:tone(self:durationTicks(length), self:frequency(note))
elseif command >= 0xC0 and command < 0xD0 then
local length = bit.band(command, 0x0F) + 1
return self:silenceEvent(self:durationTicks(length))
elseif command >= 0xD0 and command < 0xE0 then
self.speed = bit.band(command, 0x0F)
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 >= 0xE0 and command <= 0xE7 then
self.octave = 8 - bit.band(command, 7)
elseif command == 0xE8 then
self.perfectPitch = not self.perfectPitch
elseif command == 0xE9 then
-- Unused command.
elseif command == 0xEA then
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 == 0xEB then
local length, packed = self:byte(), self:byte()
local octave = 8 - bit.rshift(packed, 4)
self.pendingSlide = {
length = length,
target = self:frequency(bit.band(packed, 0x0F), octave),
}
elseif command == 0xEC then
self.duty = DUTY[bit.band(self:byte(), 3)] or 0.5
elseif command == 0xED then
self.engine.tempo = self:byte() * 0x100 + self:byte()
elseif command == 0xEE then
self.engine.pan = self:byte()
elseif command == 0xEF or command == 0xF0 then
self:byte()
elseif command == 0xF8 then
self.executeMusic = not self.executeMusic
elseif command == 0xFC then
local packed = self:byte()
self.duty = {
DUTY[bit.band(bit.rshift(packed, 6), 3)],
DUTY[bit.band(bit.rshift(packed, 4), 3)],
DUTY[bit.band(bit.rshift(packed, 2), 3)],
DUTY[bit.band(packed, 3)],
}
elseif command == 0xFD then
self.callStack[#self.callStack + 1] = self.address + 2
self.address = self:word()
elseif command == 0xFE then
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 == 0xFF then
local returnAddress = table.remove(self.callStack)
if returnAddress then
self.address = returnAddress
else
self.ended = true
return nil
end
elseif self.sfx and command >= 0x20 and command < 0x30 then
local length = bit.band(command, 0x0F) + 1
local packed = self:byte()
local volume = bit.rshift(packed, 4)
local fade = fadeValue(bit.band(packed, 0x0F))
if self.noise then
local parameter = self:byte()
return self:noiseEvent(
self:durationTicks(length), volume, fade, parameter)
end
local register = bit.band(self:word() + self.frequencyOffset, 0x7FF)
return self:tone(self:durationTicks(length), register, volume, fade)
elseif command == 0x10 then
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),
}
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))
if fade > 0 then return math.max(0, volume - steps) end
return math.min(15, volume + steps)
end
function Channel:resetNoise()
self.noiseLfsr = 0x7FFF
self.noiseClock = 0
end
function Channel:clockNoise(width7)
local feedback = bit.bxor(
bit.band(self.noiseLfsr, 1),
bit.band(bit.rshift(self.noiseLfsr, 1), 1))
self.noiseLfsr = bit.bor(
bit.rshift(self.noiseLfsr, 1),
bit.lshift(feedback, 14))
if width7 then
self.noiseLfsr = bit.bor(
bit.band(self.noiseLfsr, bit.bnot(0x40)),
bit.lshift(feedback, 6))
end
end
function Channel:sampleNoise(parameter)
parameter = parameter or 0
local divisor = NOISE_DIVISORS[bit.band(parameter, 7)]
local shift = bit.rshift(parameter, 4)
local output = bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
if shift >= 14 then return output end
local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE
local width7 = bit.band(parameter, 8) ~= 0
local remaining = cycles
local area = 0
while remaining > 0 do
local untilClock = 1 - self.noiseClock
local span = math.min(remaining, untilClock)
output = bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
area = area + output * span
self.noiseClock = self.noiseClock + span
remaining = remaining - span
if self.noiseClock >= 1 - 1e-12 then
self.noiseClock = 0
self:clockNoise(width7)
end
end
return area / cycles
end
local function sweepCalculation(register, sweep)
local delta = math.floor(register / (2 ^ sweep.shift))
if sweep.subtract then return register - delta end
return register + delta
end
local function sweptRegister(register, sweep, elapsed)
if not sweep or sweep.shift == 0 then return register end
local nextRegister = sweepCalculation(register, sweep)
if nextRegister > 0x7FF or nextRegister < 0 then return nil end
if sweep.pace == 0 then return register end
local iterations = math.floor(elapsed * 128 / sweep.pace)
for _ = 1, iterations do
register = nextRegister
nextRegister = sweepCalculation(register, sweep)
if nextRegister > 0x7FF or nextRegister < 0 then return nil end
end
return register
end
function Channel:sampleDrum(event, sampleIndex)
local index = event.drumSegmentIndex or 1
local segment = event.drum[index]
while segment and sampleIndex >= segment.endSample do
index = index + 1
segment = event.drum[index]
end
if not segment or sampleIndex < segment.startSample then return 0 end
if event.drumSegmentIndex ~= index then
event.drumSegmentIndex = index
self:resetNoise()
end
local elapsed = (sampleIndex - segment.startSample) / SAMPLE_RATE
local volume = envelopeVolume(segment.volume, segment.fade, elapsed)
return self:sampleNoise(segment.parameter) * volume / 15 * 0.35
end
function Channel:sample()
while not self.ended
and (not self.event or self.event.sample >= self.event.samples) do
self.event = self:nextEvent()
self.phase = 0
self:resetNoise()
end
local event = self.event
if not event then return 0 end
local sampleIndex = event.sample
event.elapsed = sampleIndex / SAMPLE_RATE
event.sample = sampleIndex + 1
if event.silence then return 0 end
if event.drum then return self:sampleDrum(event, sampleIndex) end
local volume = envelopeVolume(
event.volume or 0, event.fade or 0, event.elapsed)
if event.noise then
return self:sampleNoise(event.noiseParameter) * volume / 15 * 0.35
end
local register = event.register
local frame = math.floor(event.elapsed * 60)
if event.sweep then
register = sweptRegister(register, event.sweep, event.elapsed)
if not register then return 0 end
elseif event.slide then
local amount = math.min(1, frame / event.slide.frames)
register = register + (event.slide.target - register) * amount
elseif event.vibrato and frame >= event.vibrato.delay then
local vibrato = event.vibrato
local toggles = math.floor(
(frame - vibrato.delay + 1) / (vibrato.rate + 1))
if toggles > 0 then
local low = bit.band(register, 0xFF)
local high = bit.band(register, 0x700)
if bit.band(toggles, 1) ~= 0 then
register = high + math.min(0xFF, low + vibrato.above)
else
register = high + math.max(0, low - vibrato.below)
end
end
end
local frequency = 131072 / (2048 - math.min(register, 2047))
if event.wave then frequency = frequency * 0.5 end
local phase = self.phase
self.phase = (phase + frequency / SAMPLE_RATE) % 1
if event.wave then
local wave = self.engine.waves[
math.min(event.waveInstrument + 1, #self.engine.waves)]
local index = math.min(32, math.floor(phase * 32) + 1)
return wave[index] * event.waveLevel * 0.55
end
local duty = event.duty
if type(duty) == "table" then
duty = duty[frame % 4 + 1]
end
return (phase < duty and 1 or -1) * volume / 15 * 0.5
end
local Engine = {}
Engine.__index = Engine
function Engine:noiseInstrument(number)
local cached = self.noiseInstruments[number]
if cached then return cached end
local header = self.noiseHeaders[tostring(number)]
local segments = {}
if header then
local spec = headerChannels(self.banks, header)[1]
local address = spec and spec.address
local ticks = 0
for _ = 1, 64 do
local command = romByte(self.banks, header.bank, address)
address = address + 1
if command == 0xFF then break end
if command < 0x20 or command >= 0x30 then
error(("unsupported drum command %02X at %02X:%04X")
:format(command, header.bank, address - 1))
end
local packed = romByte(self.banks, header.bank, address)
local parameter = romByte(self.banks, header.bank, address + 1)
address = address + 2
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[number] = segments
return segments
end
local function readWaves(banks, audio, engineNumber)
local spec = audio.waveBanks[tostring(engineNumber)]
local waves = {}
for wave = 0, 4 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) - 7.5) / 7.5
values[#values + 1] = (bit.band(packed, 0x0F) - 7.5) / 7.5
end
waves[#waves + 1] = values
end
local values = {}
for byteIndex = 0, 15 do
local packed = romByte(
banks, spec.bank, spec.address + 5 * 16 + byteIndex)
values[#values + 1] = (bit.rshift(packed, 4) - 7.5) / 7.5
values[#values + 1] = (bit.band(packed, 0x0F) - 7.5) / 7.5
end
for _ = 1, 4 do waves[#waves + 1] = values end
return waves
end
function Engine.new(data, header, options)
options = options or {}
local banks = loadBanks(data)
local engine = setmetatable({
banks = banks,
tempo = 0x100,
pan = 0xFF,
waves = readWaves(banks, data.audio, header.engine),
noiseHeaders = data.audio.noiseHeaders
and data.audio.noiseHeaders[tostring(header.engine)] or {},
noiseInstruments = {},
channels = {},
}, Engine)
for _, spec in ipairs(headerChannels(banks, header)) do
local frameTicks = options.frameTicks
local hardware = (spec.number - 1) % 4 + 1
if hardware == 4 then
frameTicks = FRAME_TICKS
elseif options.cryLength then
frameTicks = 0x80 + options.cryLength
end
engine.channels[#engine.channels + 1] = Channel.new(engine, spec, {
bank = header.bank,
sfx = options.sfx,
allowLoops = options.allowLoops,
frequencyOffset = options.frequencyOffset,
frameTicks = frameTicks,
})
end
return engine
end
function Engine:finished()
for _, channel in ipairs(self.channels) do
if not channel.ended or channel.event then return false end
end
return true
end
function Engine:sample()
local value = 0
for _, channel in ipairs(self.channels) do value = value + channel:sample() end
return math.max(-1, math.min(1, value * 0.5))
end
function Engine:sampleStereo()
local left, right = 0, 0
for _, channel in ipairs(self.channels) do
local value = channel:sample()
local event = channel.event
if not event or event.panLeft ~= false then left = left + value end
if not event or event.panRight ~= false then right = right + value end
end
return math.max(-1, math.min(1, left * 0.5)),
math.max(-1, math.min(1, right * 0.5))
end
function Engine:sampleChannel(number)
local selected = 0
for _, channel in ipairs(self.channels) do
local value = channel:sample()
if channel.number == number then selected = value end
end
return math.max(-1, math.min(1, selected * 0.5))
end
local function soundData(engine, samples, channels)
local result = love.sound.newSoundData(samples, SAMPLE_RATE, 16, channels)
for index = 0, samples - 1 do
if channels == 2 then
local left, right = engine:sampleStereo()
result:setSample(index, 1, left)
result:setSample(index, 2, right)
else
result:setSample(index, engine:sample())
end
end
return result
end
local function fillMusic()
local music = currentMusic
if not music or music.engine:finished() then return end
local free = music.source:getFreeBufferCount()
while free > 0 and not music.engine:finished() do
music.source:queue(soundData(
music.engine, MUSIC_BUFFER_SAMPLES, 2))
free = free - 1
end
end
function ChipAudio.playMusic(data, header, allowLoops)
ChipAudio.stopMusic()
local ok, source = pcall(
love.audio.newQueueableSource, SAMPLE_RATE, 16, 2, MUSIC_BUFFER_COUNT)
if not ok then return nil, source end
currentMusic = {
source = source,
engine = Engine.new(data, header, { allowLoops = allowLoops }),
}
fillMusic()
source:play()
return source
end
-- Recover from an audio queue underrun caused by a long render stall. This
-- is called after Music has handled intentional fanfare pauses, so it never
-- fights the normal pause/resume behavior.
function ChipAudio.ensureMusicPlaying()
local music = currentMusic
if not music or music.engine:finished() then return end
local ok, playing = pcall(music.source.isPlaying, music.source)
if ok and not playing then
fillMusic()
pcall(music.source.play, music.source)
end
end
function ChipAudio.update()
fillMusic()
end
function ChipAudio.stopMusic()
if currentMusic and currentMusic.source then
pcall(currentMusic.source.stop, currentMusic.source)
end
currentMusic = nil
end
local function renderEffect(data, header, options)
if not header then return nil end
options = options or {}
options.sfx = true
options.allowLoops = false
local engine = Engine.new(data, header, options)
local maximum = SAMPLE_RATE * 5
local values = {}
local count = 0
while count < maximum and not engine:finished() do
count = count + 1
values[count] = engine:sample()
end
if count < math.floor(SAMPLE_RATE / 100) then return nil end
local result = love.sound.newSoundData(count, SAMPLE_RATE, 16, 1)
for index = 1, count do result:setSample(index - 1, values[index]) end
return love.audio.newSource(result, "static")
end
function ChipAudio._renderMusicForTest(data, header, seconds)
local engine = Engine.new(data, header, { allowLoops = true })
return soundData(engine, math.floor(seconds * SAMPLE_RATE), 2)
end
function ChipAudio._renderMusicChannelForTest(data, header, seconds, number)
local engine = Engine.new(data, header, { allowLoops = true })
local samples = math.floor(seconds * SAMPLE_RATE)
local result = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 1)
for index = 0, samples - 1 do
result:setSample(index, engine:sampleChannel(number))
end
return result
end
function ChipAudio._traceFirstMusicSampleForTest(data, header)
local engine = Engine.new(data, header, { allowLoops = true })
local result = {}
for _, channel in ipairs(engine.channels) do
local value = channel:sample()
local event = channel.event or {}
result[#result + 1] = {
number = channel.number,
value = value,
register = event.register,
duration = event.duration,
volume = event.volume,
duty = event.duty,
wave = event.wave,
waveInstrument = event.waveInstrument,
drumSegments = event.drum and #event.drum or nil,
noiseParameter = event.noiseParameter,
sweep = event.sweep,
}
end
return result
end
function ChipAudio._traceFirstSfxSampleForTest(data, header)
local engine = Engine.new(data, header, {
sfx = true,
allowLoops = false,
})
local result = {}
for _, channel in ipairs(engine.channels) do
local value = channel:sample()
local event = channel.event or {}
result[#result + 1] = {
number = channel.number,
value = value,
register = event.register,
duration = event.duration,
volume = event.volume,
fade = event.fade,
noiseParameter = event.noiseParameter,
sweep = event.sweep,
}
end
return result
end
function ChipAudio._renderSfxForTest(data, header, seconds)
local engine = Engine.new(data, header, {
sfx = true,
allowLoops = false,
})
return soundData(engine, math.floor(seconds * SAMPLE_RATE), 1)
end
function ChipAudio.newSfx(data, name, pitch, tempo, header)
header = header or data.audio.sfx[name]
return renderEffect(data, header, {
frequencyOffset = pitch or 0,
frameTicks = 0x80 + (tempo or 0x80),
})
end
function ChipAudio.newCry(data, species)
local cry = data.audio.cries[species]
if not cry then return nil end
return renderEffect(data, cry.header, {
frequencyOffset = cry.pitch,
cryLength = cry.length,
})
end
function ChipAudio.newLowHealthAlarm()
local samples = math.floor(SAMPLE_RATE * 62 / 60)
local data = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 1)
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 frequency = 131072 / (2048 - register)
phase = (phase + frequency / SAMPLE_RATE) % 1
data:setSample(index, (phase < 0.5 and 1 or -1) * 0.25)
end
return love.audio.newSource(data, "static")
end
return ChipAudio
+64
View File
@@ -0,0 +1,64 @@
-- Loads generated data from either the private first-boot cache or the
-- optional source-tree developer build.
local Logger = require("src.core.Logger")
local Data = {}
local MODULES = {
"constants", "maps", "tilesets", "text", "text_pointers",
"trainer_headers", "font", "sprites", "pokemon", "moves", "items",
"type_chart", "trainers", "encounters", "field", "battle_anims",
}
-- Optional for compatibility with developer and stale caches.
local OPTIONAL = { "audio", "palettes", "icons" }
function Data:load()
for _, name in ipairs(MODULES) do
local ok, mod = pcall(require, "data.generated." .. name)
if not ok then
error(("missing generated data module 'data/generated/%s.lua'.\n" ..
"Import the ROM again or rebuild developer data.\n(%s)")
:format(name, mod))
end
self[name] = mod
end
for _, name in ipairs(OPTIONAL) do
local ok, mod = pcall(require, "data.generated." .. name)
self[name] = ok and mod or nil
if not ok then
Logger.warn("optional data module '%s' missing (feature disabled)", name)
end
end
Logger.info("generated data loaded (%d maps, %d species, %d moves)",
(function() local n = 0 for _ in pairs(self.maps) do n = n + 1 end return n end)(),
(function() local n = 0 for _ in pairs(self.pokemon) do n = n + 1 end return n end)(),
(function() local n = 0 for _ in pairs(self.moves) do n = n + 1 end return n end)())
end
-- Resolve a TEXT_* constant on a map to a plain string (or nil if the text
-- needs a hand-ported script; see data/scripts/).
function Data:resolveText(mapLabel, textConst)
local entry = self:textEntry(mapLabel, textConst)
if not entry then return nil end
if entry.text then
local s = self.text[entry.text]
if s then return s, entry.asm end
end
return nil, entry.asm
end
-- The raw text-pointer entry (carries mart/nurse/pc markers and the label).
function Data:textEntry(mapLabel, textConst)
local perMap = self.text_pointers[mapLabel]
return perMap and perMap[textConst] or nil
end
-- Trainer sight/dialogue header for a map object (or nil).
function Data:trainerHeader(mapLabel, objIndex)
local perMap = self.trainer_headers[mapLabel]
return perMap and perMap[objIndex] or nil
end
return Data
+23
View File
@@ -0,0 +1,23 @@
-- Fixed-step update loop at the Game Boy's ~60Hz. Game logic advances in
-- whole steps regardless of the display refresh rate, which keeps movement,
-- text speed and battle timing deterministic.
local FixedStep = {}
FixedStep.STEP = 1 / 60
local MAX_ACCUM = 0.25 -- avoid spiral of death after a stall
function FixedStep:init(callback)
self.accum = 0
self.callback = callback
end
function FixedStep:update(dt)
self.accum = math.min(self.accum + dt, MAX_ACCUM)
while self.accum >= self.STEP do
self.accum = self.accum - self.STEP
self.callback(self.STEP)
end
end
return FixedStep
+298
View File
@@ -0,0 +1,298 @@
-- Central game object: owns the data, renderer, input, state stack, world
-- and save state. Everything else reaches shared services through here.
local Data = require("src.core.Data")
local FixedStep = require("src.core.FixedStep")
local Input = require("src.core.Input")
local Logger = require("src.core.Logger")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local TouchInput = require("src.core.TouchInput")
local ModLoader = require("src.mods.Loader")
local Game = {}
function Game:load()
self.data = Data
Data:load()
-- Mods are a native engine subsystem. They load after the verified ROM
-- data exists, so mods can register or override the same definitions that
-- the rest of the game consumes. A broken mod is reported and skipped by
-- the loader without preventing the base game from booting.
self.mods = ModLoader.new()
self.mods:load(Data)
self.modStatus = self.mods:status()
self.input = Input
Input:init()
self.touchInput = TouchInput
TouchInput:init()
self.renderer = Renderer
Renderer:init()
require("src.render.Font").load(Data)
self.stack = StateStack
StateStack:init()
self.save = SaveData.newGame()
-- apply the persisted audio + display options before anything plays
self:applyOptions(self.save.options)
FixedStep:init(function(step) self:step(step) end)
self.fixedStep = FixedStep
local OverworldState = require("src.world.OverworldController")
self.overworld = OverworldState
-- boot into the title screen (engine/movie/title.asm); NEW GAME runs
-- the Oak speech + naming, CONTINUE restores the save. The headless
-- autopilot skips straight into the overworld.
if os.getenv("POKEPORT_AUTOPILOT") then
StateStack:push(OverworldState, self.save.player.map,
self.save.player.x, self.save.player.y, self.save.player.facing)
else
local titleState = self:makeTitleState()
-- the copyright splash + Nidorino-vs-Gengar attract movie plays
-- before the title (engine/movie/splash.asm + intro.asm)
local IntroMovie = require("src.ui.IntroMovie")
StateStack:push(IntroMovie.new(self, function()
StateStack:push(titleState)
end))
end
Logger.info("game loaded")
end
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
-- and by the START-menu QUIT confirmation
function Game:makeTitleState()
local TitleState = require("src.ui.TitleState")
local OverworldState = require("src.world.OverworldController")
return TitleState.new(self, {
onNewGame = function()
while self.stack:top() do self.stack:pop() end
-- New Game keeps the standalone options.lua preferences
self.save = SaveData.newGame()
self:applyOptions(self.save.options)
self.stack:push(OverworldState, self.save.player.map,
self.save.player.x, self.save.player.y,
self.save.player.facing)
local OakSpeech = require("src.ui.OakSpeech")
self.stack:push(OakSpeech.new(self, function() end))
end,
onContinue = function()
local loaded = SaveData.load()
if loaded then
self:restoreSave(loaded)
end
end,
})
end
-- QUIT from the START menu: back to the title like a power-cycle,
-- unsaved progress discarded. TitleState:enter restarts the title
-- theme; stop() keeps the map song from bleeding over in the meantime.
function Game:returnToTitle()
require("src.core.Music").stop()
while self.stack:top() do self.stack:pop() end
self.stack:push(self:makeTitleState())
end
function Game:step(dt)
self.input:step()
-- serviced unconditionally: a link battle's ENet transport must not
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
-- on top of BattleState (see LinkBattle.new)
if self.linkNet and not self.linkNet.closed then
self.linkNet:update()
end
self.stack:update(dt)
-- play time for the trainer card / save screen
self.save.playTime = (self.save.playTime or 0) + dt
require("src.core.Music").update(Data)
end
function Game:update(dt)
-- Touch timers / prior-frame auto-releases before the fixed step so
-- deferred A and edge pulses land in Input's press queue for this step.
TouchInput:update(dt)
FixedStep:update(dt)
-- Overworld tilt toggle tween: presentational, so it runs on the real
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
require("src.render.Tilt").update(dt)
end
function Game:draw()
-- the UI canvas clears transparent when the overworld's world pass
-- shows through beneath it; opaque full-screen states get the classic
-- white clear
local base = self.stack:visibleBase()
local worldBelow = self.stack.states[base] == self.overworld
Renderer:beginFrame(worldBelow)
self.stack:draw()
-- SGB colorization: the topmost state that knows its palette owns the
-- screen (overlays like text boxes inherit from what's beneath them);
-- the overworld's world pass colors each visible map area separately
local zones, worldZones
for i = #self.stack.states, 1, -1 do
local s = self.stack.states[i]
if s.sgbPalettes then
zones = s:sgbPalettes(self)
break
end
end
if worldBelow and self.overworld.sgbWorldZones then
worldZones = self.overworld:sgbWorldZones()
end
Renderer:endFrame(zones, worldZones)
end
-- overworld survey zoom: wheel up / '=' zooms in, wheel down / '-' out
function Game:zoomStep(delta)
local Zoom = require("src.render.Zoom")
if not Zoom.gateOK(self.stack:top(), self.overworld) then return end
Zoom.step(delta, Renderer:fitScale())
end
function Game:wheelmoved(_, dy)
if dy > 0 then
self:zoomStep(1)
elseif dy < 0 then
self:zoomStep(-1)
end
end
function Game:keypressed(key)
if self.stack and self.stack:top() and self.stack:top().onKeyPressed then
self.stack:top():onKeyPressed(key)
return
end
if key == "f10" then
local ManagerState = require("src.mods.ManagerState")
self.stack:push(ManagerState.new(self))
return
end
if key == "f1" then
self:writeSave()
return
elseif key == "f2" then
local loaded = SaveData.load()
if loaded then self:restoreSave(loaded) end
return
elseif key == "-" then
self:zoomStep(-1)
return
elseif key == "=" then
self:zoomStep(1)
return
elseif key == "2" then
-- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); always on
local PaletteFX = require("src.render.PaletteFX")
self.save.options.colors = PaletteFX.cycleMode()
self:writeOptions()
return
elseif key == "3" then
-- cycle TILT OFF → 15 → 35 → 50 → OFF (mnemonic: 3D), free-roam only
local Tilt = require("src.render.Tilt")
if Tilt.gateOK(self.stack:top(), self.overworld) then
self.save.options.tilt = Tilt.cycle()
self:writeOptions()
end
return
elseif key == "5" then
-- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on
local GBCFX = require("src.render.GBCFX")
self.save.options.gbcfx = GBCFX.cycle()
self:writeOptions()
return
end
Input:keypressed(key)
end
-- Mod enablement is stored with persistent options. Restarting the actual
-- LÖVE process ensures scripts, registries, and assets are all rebuilt from
-- the newly selected mod state.
function Game:restartWithMods()
if love.event and love.event.quit then
love.event.quit("restart")
end
end
function Game:keyreleased(key)
Input:keyreleased(key)
end
function Game:gamepadpressed(joystick, button)
Input:gamepadpressed(joystick, button)
end
function Game:gamepadreleased(joystick, button)
Input:gamepadreleased(joystick, button)
end
function Game:gamepadaxis(joystick, axis, value)
Input:gamepadaxis(joystick, axis, value)
end
function Game:touchpressed(id, x, y)
TouchInput:touchpressed(id, x, y)
end
function Game:touchmoved(id, x, y)
TouchInput:touchmoved(id, x, y)
end
function Game:touchreleased(id, x, y)
TouchInput:touchreleased(id, x, y)
end
-- Capture the live world state into the save table and persist it.
-- Options are flushed to options.lua as part of SaveData.save.
function Game:writeSave()
if self.overworld and self.overworld.captureSave then
self.overworld:captureSave(self.save)
end
SaveData.save(self.save)
end
-- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings
-- across New Game without touching the progress save.
function Game:writeOptions()
if not (self.save and self.save.options) then return end
SaveData.saveOptions(self.save.options)
end
-- Push the live options table into audio + display subsystems.
function Game:applyOptions(opts)
opts = opts or (self.save and self.save.options) or {}
local Music = require("src.core.Music")
local Sound = require("src.core.Sound")
if Music.applyOptions then Music.applyOptions(opts) end
if Sound.applyOptions then Sound.applyOptions(opts) end
require("src.render.PaletteFX").applyOptions(opts)
require("src.render.Tilt").applyOptions(opts)
require("src.render.GBCFX").applyOptions(opts)
end
function Game:restoreSave(loaded)
self.save = loaded
-- SaveData.load already attached the standalone options.lua table
self:applyOptions(loaded.options)
-- saves from before OT/ID stamping: backfill with the player's
local stamp = require("src.battle.BattleState").stampOT
for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end
for _, box in ipairs(loaded.boxes or {}) do
for _, mon in ipairs(box) do stamp(loaded, mon) end
end
-- rebuild the state stack from the save
while self.stack:top() do self.stack:pop() end
self.stack:push(self.overworld, loaded.player.map,
loaded.player.x, loaded.player.y, loaded.player.facing)
end
return Game
+123
View File
@@ -0,0 +1,123 @@
-- Input abstraction: maps keyboard to Game Boy buttons.
-- `down` = held this frame; `pressed` = edge, consumed per fixed step.
local Input = {}
local BINDINGS = {
up = "up", w = "up",
down = "down", s = "down",
left = "left", a = "left",
right = "right", d = "right",
z = "a", ["return"] = "a", space = "a",
x = "b", backspace = "b",
["kpenter"] = "start", escape = "start",
rshift = "select",
}
-- keys that map to "start" but also to "a" would conflict; keep Enter = a,
-- Escape = start for desktop friendliness.
-- LÖVE's standard gamepad mapping (SDL game controller DB), consistent
-- across Xbox/PlayStation/generic controllers on desktop and mobile.
local GAMEPAD_BINDINGS = {
dpup = "up", dpdown = "down", dpleft = "left", dpright = "right",
a = "a", b = "b",
start = "start", back = "select",
}
-- left-stick deadzones: press past STICK_ON, release once back under
-- STICK_OFF. The gap (hysteresis) stops the direction from flickering
-- while the stick sits near the threshold.
local STICK_ON = 0.5
local STICK_OFF = 0.3
function Input:init()
self.state = {}
self.pressQueue = {}
self.pressed = {}
self.stickAxis = { x = 0, y = 0 }
self.stickDir = nil
end
function Input:keypressed(key)
local btn = BINDINGS[key]
if btn then
table.insert(self.pressQueue, btn)
end
end
function Input:keyreleased(key)
local btn = BINDINGS[key]
if btn then
self.state[btn] = false
end
end
-- Called once per fixed step: promote queued presses to this step's edges.
function Input:step()
self.pressed = {}
for _, btn in ipairs(self.pressQueue) do
self.pressed[btn] = true
self.state[btn] = true
end
self.pressQueue = {}
end
function Input:gamepadpressed(joystick, button)
local btn = GAMEPAD_BINDINGS[button]
if btn then
table.insert(self.pressQueue, btn)
end
end
function Input:gamepadreleased(joystick, button)
local btn = GAMEPAD_BINDINGS[button]
if btn then
self.state[btn] = false
end
end
-- left stick treated as a continuous held direction, same 4-way rule as
-- the touch swipe d-pad: whichever axis has the larger magnitude wins.
function Input:gamepadaxis(joystick, axis, value)
if axis == "leftx" then
self.stickAxis.x = value
elseif axis == "lefty" then
self.stickAxis.y = value
else
return
end
local x, y = self.stickAxis.x, self.stickAxis.y
local ax, ay = math.abs(x), math.abs(y)
local newDir = self.stickDir
if ax > STICK_ON or ay > STICK_ON then
if ax >= ay then
newDir = x > 0 and "right" or "left"
else
newDir = y > 0 and "down" or "up"
end
elseif ax < STICK_OFF and ay < STICK_OFF then
newDir = nil
end
if newDir ~= self.stickDir then
if self.stickDir then
self.state[self.stickDir] = false
end
if newDir then
table.insert(self.pressQueue, newDir)
end
self.stickDir = newDir
end
end
function Input:isDown(btn)
return self.state[btn] or false
end
function Input:wasPressed(btn)
return self.pressed[btn] or false
end
return Input
+19
View File
@@ -0,0 +1,19 @@
-- Minimal logger; warnings are collected so debug overlays can show them.
local Logger = { history = {} }
local function emit(level, fmt, ...)
local msg = select("#", ...) > 0 and string.format(fmt, ...) or fmt
local line = string.format("[%s] %s", level, msg)
print(line)
table.insert(Logger.history, line)
if #Logger.history > 200 then
table.remove(Logger.history, 1)
end
end
function Logger.info(fmt, ...) emit("info", fmt, ...) end
function Logger.warn(fmt, ...) emit("warn", fmt, ...) end
function Logger.error(fmt, ...) emit("error", fmt, ...) end
return Logger
+362
View File
@@ -0,0 +1,362 @@
-- Music playback supports compact ROM channel programs synthesized live by
-- ChipAudio and legacy pre-rendered WAV definitions. Songs with split WAVs
-- chain def.file into def.loopFile in Music.update().
-- Map themes switch on map change; battles override with the battle
-- theme and restore afterwards; riding the bike overrides outdoor map
-- themes with Music_BikeRiding until dismount.
local Logger = require("src.core.Logger")
local Music = {}
local VOLUME = 0.7
-- port additions driven by OptionsMenu / save.options: musicVol scales
-- VOLUME (0-7 level like the GB's NR50 master volume) and musicFilter
-- low-passes the song. Each filter step keeps 40% of the previous
-- step's treble (highgain 0.4^level), so 2X/3X are the 1X filter
-- applied twice/three times over.
local volumeScale = 1
local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
local filterLevel = 0
local function applyVolume(src)
if src then pcall(src.setVolume, src, VOLUME * volumeScale) end
end
-- Source:setFilter needs OpenAL EFX; the pcall degrades to unfiltered
-- audio where it's missing (and under the headless stub)
local function applyFilter(src)
if not src then return end
if filterLevel > 0 then
pcall(src.setFilter, src, { type = "lowpass", volume = 1,
highgain = FILTER_HIGHGAIN[filterLevel] })
else
pcall(src.setFilter, src)
end
end
local state = {
enabled = true,
current = nil, -- song label
source = nil, -- currently playing source
loopSource = nil, -- pre-loaded loop body waiting for the intro to end
mapSong = nil, -- song to restore after a battle
onBike = false, -- bike theme overrides outdoor map themes
surfing = false, -- surf theme likewise (home/audio.asm MUSIC_SURFING)
pendingRestore = nil,
fanfare = nil, -- fanfare SFX source; the song pauses while it plays
fanfareResume = false, -- start/resume state.source when the fanfare ends
fade = nil, -- active volume-ramp fade-out (see Music.fadeOut)
}
-- Is a fanfare SFX (Sound.lua's FANFARES) still sounding?
local function fanfareActive()
local src = state.fanfare
if not src then return false end
local ok, playing = pcall(src.isPlaying, src)
if ok and playing then return true end
state.fanfare = nil
return false
end
-- Called by Sound.play when a fanfare starts: fanfares own the music
-- channels on the Game Boy, so the current song halts and resumes when
-- the jingle ends (see update()).
function Music.duckForFanfare(src)
if not state.enabled or not src then return end
state.fanfare = src
if state.source then
local ok, playing = pcall(state.source.isPlaying, state.source)
if ok and playing then
pcall(state.source.pause, state.source)
state.fanfareResume = true
end
end
end
-- Overworld themes where the bike can be ridden (outdoor maps plus the
-- caves/dungeons where gen-1 allows cycling). Indoor themes such as
-- Pokecenter/Gym/SilphCo never get replaced by the bike theme.
local OUTDOOR = {
Music_PalletTown = true,
Music_Cities1 = true,
Music_Cities2 = true,
Music_Celadon = true,
Music_Cinnabar = true,
Music_Vermilion = true,
Music_Lavender = true,
Music_Routes1 = true,
Music_Routes2 = true,
Music_Routes3 = true,
Music_Routes4 = true,
Music_IndigoPlateau = true,
Music_SafariZone = true,
Music_Dungeon1 = true,
Music_Dungeon2 = true,
Music_Dungeon3 = true,
}
local function songDef(data, song)
return data and data.audio and data.audio.songs and data.audio.songs[song]
end
local function stopSource(src)
if src then pcall(src.stop, src) end
end
local function newSource(file)
local ok, src = pcall(love.audio.newSource, file, "stream")
if ok and src then return src end
Logger.warn("music: cannot load %s", tostring(file))
return nil
end
function Music.play(data, song, loop)
if not state.enabled or not song or song == state.current then return end
if not love.audio then -- headless test stub
state.enabled = false
return
end
local def = songDef(data, song)
local runtime = data and data.audio and data.audio.runtime
if not def or (not runtime and not def.file) then return end
stopSource(state.source)
stopSource(state.loopSource)
if runtime then require("src.core.ChipAudio").stopMusic() end
state.source, state.loopSource, state.fade = nil, nil, nil
local wantLoop = loop ~= false
local src
if runtime then
local ok, generated = pcall(
require("src.core.ChipAudio").playMusic, data, def, wantLoop)
if ok then src = generated end
else
src = newSource(def.file)
end
if not src then
state.enabled = false
state.current = nil
return
end
if not runtime and def.loopFile then
-- intro file plays once, then update() chains to the loop body
-- (for one-shot jingles the body plays once and doesn't repeat)
pcall(src.setLooping, src, false)
local loopSrc = newSource(def.loopFile)
if loopSrc then
pcall(loopSrc.setLooping, loopSrc, wantLoop)
applyVolume(loopSrc)
applyFilter(loopSrc)
state.loopSource = loopSrc
else
pcall(src.setLooping, src, wantLoop) -- degrade: intro file only
end
else
pcall(src.setLooping, src, wantLoop)
end
applyVolume(src)
applyFilter(src)
-- a fanfare owns the music channels: hold the new song until it ends
-- (update() starts it, like the paused-song resume)
if fanfareActive() then
state.fanfareResume = true
else
pcall(src.play, src)
end
state.source = src
state.current = song
end
function Music.stop()
stopSource(state.source)
stopSource(state.loopSource)
require("src.core.ChipAudio").stopMusic()
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
end
-- Ramp the current song's volume to silence, then stop it, mirroring the
-- Game Boy's audio fade-out (home/fade_audio.asm FadeOutAudio +
-- home/audio.asm's .fadeOut): rAUDVOL's master volume steps 7 -> 0 in
-- integer levels, one level every `control` frames, and the music stops
-- when it reaches 0. `control` is the wAudioFadeOutControl value the ROM
-- writes (oak_speech.asm sets 10 at the shrink beat -> 7*10 = 70 frames
-- to silence). Ticked once per frame from Music.update().
function Music.fadeOut(control)
if not state.enabled then return end
if not state.source then Music.stop() return end
control = math.max(1, control or 10)
state.fade = {
control = control,
counter = control, -- frames until the next volume step
level = 7, -- current master-volume level (rAUDVOL nibble)
from = VOLUME * volumeScale, -- level-7 (full) source volume
}
end
-- the song a map should currently play, honoring the bike/surf overrides
local function effectiveMapSong(data, song)
if state.onBike and song and OUTDOOR[song]
and songDef(data, "Music_BikeRiding") then
return "Music_BikeRiding"
end
if state.surfing and song and OUTDOOR[song]
and songDef(data, "Music_Surfing") then
return "Music_Surfing"
end
return song
end
-- overworld map theme; onBike/surfing override outdoor themes with the
-- bike/surf songs and restore the map theme when they end
function Music.playMap(data, mapId, onBike, surfing)
local song = data and data.audio and data.audio.mapSongs
and mapId and data.audio.mapSongs[mapId] or nil
state.mapSong = song
state.onBike = not not onBike
state.surfing = not not surfing
local play = effectiveMapSong(data, song)
if play then Music.play(data, play) end
end
-- toggle the surf override mid-map (starting/ending a surf)
function Music.setSurfing(data, surfing)
state.surfing = not not surfing
local play = effectiveMapSong(data, state.mapSong)
if play then Music.play(data, play) end
end
-- battle themes; kind = "wild"|"trainer"|"gym"|"final"
function Music.playBattle(data, kind)
local b = data.audio and data.audio.battle
if b then Music.play(data, b[kind] or b.wild) end
end
-- victory theme (Music_DefeatedWildMon/Trainer/GymLeader): starts the
-- moment the win is decided and loops until the battle screen closes
-- (each Defeated* song ends in `sound_loop 0, .mainloop`); the battle's
-- finish() restores the map theme, like the overworld reload's
-- PlayDefaultMusicFadeOutCurrent. Returns true if the theme started.
function Music.playVictory(data, kind)
local b = data.audio and data.audio.battle
local jingle = b and b[kind .. "Win"]
local def = jingle and songDef(data, jingle)
if def and (def.file or (data.audio and data.audio.runtime)) then
Music.play(data, jingle)
return true
end
return false
end
-- one-shot jingle (PkmnHealed, Jigglypuff's song): the map theme
-- resumes when it ends, via update()
function Music.playOnce(data, song)
local def = songDef(data, song)
if not (def and (def.file or (data.audio and data.audio.runtime))) then
return false
end
Music.play(data, song, false)
state.pendingRestore = true
return true
end
-- is a playOnce jingle still sounding? (AnimateHealingMachine's
-- .waitLoop2 holds the healing machine until MUSIC_PKMN_HEALED ends)
function Music.oneShotPlaying()
if not state.pendingRestore then return false end
local src = state.source
if not src then return false end
local ok, playing = pcall(src.isPlaying, src)
return ok and playing or false
end
function Music.restoreMap(data)
state.current = nil
state.pendingRestore = nil
local play = effectiveMapSong(data, state.mapSong)
if play then Music.play(data, play) end
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)
volumeScale = math.max(0, math.min(7, level or 7)) / 7
applyVolume(state.source)
applyVolume(state.loopSource)
end
-- music low-pass filter level, 0 (OFF) to 3
function Music.setFilterLevel(level)
filterLevel = math.max(0, math.min(3, level or 0))
applyFilter(state.source)
applyFilter(state.loopSource)
end
-- re-apply persisted audio options (Game calls this on boot and after
-- loading a save)
function Music.applyOptions(opts)
Music.setVolumeLevel(opts and opts.musicVol or 7)
Music.setFilterLevel(opts and opts.musicFilter or 0)
end
local function sourceStopped(src)
if not src then return false end
local ok, playing = pcall(src.isPlaying, src)
return ok and not playing
end
-- call once per frame: chains a finished intro into its loop body and
-- restores the map theme after a one-shot jingle
function Music.update(data)
if data and data.audio and data.audio.runtime then
require("src.core.ChipAudio").update()
end
if not state.enabled then return end
-- volume ramp (Music.fadeOut): hold the current level for `control`
-- frames, then drop one level (FadeOutAudio decrements both rAUDVOL
-- nibbles when its counter reaches 0); at level 0 the music stops.
if state.fade then
local f = state.fade
f.counter = f.counter - 1
if f.counter <= 0 then
f.counter = f.control
f.level = f.level - 1
if f.level <= 0 then
state.fade = nil
Music.stop()
return
end
local vol = f.from * f.level / 7
if state.source then pcall(state.source.setVolume, state.source, vol) end
if state.loopSource then
pcall(state.loopSource.setVolume, state.loopSource, vol)
end
end
return
end
-- while a fanfare plays the song stays paused (a paused source reads
-- as stopped, so the intro-chain/restore checks below must not run);
-- when it ends, the song picks up where it left off
if state.fanfare then
if fanfareActive() then return end
if state.fanfareResume and state.source then
pcall(state.source.play, state.source)
end
state.fanfareResume = false
end
if data and data.audio and data.audio.runtime and not state.fanfare then
require("src.core.ChipAudio").ensureMusicPlaying()
end
if state.loopSource and sourceStopped(state.source) then
local loopSrc = state.loopSource
state.loopSource = nil
state.source = loopSrc
pcall(loopSrc.play, loopSrc)
end
if state.pendingRestore and sourceStopped(state.source)
and not state.loopSource then
Music.restoreMap(data)
end
end
return Music
+217
View File
@@ -0,0 +1,217 @@
-- Save/load via love.filesystem. Game progress lives in save.lua;
-- Options (audio, display, battle preferences) live in a separate
-- options.lua so they survive New Game and aren't tied to a save slot.
-- Both are plain Lua tables serialized as Lua source (deterministic
-- key order).
local Logger = require("src.core.Logger")
local SaveData = {}
local FILENAME = "save.lua"
local OPTIONS_FILENAME = "options.lua"
-- Port + original Options menu defaults. Missing keys on load are filled
-- from this table so old options.lua files stay compatible.
function SaveData.defaultOptions()
return {
-- textSpeed 3 = MEDIUM, matching InitOptions' TEXT_DELAY_MEDIUM
-- in wOptions (engine/menus/main_menu.asm)
textSpeed = 3,
animations = true,
battleStyle = "shift",
ruleset = "gen1_faithful",
-- 0-7 like the GB's NR50 master volume
musicVol = 7,
sfxVol = 7,
musicFilter = 0,
-- port display options (OptionsMenu / hotkeys 2/3/5)
colors = "gbc",
tilt = 0,
gbcfx = 0,
-- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default.
mods = {},
}
end
-- Merge loaded keys over defaults (shallow). Unknown keys are kept so
-- future options aren't dropped by older builds writing the file back.
function SaveData.mergeOptions(loaded)
local opts = SaveData.defaultOptions()
if type(loaded) == "table" then
for k, v in pairs(loaded) do
opts[k] = v
end
end
return opts
end
local function serialize(v, indent)
indent = indent or 0
local pad = string.rep(" ", indent)
local t = type(v)
if t == "number" or t == "boolean" then
return tostring(v)
elseif t == "string" then
return string.format("%q", v)
elseif t == "table" then
local keys = {}
for k in pairs(v) do table.insert(keys, k) end
table.sort(keys, function(a, b)
local ta, tb = type(a), type(b)
if ta ~= tb then return ta < tb end
return a < b
end)
if next(v) == nil then return "{}" end
local parts = {}
for _, k in ipairs(keys) do
local key
if type(k) == "string" and k:match("^[%a_][%w_]*$") then
key = k
else
key = "[" .. serialize(k) .. "]"
end
table.insert(parts, pad .. " " .. key .. " = " .. serialize(v[k], indent + 1))
end
return "{\n" .. table.concat(parts, ",\n") .. ",\n" .. pad .. "}"
end
error("cannot serialize " .. t)
end
function SaveData.encode(data)
return "return " .. serialize(data) .. "\n"
end
function SaveData.decode(str)
local loader = loadstring or load
local chunk, err = loader(str, "@save.lua")
if not chunk then return nil, err end
local ok, data = pcall(chunk)
if not ok then return nil, data end
if type(data) ~= "table" then return nil, "save root must be a table" end
return data
end
function SaveData.saveOptions(opts)
opts = SaveData.mergeOptions(opts)
local ok, err = love.filesystem.write(OPTIONS_FILENAME, SaveData.encode(opts))
if not ok then
Logger.error("options save failed: %s", tostring(err))
end
return ok and opts or nil
end
function SaveData.loadOptions()
if not love.filesystem.getInfo(OPTIONS_FILENAME) then
return SaveData.defaultOptions()
end
local chunk, err = love.filesystem.load(OPTIONS_FILENAME)
if not chunk then
Logger.error("options load failed: %s", tostring(err))
return SaveData.defaultOptions()
end
local ok, data = pcall(chunk)
if not ok or type(data) ~= "table" then
Logger.error("options load failed: %s", tostring(data))
return SaveData.defaultOptions()
end
return SaveData.mergeOptions(data)
end
-- Game progress only; options are written separately via saveOptions.
-- If `data.options` is present it is also flushed to options.lua so an
-- F1 / in-game save keeps the live settings in sync, then stripped from
-- the game file.
function SaveData.save(data)
if data.options then
SaveData.saveOptions(data.options)
end
local gameOnly = {}
for k, v in pairs(data) do
if k ~= "options" then gameOnly[k] = v end
end
local ok, err = love.filesystem.write(FILENAME, SaveData.encode(gameOnly))
if ok then
Logger.info("saved game")
else
Logger.error("save failed: %s", tostring(err))
end
return ok
end
function SaveData.load()
if not love.filesystem.getInfo(FILENAME) then
return nil
end
local chunk, err = love.filesystem.load(FILENAME)
if not chunk then
Logger.error("load failed: %s", tostring(err))
return nil
end
local ok, data = pcall(chunk)
if not ok then
Logger.error("load failed: %s", tostring(data))
return nil
end
-- saves from before the trainer ID existed: backfill once on load
-- (like the OT backfill for old saves)
if data.player and not data.player.id then
data.player.id = math.random(0, 65535)
end
-- saves from before EVENT_BEAT_ROUTE12/16_SNORLAX existed: the object
-- was already hidden (Snorlax beaten) but the flag was never added,
-- and it can never be set again since the hidden object is
-- unreachable -- backfill it from the toggle so it isn't stuck forever
if data.objectToggles and data.flags then
local snorlaxRoutes = {
{ map = "ROUTE_12", obj = "ROUTE12_SNORLAX", flag = "EVENT_BEAT_ROUTE12_SNORLAX" },
{ map = "ROUTE_16", obj = "ROUTE16_SNORLAX", flag = "EVENT_BEAT_ROUTE16_SNORLAX" },
}
for _, r in ipairs(snorlaxRoutes) do
local toggles = data.objectToggles[r.map]
if toggles and toggles[r.obj] == false and not data.flags[r.flag] then
data.flags[r.flag] = true
end
end
end
-- Migrate options that still live inside an old save.lua into the
-- standalone options file (once), then always prefer options.lua.
if type(data.options) == "table" and not love.filesystem.getInfo(OPTIONS_FILENAME) then
SaveData.saveOptions(data.options)
end
data.options = SaveData.loadOptions()
Logger.info("loaded save")
return data
end
function SaveData.newGame()
return {
player = {
map = "PALLET_TOWN",
x = 5,
y = 6,
facing = "down",
name = "RED",
rival = "BLUE",
-- 16-bit trainer ID rolled at new game (wPlayerID, filled from
-- hRandomAdd in OakSpeech)
id = math.random(0, 65535),
},
flags = {},
inventory = {},
party = {},
box = {},
money = 3000,
defeatedTrainers = {},
pokedex = { seen = {}, owned = {} },
-- where blackouts and ESCAPE ROPE return to (updated by nurses)
lastHeal = { map = "PALLET_TOWN", x = 5, y = 6 },
repelSteps = 0,
-- Live options from options.lua (or defaults); New Game keeps the
-- player's audio/display/battle preferences.
options = SaveData.loadOptions(),
}
end
return SaveData
+222
View File
@@ -0,0 +1,222 @@
-- Sound effects and cries synthesized from compact ROM channel programs or
-- loaded from legacy static audio definitions. Sources are cached; headless
-- use is a safe no-op.
local Sound = {}
local cache = {}
local enabled = true
-- port addition: 0-7 SFX volume from save.options.sfxVol (OptionsMenu),
-- scaling the 0.8 base every source gets
local BASE_VOLUME = 0.8
local volumeScale = 1
-- Fanfares occupy the music's tone channels on the Game Boy: their sfx
-- headers claim channels 5-7 (= hardware channels 1-3), silencing the
-- song until they finish (audio/headers/sfxheaders*.asm; the game also
-- blocks on them via PlaySoundWaitForCurrent/WaitForSoundToFinish).
-- The Poké Flute even issues SFX_STOP_ALL_MUSIC first
-- (engine/items/item_effects.asm). Music.lua pauses the current song
-- while one of these plays and resumes it afterwards. Ordinary short
-- SFX (menu beeps, hits, cries) stay overlaid.
local FANFARES = {
Level_Up = true,
Caught_Mon = true,
Get_Item1 = true,
Get_Item2 = true,
Get_Key_Item = true,
Pokedex_Rating = true,
Dex_Page_Added = true,
Pokeflute = true,
}
local function playPath(data, key, path, pitch, tempo)
if not enabled or not love.audio or not path then return nil end
local src = cache[key]
if not src then
local ok, s
if data.audio and data.audio.runtime and type(path) == "table" then
ok, s = pcall(
require("src.core.ChipAudio").newSfx,
data, key:match("^([^@]+)") or key, pitch, tempo, path)
else
ok, s = pcall(love.audio.newSource, path, "static")
end
if not ok or not s then
enabled = false
return nil
end
s:setVolume(BASE_VOLUME * volumeScale)
cache[key] = s
src = s
end
src:stop()
src:play()
return src
end
function Sound.play(data, name)
local sfx = data.audio and data.audio.sfx
local src = playPath(data, name, sfx and sfx[name])
if src and FANFARES[name] then
require("src.core.Music").duckForFanfare(src)
end
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
-- battle SFX -- audio/engine_2.asm Audio2_ApplyFrequencyModifier/
-- Audio2_SetSfxTempo). The extractor pre-synthesizes one WAV per
-- distinct (sfx, pitch, tempo) as "<name>@<pitch><tempo>" keys in the
-- sfx table; older audio.lua builds without the variants fall back to
-- the unmodified sound.
-- anim: a moves.lua anim table { sound, pitch, tempo }.
function Sound.playMove(data, anim)
if not anim or not anim.sound then return end
local sfx = data.audio and data.audio.sfx
if not sfx then return end
local name = anim.sound
local pitch, tempo = anim.pitch or 0, anim.tempo or 0x80
if data.audio.runtime and sfx[name] then
playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo),
sfx[name], pitch, tempo)
return
end
if pitch ~= 0 or tempo ~= 0x80 then
local key = ("%s@%02x%02x"):format(name, pitch, tempo)
if sfx[key] then
playPath(data, key, sfx[key])
return
end
end
playPath(data, name, sfx[name])
end
function Sound.playCry(data, species)
local cries = data.audio and data.audio.cries
-- returns the source (nil headless) so callers that block on the cry
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
local definition = cries and cries[species]
if data.audio and data.audio.runtime and definition then
local key = "cry:" .. tostring(species)
local src = cache[key]
if not src then
local ok, generated = pcall(
require("src.core.ChipAudio").newCry, data, species)
if not ok or not generated then return nil end
generated:setVolume(BASE_VOLUME * volumeScale)
cache[key] = generated
src = generated
end
src:stop()
src:play()
return src
end
return playPath(data, "cry:" .. tostring(species), definition)
end
-- GROWL/ROAR are the only two moves that play a cry (IsCryMove checks
-- wAnimationID); GetMoveSound still adds their own MoveSoundTable pitch/
-- tempo bytes on top of the cry's species modifiers before the tempo
-- register is set (Audio2_SetSfxTempo: tempo9bit = wTempoModifier+$80).
-- $80 is the table's "no extra shift" tempo byte (every other move's
-- entry defaults to it), so the two moves' own bytes -- Growl's $c0,
-- Roar's $40 -- are the *extra* shift on top of whatever the species'
-- cry already sounds like. The generated cry source already includes the
-- species' pitch/tempo, so layer the move's extra shift on with
-- Source:setPitch (pitch mod is left unmodeled: both moves set it $00).
function Sound.playMoveCry(data, species, tempoMod)
local src = Sound.playCry(data, species)
if src and tempoMod and tempoMod ~= 0x80 then
pcall(src.setPitch, src, 256 / (128 + tempoMod))
end
return src
end
-- is a previously played one-shot still sounding? (ShakeElevator's
-- .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]
if not src then return false end
local ok, playing = pcall(src.isPlaying, src)
return ok and playing or false
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]
if src then pcall(src.stop, src) end
end
-- Looping sources (the low-health alarm): started/stopped by game
-- states. ChipAudio generates the two-tone siren used by runtime imports;
-- legacy data can still provide a looping static source.
local loopCache = {}
local looping = {}
function Sound.startLoop(data, name)
if looping[name] then return end
local sfx = data.audio and data.audio.sfx
local path = sfx and sfx[name]
local runtimeAlarm = data.audio and data.audio.runtime
and name == "Low_Health_Alarm"
if not enabled or not love.audio or (not path and not runtimeAlarm) then
return
end
local src = loopCache[name]
if not src then
local ok, s
if data.audio.runtime and name == "Low_Health_Alarm" then
ok, s = pcall(require("src.core.ChipAudio").newLowHealthAlarm)
elseif data.audio.runtime and type(path) == "table" then
ok, s = pcall(
require("src.core.ChipAudio").newSfx, data, name)
else
ok, s = pcall(love.audio.newSource, path, "static")
end
if not ok then return end
s:setLooping(true)
s:setVolume(BASE_VOLUME * volumeScale)
loopCache[name] = s
src = s
end
src:play()
looping[name] = src
end
function Sound.stopLoop(name)
local src = looping[name]
if src then
pcall(src.stop, src)
looping[name] = nil
end
end
-- is a looping source currently sounding? (drivers assert on this)
function Sound.isLooping(name)
return looping[name] ~= nil
end
-- 0-7 SFX volume level (0 mutes); cached sources (menu beeps, cries,
-- the low-health alarm loop) update immediately so the change is heard
-- on the next play
function Sound.setVolumeLevel(level)
volumeScale = math.max(0, math.min(7, level or 7)) / 7
for _, src in pairs(cache) do
pcall(src.setVolume, src, BASE_VOLUME * volumeScale)
end
for _, src in pairs(loopCache) do
pcall(src.setVolume, src, BASE_VOLUME * volumeScale)
end
end
-- re-apply persisted audio options (Game calls this on boot and after
-- loading a save)
function Sound.applyOptions(opts)
Sound.setVolumeLevel(opts and opts.sfxVol or 7)
end
return Sound
+45
View File
@@ -0,0 +1,45 @@
-- Game state stack. The top state updates; all states draw bottom-up
-- (so a text box can overlay the overworld, a battle replaces it, etc).
-- States are tables with optional enter/exit/update/draw/isOpaque.
local StateStack = {}
function StateStack:init()
self.states = {}
end
function StateStack:push(state, ...)
table.insert(self.states, state)
if state.enter then state:enter(...) end
end
function StateStack:pop()
local state = table.remove(self.states)
if state and state.exit then state:exit() end
return state
end
function StateStack:top()
return self.states[#self.states]
end
function StateStack:update(dt)
local top = self:top()
if top and top.update then top:update(dt) end
end
-- index of the lowest state drawn this frame (highest opaque, else 1)
function StateStack:visibleBase()
for i = #self.states, 1, -1 do
if self.states[i].isOpaque then return i end
end
return 1
end
function StateStack:draw()
for i = self:visibleBase(), #self.states do
if self.states[i].draw then self.states[i]:draw() end
end
end
return StateStack
+282
View File
@@ -0,0 +1,282 @@
-- Touch gesture recognizer → virtual keyboard keys for Input.lua.
--
-- Deferred-tap tradeoff: A fires only after DOUBLE_TAP_MS with no second
-- tap. That adds ~280ms latency to every A press so a double-tap can be
-- remapped to START instead of A-then-START. Gen 1 has no frame-perfect
-- input needs, so the latency is acceptable.
--
-- Select = two-finger tap (open Q1 in docs/mobile-plan.md): when a second distinct
-- touch ID lands while another short, low-movement touch is active, fire
-- SELECT (press + one-frame auto-release).
local Input = require("src.core.Input")
local TouchInput = {}
local function dpiScale()
if love and love.window then
if love.window.getDPIScale then
return love.window.getDPIScale()
end
if love.window.toPixels then
return love.window.toPixels(1)
end
end
return 1
end
-- Tunables (device-DPI-scaled where noted). Adjust after on-device testing.
local SWIPE_THRESHOLD_PX = 14
local EDGE_PX = 24
local EDGE_SWIPE_PX = 24
local DOUBLE_TAP_MS = 280
local TAP_MAX_MS = 320
local TAP_MAX_MOVE_PX = 12
local function scaled(px)
return px * dpiScale()
end
local DIRS = { up = true, down = true, left = true, right = true }
-- Virtual keys Input:keypressed looks up in KEYBOARD bindings (not button names).
local KEY = {
up = "up",
down = "down",
left = "left",
right = "right",
a = "z",
b = "x",
start = "escape",
select = "rshift",
}
local function nowMs()
return love.timer.getTime() * 1000
end
local function dominantDir(dx, dy)
if math.abs(dx) >= math.abs(dy) then
return dx > 0 and "right" or "left"
end
return dy > 0 and "down" or "up"
end
function TouchInput:init()
self.touches = {}
self.pendingA = nil -- { deadlineMs = number }
-- Edge pulses (B / START / SELECT / deferred-A): press now, release on a
-- later update so FixedStep can consume wasPressed first.
-- `armed` = pressed during events since last update; promoted to
-- `autoRelease` at the start of update (released on the *following* update).
-- Deferred-A fired inside update goes straight into `autoRelease`.
self.armed = {}
self.autoRelease = {}
self.selectFired = false -- one SELECT per two-finger gesture cluster
end
local function pulse(self, key)
Input:keypressed(key)
self.armed[#self.armed + 1] = key
end
local function pulseInUpdate(self, key)
Input:keypressed(key)
self.autoRelease[#self.autoRelease + 1] = key
end
local function releaseDir(self, touch)
if touch.dir and DIRS[touch.dir] then
Input:keyreleased(KEY[touch.dir])
touch.dir = nil
end
end
local function pressDir(self, touch, dir)
if touch.dir == dir then return end
releaseDir(self, touch)
touch.dir = dir
Input:keypressed(KEY[dir])
end
local function totalMove(touch, x, y)
local dx = x - touch.x0
local dy = y - touch.y0
return math.abs(dx), math.abs(dy), dx, dy
end
local function isTapLike(touch, x, y, tMs)
local ax, ay = totalMove(touch, x, y)
local elapsed = tMs - touch.t0
return elapsed <= TAP_MAX_MS
and ax <= scaled(TAP_MAX_MOVE_PX)
and ay <= scaled(TAP_MAX_MOVE_PX)
and not touch.classified
end
local function countActive(self)
local n = 0
for _ in pairs(self.touches) do n = n + 1 end
return n
end
local function tryTwoFingerSelect(self, tMs)
if self.selectFired then return false end
local ids = {}
for id, touch in pairs(self.touches) do
if isTapLike(touch, touch.x, touch.y, tMs) then
ids[#ids + 1] = id
end
end
if #ids < 2 then return false end
self.selectFired = true
self.pendingA = nil
for _, id in ipairs(ids) do
local touch = self.touches[id]
touch.classified = true
touch.consumed = true
releaseDir(self, touch)
end
pulse(self, KEY.select)
return true
end
function TouchInput:update(dt)
-- Releases armed on a prior update (FixedStep already saw wasPressed).
for i = 1, #self.autoRelease do
Input:keyreleased(self.autoRelease[i])
end
-- Promote event-phase pulses from since the last update; they release next time.
self.autoRelease = self.armed
self.armed = {}
local tMs = nowMs()
-- Deferred A: fire once the double-tap window closes with no second tap.
-- Queued into autoRelease so the next update clears hold after this FixedStep.
if self.pendingA and tMs >= self.pendingA.deadlineMs then
self.pendingA = nil
pulseInUpdate(self, KEY.a)
end
-- Keep two-finger SELECT detection live while both fingers stay down.
if countActive(self) >= 2 then
tryTwoFingerSelect(self, tMs)
elseif countActive(self) == 0 then
self.selectFired = false
end
end
function TouchInput:touchpressed(id, x, y)
local tMs = nowMs()
-- Second tap inside the deferred-A window → START instead of A.
if self.pendingA and tMs < self.pendingA.deadlineMs then
self.pendingA = nil
pulse(self, KEY.start)
-- Still record this touch so a lingering finger doesn't become a stray swipe.
self.touches[id] = {
x0 = x, y0 = y, x = x, y = y, t0 = tMs,
edge = x < scaled(EDGE_PX),
classified = true,
consumed = true,
dir = nil,
}
return
end
self.touches[id] = {
x0 = x, y0 = y, x = x, y = y, t0 = tMs,
edge = x < scaled(EDGE_PX),
classified = false,
consumed = false,
dir = nil,
}
if countActive(self) >= 2 then
tryTwoFingerSelect(self, tMs)
end
end
function TouchInput:touchmoved(id, x, y)
local touch = self.touches[id]
if not touch or touch.consumed then return end
touch.x, touch.y = x, y
local ax, ay, dx, dy = totalMove(touch, x, y)
local swipeTh = scaled(SWIPE_THRESHOLD_PX)
-- Edge-origin swipes become B on release; never promote to d-pad.
if touch.edge then
if ax >= scaled(EDGE_SWIPE_PX) or ay >= scaled(EDGE_SWIPE_PX) then
touch.classified = true
end
return
end
if not touch.classified then
if ax < swipeTh and ay < swipeTh then return end
touch.classified = true
pressDir(self, touch, dominantDir(dx, dy))
return
end
-- Mid-hold direction change: release old, press new (dominant axis).
if touch.dir then
local fromLastX = x - touch.x0
local fromLastY = y - touch.y0
-- Re-evaluate from origin so small jitter doesn't flip; require threshold
-- distance from origin along the new dominant axis.
if math.abs(fromLastX) >= swipeTh or math.abs(fromLastY) >= swipeTh then
local newDir = dominantDir(fromLastX, fromLastY)
if newDir ~= touch.dir then
pressDir(self, touch, newDir)
end
end
end
end
function TouchInput:touchreleased(id, x, y)
local touch = self.touches[id]
if not touch then return end
local tMs = nowMs()
touch.x, touch.y = x, y
local ax, ay = totalMove(touch, x, y)
if touch.dir then
releaseDir(self, touch)
self.touches[id] = nil
if countActive(self) == 0 then self.selectFired = false end
return
end
if touch.consumed then
self.touches[id] = nil
if countActive(self) == 0 then self.selectFired = false end
return
end
-- Left-edge B: origin in EDGE_PX strip and movement past EDGE_SWIPE_PX
-- (or already marked classified while moving). Prefer over d-pad / tap.
if touch.edge then
local edgeTh = scaled(EDGE_SWIPE_PX)
if touch.classified or ax >= edgeTh or ay >= edgeTh then
pulse(self, KEY.b)
self.touches[id] = nil
if countActive(self) == 0 then self.selectFired = false end
return
end
end
-- Plain tap → defer A (or it was already classified as swipe without dir, ignore).
if isTapLike(touch, x, y, tMs) then
self.pendingA = { deadlineMs = tMs + DOUBLE_TAP_MS }
end
self.touches[id] = nil
if countActive(self) == 0 then self.selectFired = false end
end
return TouchInput