Files
gen1recomp/tests/chip_synth_bulk_pcm_test.lua
T
Shane McGovern 795276935c Performance pass: cut per-frame allocations and draw calls, fix frame-rate and batch bugs
- Shared render: cache Font encode/width, skip unchanged palette/ShaderFX
  uniform sends, reuse quads and scratch tables, cheaper sprite palettes
- Mods: fast path for loaded requires, memoized sandbox verdicts, fewer
  Hooks:call allocations, drop emptied hook chains
- Audio: bulk PCM writes, cry/SFX prewarm on the worker, sliced sync
  fallback, cached Gen 3 SE sources
- Gen 1: per-frame SGB battle palettes, hoisted overworld FX, cached menus
- Gen 2: numeric attr grid, indexed anim cells, entity culling, memoized
  cache keys, palette shader set once per tile run
- Gen 3: reuse native tile batches, per-cell metatile rebuilds, partial
  FRLG tile anim uploads, near-view ghost ticking, packed FRLG font atlas
- Fixes: emergency quit timer, Gen 3 batch regression and stale sprite
  index, trainer card texture leak, missing pic re-reads, anim tint cache,
  Crystal ghost grass overdraw, refresh-rate dependent counters, pad swap
2026-09-30 14:53:18 +00:00

274 lines
9.5 KiB
Lua

-- ChipSynth bulk PCM writes (ffi int16 stores through Data:getFFIPointer)
-- must be bit-identical to the per-sample SoundData:setSample path they
-- replace, for streamed music buffers, SFX/cries (renderEffectData) and the
-- synchronous music fallback's sliced staging fill (ChipAudio fillSync).
-- luajit tests/chip_synth_bulk_pcm_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("chip synth bulk pcm")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local ffi = require("ffi")
-- ffi-backed SoundData. setSample reproduces LOVE 11.5's 16-bit store,
-- (int16)(value * 32767) in double precision with no clamp -- verified
-- against a real LOVE 11.5 SoundData over 2M values including
-- out-of-range and NaN. getFFIPointer exposes the same storage.
local FfiSoundData = {}
FfiSoundData.__index = FfiSoundData
function FfiSoundData: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 FfiSoundData:getSample(index, channel)
return self.buf[index * self.channels + ((channel or 1) - 1)] / 32767
end
function FfiSoundData:getFFIPointer() return self.buf end
function FfiSoundData:getSampleCount() return self.samples end
function FfiSoundData:getSampleRate() return self.rate end
function FfiSoundData:getBitDepth() return 16 end
function FfiSoundData:getChannelCount() return self.channels end
love.sound = love.sound or {}
love.sound.newSoundData = function(samples, rate, bits, channels)
local sd = setmetatable({
samples = samples, rate = rate, channels = channels,
buf = ffi.new("int16_t[?]", samples * channels),
}, FfiSoundData)
return sd
end
-- the conversion itself, including values the synth never produces
do
local a = love.sound.newSoundData(8, 44100, 16, 1)
local values = { 0, 1, -1, 1.5, -1.5, 0.99999, 1.5 / 32767, 0 / 0 }
local expect = { 0, 32767, -32767, -16386, 16386, 32766, 1, 0 }
for i, v in ipairs(values) do a:setSample(i - 1, v) end
for i = 1, #expect do
eq(a.buf[i - 1], expect[i], "LOVE 11.5 int16 store of " .. tostring(values[i]))
end
end
local ChipSynth = require("src.core.ChipSynth")
local ChipAsm = require("src.audio.ChipAsm")
ChipSynth.setChannelVolumes({ 1, 1, 1, 1 })
ChipSynth.setChannelPitches({ 1, 1, 1, 1 })
-- the fake SoundData really takes the bulk path (and the switch disables it)
check(ChipSynth._int16Pointer(love.sound.newSoundData(1, 44100, 16, 2)),
"bulk path active on an ffi SoundData")
ChipSynth._setBulkWritesForTest(false)
check(not ChipSynth._int16Pointer(love.sound.newSoundData(1, 44100, 16, 2)),
"bulk switch off falls back to setSample")
ChipSynth._setBulkWritesForTest(true)
local function same(a, b)
if a.samples ~= b.samples or a.channels ~= b.channels then
return false, "shape " .. a.samples .. "x" .. a.channels
.. " vs " .. b.samples .. "x" .. b.channels
end
local n = a.samples * a.channels
for i = 0, n - 1 do
if a.buf[i] ~= b.buf[i] then
return false, ("sample %d: %d vs %d"):format(i, a.buf[i], b.buf[i])
end
end
return true
end
local function nonzero(sd)
local count = 0
for i = 0, sd.samples * sd.channels - 1 do
if sd.buf[i] ~= 0 then count = count + 1 end
end
return count
end
local data = { audio = {} }
local song = ChipAsm.song{
tempo = 0x100,
channels = {
{ hw = 1, program = {
{ duty = 2 },
{ notetype = { speed = 12, volume = 12, fade = 0 } },
{ octave = 4 },
{ label = "body" },
{ note = "C", len = 8 }, { note = "E", len = 8 }, { note = "G", len = 4 },
{ loop = { count = 0, to = "body" } },
} },
{ hw = 2, program = {
{ duty = 1 },
{ notetype = { speed = 12, volume = 10, fade = 2 } },
{ octave = 3 },
{ label = "body" },
{ note = "G", len = 4 }, { note = "A", len = 12 },
{ loop = { count = 0, to = "body" } },
} },
{ hw = 4, program = {
{ label = "body" },
{ drum = 3, len = 4 },
{ loop = { count = 0, to = "body" } },
} },
},
drums = { [3] = { { len = 4, volume = 13, fade = 2, parameter = 0x42 } } },
}
local function render(bulk, fn)
ChipSynth._setBulkWritesForTest(bulk)
local ok, result = pcall(fn)
ChipSynth._setBulkWritesForTest(true)
assert(ok, result)
return result
end
-- streamed music buffers (worker + sync fallback), stereo and mono
for _, channels in ipairs({ 2, 1 }) do
local function music()
local engine = ChipSynth.newEngine(data, song, { allowLoops = true })
local first = ChipSynth.soundData(engine, 8192, channels)
local second = ChipSynth.soundData(engine, 8192, channels)
return { first, second }
end
local slow, fast = render(false, music), render(true, music)
for i = 1, 2 do
local ok, why = same(slow[i], fast[i])
check(ok, ("music buffer %d (%dch) bit-identical %s")
:format(i, channels, why or ""))
end
check(nonzero(fast[1]) > 1000, "music buffer carries audio")
end
-- sliced staging fill == one whole-buffer render
do
local whole = render(false, function()
local engine = ChipSynth.newEngine(data, song, { allowLoops = true })
ChipSynth.soundData(engine, 1000, 2)
return ChipSynth.soundData(engine, 8192, 2)
end)
local sliced = render(true, function()
local engine = ChipSynth.newEngine(data, song, { allowLoops = true })
ChipSynth.soundData(engine, 1000, 2)
local sd = ChipSynth.newBuffer(8192, 2)
local fill = 0
for _, n in ipairs({ 1024, 1024, 777, 1024, 3000, 1343 }) do
ChipSynth.renderInto(engine, sd, fill, n, 2)
fill = fill + n
end
assert(fill == 8192)
return sd
end)
local ok, why = same(whole, sliced)
check(ok, "sliced staging fill bit-identical " .. (why or ""))
end
-- SFX / cries
local sfx = ChipAsm.sfx{ channels = {
{ hw = 1, program = {
{ squareNote = { len = 8, volume = 15, fade = 1, frequency = 0x600 } },
{ squareNote = { len = 16, volume = 12, fade = 3, frequency = 0x700 } },
} },
{ hw = 4, program = {
{ noiseNote = { len = 16, volume = 14, fade = 2, parameter = 0x33 } },
} },
} }
for _, opts in ipairs({
function() return {} end,
function() return { frequencyOffset = 0x40, cryLength = 0x60 } end,
function() return { frequencyOffset = -0x20, frameTicks = 0xC0 } end,
}) do
local slow = render(false, function()
return ChipSynth.renderEffectData(data, sfx, opts())
end)
local fast = render(true, function()
return ChipSynth.renderEffectData(data, sfx, opts())
end)
check(slow and fast, "effect renders both ways")
if slow and fast then
local ok, why = same(slow, fast)
check(ok, "effect bit-identical " .. (why or ""))
check(nonzero(fast) > 100, "effect carries audio")
end
end
-- a second, longer effect after a short one: the reused scratch grows
do
local notes = {}
for i = 1, 24 do
notes[i] = { squareNote = { len = 16, volume = 15, fade = 0,
frequency = 0x600 + i * 8 } }
end
local long = ChipAsm.sfx{ channels = { { hw = 1, program = notes } } }
local slow = render(false, function()
return ChipSynth.renderEffectData(data, long, {})
end)
local fast = render(true, function()
return ChipSynth.renderEffectData(data, long, {})
end)
check(slow and fast and slow.samples > 44100,
"long effect renders past the initial scratch")
if slow and fast then
local ok, why = same(slow, fast)
check(ok, "long effect bit-identical " .. (why or ""))
end
end
-- synchronous music fallback: the sliced per-tick staging fill queues the
-- same 8192-sample buffers, in order, as whole-buffer renders did
do
love.thread = nil
local queued = {}
local source = { free = 32, playing = false }
function source:getFreeBufferCount() return self.free end
function source:queue(sd) self.free = self.free - 1; queued[#queued + 1] = sd end
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
love.audio = love.audio or {}
love.audio.newQueueableSource = function() return source end
local ChipAudio = require("src.core.ChipAudio")
ChipAudio.playMusic(data, song, true)
eq(#queued, 4, "song start queues the initial buffers")
local maxTick = 0
local consumed = 0
for _ = 1, 400 do
consumed = consumed + ChipSynth.SAMPLE_RATE / 60
while consumed >= 8192 and source.free < 32 do
consumed = consumed - 8192
source.free = source.free + 1
end
local before = #queued
ChipAudio.update()
maxTick = math.max(maxTick, #queued - before)
end
check(#queued > 40, "the queue keeps filling (" .. #queued .. " buffers)")
check(maxTick <= 1, "at most one buffer completes per tick")
local engine = ChipSynth.newEngine(data, song, { allowLoops = true })
local allSame = true
for i = 1, #queued do
local ref = ChipSynth.soundData(engine, 8192, 2)
local ok = same(ref, queued[i])
if not ok then allSame = false; break end
end
check(allSame, "sliced fallback stream bit-identical to whole buffers")
-- a starved queue (below the low-water mark) renders a whole buffer's
-- worth in one tick
source.free = 31
local before = #queued
ChipAudio.update()
check(#queued - before >= 1, "low queue catches up within one tick")
ChipAudio.stopMusic()
-- no worker: prewarm declines rather than rendering on this thread
local fx = { audio = { sfx = { Beep = sfx }, cries = {} } }
check(ChipAudio.prewarmSfx(fx, "Beep") == false,
"prewarm is a no-op without a worker")
eq(ChipAudio._effectStateForTest().pending, 0, "nothing left pending")
end
S.finish()