mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-28 07:36:30 -04:00
7e2307f58c
* Use spare CPU cores to remove in-game hitches on low-power devices Per-frame game logic and drawing are cheap; the remaining stalls on low-power handhelds are one-off main-thread hitches from chip SFX/cry synthesis, RED++ atlas recolouring, image decodes and the require shim. Move those onto background threads, each with a synchronous fallback so behaviour is identical when threads are unavailable (POKEPORT_NO_THREAD=1, no love.thread, worker failure). Also adds an F3 frame-time overlay and POKEPORT_GAME_PROF headless profiling to measure these costs. * Address code review: shared WorkerLane, F3 gating, decode/epoch fixes - Extract src/core/WorkerLane.lua (worker lifecycle, prioritised lanes, per-lane epochs, bounded LRU) and rebuild the SFX and atlas/decode prefetchers on it - Keep decoded sprite sheets in every colour mode (the OBJ palette bake re-reads them), skip oversized decodes, and let palette changes drop only atlas bakes - Only bump the SFX render epoch when the channel mix actually changes - F3 overlay toggle no longer steals the key from rebinding screens or player bindings; profilers stop only when a quit really proceeds - Require fast path: test hook instead of a hot-path counter - Shared tests/drivers/perf_util.lua; drivers restore their patches * WorkerLane: no budget leak on duplicate sends, keep priority on requeue - Lane:pump only counts a job against the in-flight budget once per key - requeueInflight puts entries back at the head of their own queue, so background hints no longer overtake front requests after a bump - shutdown() keeps a fatally failed worker off - Hiding the F3 overlay logs one summary line instead of a full block * F3 overlay: last-120-frame stats, font-sized box, scale on large screens * Prefetch: skip useless hints, no main-thread fallback, mix check vs worker config - Don't queue decodes for sprite sheets whose Image and OBJ bake are cached - Atlas hint is dropped (sync bake at build time) when the worker can't decode the tileset itself, instead of decoding on the main thread - SFX epoch bump compares the live mix with what the worker was last configured with, replacing the load-time snapshot * Sprite decode skip: raw-path cache check, never under RED++ * Gen 2 battles prefetch both parties' cries; sprite skip keyed by raw path * Prefetch: skip cached tilesets outside RED++, log hint failures once * Profiler: bench survives F3 hide, frame total excludes vsync; safer SFX pump, ordered requeue, tileset skip respects pending variants * Profiler counts GPU stalls in present; water void-fill keeps tileset hint; README + test coverage * Profiler vsync cap: measured cadence via RefreshRate, no per-frame allocation * Use upstream's chip-worker prewarm for SFX/cries; drop our separate SFX worker Our extra coverage (Gen 1 common SFX + party cries, both parties' battle cries, lead move sounds, hit and battle SFX, Gen 2 bench cries) now goes through ChipAudio.prewarmSfx/prewarmCry and a new Sound.prewarmMove. * Skip decode hints for composed assets; share move cache key; drop dead demoteAll * Drop dev-only profiling extras: POKEPORT_JITP hook and perf drivers * README: name the worker threads POKEPORT_JIT leaves alone * Profiler: count the pre-present vblank wait as present, log on NX quit - time PresentSync.waitBeforePresent as "present" (the X11 GLX vblank wait lands there), so it is excluded like the wait inside present - NX quit now emits the PROF summary / overlay line too - love.quit's scripted check reads FrameProfiler.benchRequested, so POKEPORT_GAME_PROF=0 no longer counts as a bench run - prefetchAtlas skips composed tileset sheets (Assets.isComposed) - hit sounds defined once in EffectRegistry.HIT_SOUNDS, shared with the battle prewarm (each hit still gets its own copy) - drop redundant obpPaths, dead FrameProfiler.visible and the unused Lane:bump requeue mode; restore applyOptions' doc comment
193 lines
6.7 KiB
Lua
193 lines
6.7 KiB
Lua
-- Extra SFX / cry prewarm coverage on top of ChipAudio's prewarm (Sound.prewarmMove,
|
|
-- the Gen 1 battle prewarmCries, Game:prewarmCommonAudio).
|
|
-- luajit tests/engine/prewarm_coverage_test.lua
|
|
-- The chip worker is driven in-process like chipaudio_prewarm_queue_2732.lua.
|
|
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local S = require("tests.harness").suite("prewarm coverage")
|
|
local check, eq = S.check, S.eq
|
|
|
|
love = require("tests.love_stub")
|
|
local ffi = require("ffi")
|
|
|
|
local SoundData = {}
|
|
SoundData.__index = SoundData
|
|
function SoundData:setSample(index, a, b)
|
|
if b == nil then self.buf[index * self.channels] = a * 32767
|
|
else self.buf[index * self.channels + (a - 1)] = b * 32767 end
|
|
end
|
|
function SoundData:getFFIPointer() return self.buf end
|
|
function SoundData:getSampleCount() return self.samples end
|
|
function SoundData:getSampleRate() return self.rate end
|
|
function SoundData:getChannelCount() return self.channels end
|
|
love.sound.newSoundData = function(samples, rate, _, channels)
|
|
return setmetatable({ samples = samples, rate = rate, channels = channels,
|
|
buf = ffi.new("int16_t[?]", samples * channels) }, SoundData)
|
|
end
|
|
|
|
local Source = {}
|
|
Source.__index = Source
|
|
function Source:play() self.playing = true end
|
|
function Source:stop() self.playing = false end
|
|
function Source:isPlaying() return self.playing end
|
|
function Source:setVolume() end
|
|
function Source:setPitch() end
|
|
function Source:getPitch() return 1 end
|
|
function Source:getDuration() return 1 end
|
|
love.audio = { newSource = function(sd) return setmetatable({ sd = sd }, Source) end }
|
|
|
|
local channels = {}
|
|
local function channel(name)
|
|
local ch = channels[name]
|
|
if ch then return ch end
|
|
local items = {}
|
|
ch = { items = items }
|
|
function ch:push(v) items[#items + 1] = v end
|
|
function ch:pop() return table.remove(items, 1) end
|
|
function ch:clear() for i = #items, 1, -1 do items[i] = nil end end
|
|
function ch:getCount() return #items end
|
|
channels[name] = ch
|
|
return ch
|
|
end
|
|
love.thread = {
|
|
newThread = function()
|
|
return { start = function() end, getError = function() return nil end }
|
|
end,
|
|
getChannel = channel,
|
|
}
|
|
|
|
local ChipSynth = require("src.core.ChipSynth")
|
|
local ChipAsm = require("src.audio.ChipAsm")
|
|
local ChipAudio = require("src.core.ChipAudio")
|
|
local Sound = require("src.core.Sound")
|
|
local BattleState = require("src.battle.BattleState")
|
|
|
|
local function def(octave)
|
|
return ChipAsm.sfx{ channels = { { hw = 1, program = {
|
|
{ squareNote = { len = 4, volume = 15, fade = 1,
|
|
frequency = 0x400 + octave * 0x40 } },
|
|
} } } }
|
|
end
|
|
|
|
local function newData()
|
|
local d = {
|
|
audio = {
|
|
sfx = { Foo = def(5), Damage = def(4), Super_Effective = def(6),
|
|
Not_Very_Effective = def(3), Level_Up = def(2),
|
|
Bar = def(1) },
|
|
cries = { A = def(1), B = def(2), C = def(3), D = def(4), E = def(5) },
|
|
},
|
|
moves = {
|
|
TACKLE = { anim = { sound = "Foo", pitch = 0x20, tempo = 0x90 } },
|
|
PLAIN = { anim = { sound = "Bar" } },
|
|
},
|
|
}
|
|
return d
|
|
end
|
|
|
|
local function workerStep()
|
|
local cmdCh, fxCh = channel("chipaudio_cmd"), channel("chipaudio_fx")
|
|
local n = 0
|
|
while true do
|
|
local req = cmdCh:pop()
|
|
if not req then break end
|
|
if req.cmd == "effect" then
|
|
n = n + 1
|
|
fxCh:push({ key = req.key, epoch = req.epoch,
|
|
sd = ChipSynth.renderEffectData({ audio = req.audio }, req.header,
|
|
req.options or {}) })
|
|
end
|
|
end
|
|
return n
|
|
end
|
|
local function drain()
|
|
for _ = 1, 100 do
|
|
ChipAudio.pumpEffects()
|
|
if workerStep() == 0 then
|
|
ChipAudio.pumpEffects()
|
|
local st = ChipAudio._effectStateForTest()
|
|
if st.queued + st.inFlight == 0 then return end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---- move sounds: prewarm key == playMove's plain==0 key -------------------
|
|
do
|
|
local data = newData()
|
|
ChipAudio.resetSyncStats()
|
|
check(Sound.prewarmMove(data, data.moves.TACKLE.anim), "pitched move queued")
|
|
check(Sound.prewarmMove(data, data.moves.PLAIN.anim), "default-modifier move queued")
|
|
check(not Sound.prewarmMove(data, { sound = "Missing" }), "unknown sound declined")
|
|
check(not Sound.prewarmMove(data, nil), "no anim declined")
|
|
drain()
|
|
Sound.playMove(data, data.moves.TACKLE.anim)
|
|
Sound.playMove(data, data.moves.PLAIN.anim)
|
|
eq(ChipAudio.stats().syncRenders, 0, "prewarmed move plays render nothing")
|
|
eq(ChipAudio.stats().prewarmHits, 2, "both plays took prewarmed PCM")
|
|
end
|
|
|
|
-- ---- a play before the prewarm lands still gets its Source ------------------
|
|
do
|
|
local data = newData()
|
|
data.audio.sfx.Early = def(7)
|
|
ChipAudio.resetSyncStats()
|
|
check(Sound.prewarmSfx(data, "Early"), "queued")
|
|
local before = ChipAudio.stats().syncRenders
|
|
local src = Sound.play(data, "Early")
|
|
check(src ~= nil, "play before the prewarm completes returns a Source")
|
|
eq(ChipAudio.stats().syncRenders, before + 1, "rendered synchronously")
|
|
drain() -- the late result must not disturb the cached Source
|
|
eq(Sound.play(data, "Early"), src, "late result never replaces the cached Source")
|
|
end
|
|
|
|
-- ---- an already cached sound is never re-requested -------------------------
|
|
do
|
|
local data = newData()
|
|
data.audio.sfx.Cached = def(7)
|
|
local src = Sound.play(data, "Cached")
|
|
check(src ~= nil, "plays")
|
|
local st = ChipAudio._effectStateForTest()
|
|
check(Sound.prewarmSfx(data, "Cached"), "cached sfx reports true")
|
|
local st2 = ChipAudio._effectStateForTest()
|
|
eq(st2.queued + st2.inFlight + st2.ready, st.queued + st.inFlight + st.ready,
|
|
"and queues nothing")
|
|
end
|
|
|
|
-- ---- Gen 1 battle prewarm: whole parties, lead moves, hit sounds -----------
|
|
do
|
|
local data = newData()
|
|
local function mon(species, ...)
|
|
local moves = {}
|
|
for _, id in ipairs({ ... }) do moves[#moves + 1] = { id = id } end
|
|
return { species = species, moves = moves }
|
|
end
|
|
local enemyParty = { mon("A", "PLAIN"), mon("B", "TACKLE"), mon("C") }
|
|
local playerParty = { mon("D", "TACKLE", "PLAIN"), mon("E") }
|
|
local self = {
|
|
data = data,
|
|
enemy = { mon = enemyParty[1] }, player = { mon = playerParty[1] },
|
|
enemyParty = enemyParty,
|
|
playerPartyView = function() return playerParty end,
|
|
}
|
|
ChipAudio.resetSyncStats()
|
|
BattleState.prewarmCries(self)
|
|
drain()
|
|
for _, sp in ipairs({ "A", "B", "C", "D", "E" }) do
|
|
ChipAudio.newCry(data, sp, data.audio.cries[sp])
|
|
end
|
|
eq(ChipAudio.stats().syncRenders, 0, "enemy and player party cries prewarmed")
|
|
Sound.playMove(data, data.moves.TACKLE.anim)
|
|
Sound.playMove(data, data.moves.PLAIN.anim)
|
|
for _, anim in ipairs({ { sound = "Damage", pitch = 0x20 },
|
|
{ sound = "Super_Effective", pitch = 0xe0 },
|
|
{ sound = "Not_Very_Effective", pitch = 0x50 } }) do
|
|
Sound.playMove(data, anim)
|
|
end
|
|
Sound.play(data, "Level_Up")
|
|
eq(ChipAudio.stats().syncRenders, 0,
|
|
"lead moves, hit sounds and battle SFX prewarmed")
|
|
end
|
|
|
|
S.finish()
|