mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-02 07:50:13 -04:00
795276935c
- 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
274 lines
9.5 KiB
Lua
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()
|