Files
gen1recomp/tests/drivers/em_rec.lua
T
bryanthaboi 690f0cd9a1 TOUCHDOWN
2026-09-27 16:07:31 -04:00

330 lines
11 KiB
Lua

local ffi = require("ffi")
local R = {
tick = 0,
armed = false,
frames = 0,
marks = {},
bgm = { song = nil, pos = 0 },
frameBgm = {},
instances = {},
sounds = {},
soundIndex = {},
calls = {},
}
local srcData = setmetatable({}, { __mode = "k" })
local active = setmetatable({}, { __mode = "k" })
local function Audio() return package.loaded["src.core.game3.audio"] end
local function rate()
local Mix = package.loaded["src.core.game3.m4a_mix"]
return (Mix and Mix.SAMPLE_RATE) or 44100
end
local function fmtArgs(...)
local out = {}
for i = 1, select("#", ...) do
local v = select(i, ...)
if type(v) == "table" then
local parts = {}
for k, x in pairs(v) do parts[#parts + 1] = tostring(k) .. "=" .. tostring(x) end
out[#out + 1] = "{" .. table.concat(parts, ",") .. "}"
else
out[#out + 1] = tostring(v)
end
end
return table.concat(out, " ")
end
local function relFrame() return R.tick - (R.armTick or 0) end
local function logCall(name, ...)
if not R.armed then return end
R.calls[#R.calls + 1] = string.format("%d %s %s", relFrame(), name, fmtArgs(...))
end
local function wrapAudio()
local A = require("src.core.game3.audio")
for _, name in ipairs({ "playMapSong", "fadeOutAndPlay", "fadeOutBgm", "fadeInBgm", "pauseBgm", "resumeBgm",
"playSe", "playFanfare", "playCry", "stopSe", "stopCry", "changeMusicTo", "changeMusicToDefault",
"mapLoadMusic", "restoreMapSong", "stopAll", "bikeMusic" }) do
local orig = A[name]
if type(orig) == "function" then
A[name] = function(...)
logCall(name, ...)
return orig(...)
end
end
end
local origPlay = A.playSong
A.playSong = function(id, ...)
logCall("playSong", id, ...)
local e0, l0 = A._bgmEpoch, A._bgmLocal
local res = origPlay(id, ...)
if (A._bgmEpoch ~= e0 or (A._bgmLocal and A._bgmLocal ~= l0)) and A._bgmGen ~= nil then
R.bgm.song = A._bgmGen
R.bgm.pos = 0
logCall("bgmStart", A._bgmGen)
end
return res
end
end
local function wrapSources()
local origNew = love.audio.newSource
love.audio.newSource = function(a, ...)
local src = origNew(a, ...)
if type(a) == "userdata" and a.typeOf and a:typeOf("SoundData") and src then
srcData[src] = a
end
return src
end
local probe = love.audio.newSource(love.sound.newSoundData(16, 44100, 16, 1), "static")
local mt = getmetatable(probe)
local methods = type(mt.__index) == "table" and mt.__index or mt
print("[rec] source methods table", tostring(type(mt.__index)), tostring(methods.play ~= nil))
local oPlay, oStop, oPause, oIsPlaying, oTell = methods.play, methods.stop, methods.pause, methods.isPlaying, methods.tell
local function virtualPlaying(self)
local inst = active[self]
if not inst then return nil end
if inst.loop then return true end
local n = inst.count - inst.pos
return (relFrame() - inst.frame) * inst.rate / 60 < n
end
methods.isPlaying = function(self, ...)
if R.armed then
local v = virtualPlaying(self)
if v ~= nil then return v end
end
return oIsPlaying(self, ...)
end
methods.play = function(self, ...)
local sd = srcData[self]
if sd and R.armed then
local was = virtualPlaying(self)
if was == nil then was = oIsPlaying(self) end
if not was then
local idx = R.soundIndex[sd]
if not idx then
R.sounds[#R.sounds + 1] = sd
idx = #R.sounds
R.soundIndex[sd] = idx
end
local inst = { snd = idx, frame = relFrame(), pos = oTell(self, "samples") or 0,
vol = self:getVolume(), loop = self:isLooping(), count = sd:getSampleCount(), rate = sd:getSampleRate() }
R.instances[#R.instances + 1] = inst
active[self] = inst
end
end
return oPlay(self, ...)
end
local function ender(orig)
return function(self, ...)
local inst = active[self]
if inst then
if virtualPlaying(self) then inst.stop = relFrame() end
active[self] = nil
end
return orig(self, ...)
end
end
methods.stop = ender(oStop)
methods.pause = ender(oPause)
end
local function patchTap()
local U = require("tests.drivers.util")
local origTap = U.tap
U.tap = function(game, btn)
if R.armed and btn == "a" and (R.textHold or 0) > 0 then
local M = package.loaded["src.ui.game3.message"]
local B = package.loaded["src.core.game3.battle"]
local BU = package.loaded["src.core.game3.battle.ui"]
if B and B.isActive() and BU and BU.dialogPending and BU.dialogPending() then
for _ = 1, 40 do
if M and M.isOpen() then break end
coroutine.yield()
end
end
for _ = 1, 900 do
if not (M and M.isOpen()) then break end
if M.isWaiting() and R.waitStart and R.tick - R.waitStart >= R.textHold then break end
coroutine.yield()
end
end
return origTap(game, btn)
end
end
function R.install(game, opts)
opts = opts or {}
R.textHold = opts.textHold or tonumber(os.getenv("EM_REC_TEXT_HOLD")) or 50
patchTap()
R.game = game
R.dir = opts.dir or ".bazinga/emerald/rec/tmp"
R.fast = opts.fast or 200
os.execute('mkdir -p "' .. R.dir .. '"')
wrapAudio()
wrapSources()
local origUpdate = game.update
game.update = function(self, dt)
origUpdate(self, dt)
R.onTick()
end
local Renderer = require("src.render.Renderer")
local origBegin = Renderer.beginWorldPass
Renderer.beginWorldPass = function(...)
R.worldThisFrame = true
return origBegin(...)
end
local origDraw = love.draw
love.draw = function(...)
origDraw(...)
R.onDraw()
end
game.driverSpeed = R.fast
end
function R.onTick()
R.tick = R.tick + 1
local A = Audio()
local playing = A and A._bgmGen ~= nil and not A._bgmPaused and R.bgm.song ~= nil
if A and A._bgmGen == nil then R.bgm.song = nil end
if R.armed then
local vol = 0
if A and A._bgmSource then vol = A._bgmSource:getVolume() end
R.frameBgm[relFrame()] = { song = playing and R.bgm.song or 0, pos = R.bgm.pos, vol = playing and vol or 0 }
R.pendingFrames = (R.pendingFrames or 0) + 1
end
if playing then R.bgm.pos = R.bgm.pos + rate() / 60 end
local M = package.loaded["src.ui.game3.message"]
local waiting = M and M.isWaiting() and (M._page or 0) or false
if waiting ~= R.waitKey then
R.waitKey = waiting
R.waitStart = waiting and R.tick or nil
end
if R.onTickHook then R.onTickHook() end
end
function R.arm(label)
if R.armed then return end
R.armed = true
R.armTick = R.tick
R.game.driverSpeed = 1
R.pendingFrames = 0
pcall(love.window.setVSync, 0)
R.video = io.popen(string.format(
'ffmpeg -loglevel error -y -f rawvideo -pix_fmt rgba -s 240x160 -r 60 -i - -c:v ffv1 "%s/frames.mkv"', R.dir), "w")
R.t0 = love.timer.getTime()
R.behind = 0
R.mark(label or "arm")
print("[rec] armed at tick " .. R.tick)
end
function R.mark(label)
R.marks[#R.marks + 1] = { frame = relFrame(), label = label }
print(string.format("[rec] mark %d %s", relFrame(), label))
end
function R.onDraw()
if not (R.armed and R.video) then return end
local n = R.pendingFrames or 0
if n <= 0 then return end
R.pendingFrames = 0
local Renderer = package.loaded["src.render.Renderer"]
if not R.cap then
R.cap = love.graphics.newCanvas(240, 160, { dpiscale = 1 })
R.cap:setFilter("nearest", "nearest")
end
love.graphics.push("all")
love.graphics.setCanvas(R.cap)
love.graphics.origin()
love.graphics.setBlendMode("alpha")
love.graphics.clear(0, 0, 0, 1)
love.graphics.setColor(1, 1, 1, 1)
local world = Renderer and Renderer.worldCanvas
if R.worldThisFrame and world then
love.graphics.draw(world, math.floor((240 - world:getWidth()) / 2), math.floor((160 - world:getHeight()) / 2))
end
if Renderer and Renderer.canvas then love.graphics.draw(Renderer.canvas, 0, 0) end
love.graphics.setCanvas()
love.graphics.pop()
R.worldThisFrame = false
local bytes = R.cap:newImageData():getString()
for _ = 1, n do
R.video:write(bytes)
R.frames = R.frames + 1
end
if R.frames % 600 == 0 then
print(string.format("[rec] %d frames, %.1f fps", R.frames, R.frames / (love.timer.getTime() - R.t0)))
end
end
local function writeFloat(path, L, R2, n, mono)
local buf = ffi.new("float[?]", n * 2)
for i = 1, n do
local l, r = L[i] or 0, R2[i] or 0
if mono then
local m = (l + r) * 0.5
l, r = m, m
end
buf[(i - 1) * 2] = l
buf[(i - 1) * 2 + 1] = r
end
local f = assert(io.open(path, "wb"))
f:write(ffi.string(buf, n * 8))
f:close()
end
function R.finish()
if not R.armed then return end
R.armed = false
if R.video then R.video:close() R.video = nil end
R.game.driverSpeed = R.fast
local A = require("src.core.game3.audio")
local Player = require("src.core.game3.m4a_player")
local sr = rate()
local need = {}
for f = 0, R.frames - 1 do
local b = R.frameBgm[f]
if b and b.song ~= 0 then
need[b.song] = math.max(need[b.song] or 0, b.pos + sr / 60 + 1)
end
end
local meta = assert(io.open(R.dir .. "/audio.txt", "w"))
meta:write(string.format("rate %d\nframes %d\nmono %s\nbehind %d\n", sr, R.frames, tostring(A._mono), R.behind or 0))
for id, n in pairs(need) do
local t0 = love.timer.getTime()
local L, Rr = Player.bakeSong(A._pack, A._cache, id, { raw = true, maxSec = n / sr + 0.5, sampleRate = sr })
writeFloat(string.format("%s/song_%d.f32", R.dir, id), L, Rr, #L, A._mono)
meta:write(string.format("song %d %d\n", id, #L))
print(string.format("[rec] baked song %d: %d samples (%.1fs) in %.1fs", id, #L, #L / sr, love.timer.getTime() - t0))
end
for i, sd in ipairs(R.sounds) do
local f = assert(io.open(string.format("%s/snd_%d.raw", R.dir, i), "wb"))
f:write(sd:getString())
f:close()
meta:write(string.format("snd %d %d %d %d %d\n", i, sd:getSampleRate(), sd:getBitDepth(), sd:getChannelCount(),
sd:getSampleCount()))
end
for _, inst in ipairs(R.instances) do
meta:write(string.format("inst %d %d %d %s %.4f %s\n", inst.snd, inst.frame, inst.pos, tostring(inst.stop or -1),
inst.vol, tostring(inst.loop)))
end
for f = 0, R.frames - 1 do
local b = R.frameBgm[f] or { song = 0, pos = 0, vol = 0 }
meta:write(string.format("bgm %d %d %d %.4f\n", f, b.song, math.floor(b.pos), b.vol))
end
for _, m in ipairs(R.marks) do meta:write(string.format("mark %d %s\n", m.frame, m.label)) end
meta:close()
local cf = assert(io.open(R.dir .. "/calls.txt", "w"))
cf:write(table.concat(R.calls, "\n"), "\n")
cf:close()
print(string.format("[rec] finished: %d frames, %d sounds, %d instances, behind %d", R.frames, #R.sounds,
#R.instances, R.behind or 0))
end
return R