mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-29 07:00:55 -04:00
330 lines
11 KiB
Lua
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
|