mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-28 15:37:04 -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
216 lines
8.9 KiB
Lua
216 lines
8.9 KiB
Lua
-- T4: the require shim's fast path (already-loaded, undeniable, unserved
|
|
-- module, no owner) is outcome-identical to the slow path.
|
|
--
|
|
-- The shim is a security backstop (Sandbox.moduleDenial, the cross-generation
|
|
-- denial, the Gen 2/3 compat facades, scanRequire's warnings), and the fast
|
|
-- path skips all of it, so every cell of name x caller x owner x generation x
|
|
-- dev is run twice -- forced slow, then fast -- and the returned value, the
|
|
-- error text and every log line must match.
|
|
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local T = require("tests.modkit")
|
|
local Loader = require("src.mods.Loader")
|
|
local Runtime = require("src.mods.Runtime")
|
|
local Logger = require("src.core.Logger")
|
|
|
|
local function manifest(id, permissions)
|
|
return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua",'
|
|
.. '"api":2,"games":["all"]%s}'):format(id, id, permissions or "")
|
|
end
|
|
|
|
local FILES = {
|
|
["mods/fp_plain/manifest.json"] = manifest("fp_plain"),
|
|
["mods/fp_plain/main.lua"] = "local mod = ...",
|
|
["mods/fp_net/manifest.json"] =
|
|
manifest("fp_net", ',"permissions":["network","engine_internals"]'),
|
|
["mods/fp_net/main.lua"] = "local mod = ...",
|
|
}
|
|
|
|
-- a chunk whose name is inside / outside the engine tree, the only thing the
|
|
-- shim's callerIsMod looks at
|
|
local source = debug.getinfo(Loader._installDevShim, "S").source
|
|
local engineName = source:gsub("mods[/\\]Loader%.lua$", "fp_engine_probe.lua")
|
|
local modName = "@mods/fp_mod/main.lua"
|
|
local CODE = "local name = ...; local r = require(name); return r"
|
|
local engineChunk = assert(loadstring(CODE, engineName))
|
|
local modChunk = assert(loadstring(CODE, modName))
|
|
|
|
-- deterministic stand-ins so no case depends on what happens to be loaded
|
|
local STUBS = {
|
|
["src.core.Logger"] = false, -- real, already loaded
|
|
["src.core.Game"] = {}, -- Gen 1-only; served by the Gen 2/3 compat arms
|
|
["src.battle.gen2.Mon"] = {}, -- cross-generation
|
|
["src.fp.Plain"] = {}, -- an ordinary engine module
|
|
["socket"] = {}, -- network permission
|
|
["fp.Throws"] = false, -- preloaded below; its loader throws
|
|
["jit.util"] = {}, -- denied prefix
|
|
["love.filesystem"] = {}, -- denied prefix
|
|
}
|
|
local saved = {}
|
|
for name, stub in pairs(STUBS) do
|
|
saved[name] = package.loaded[name]
|
|
if stub then package.loaded[name] = stub end
|
|
end
|
|
|
|
local NAMES = {
|
|
"src.core.Logger", "src.core.Game", "src.battle.gen2.Mon", "src.fp.Plain",
|
|
"socket", "jit.util", "love.filesystem", "io", "os", "debug", "package",
|
|
"ffi", "src.fp.NeverLoaded", "fp.Throws", 42,
|
|
}
|
|
|
|
-- a loader that throws: LuaJIT leaves a non-module placeholder in
|
|
-- package.loaded, and the next require must error, not hand it back
|
|
local function armThrower()
|
|
package.loaded["fp.Throws"] = nil
|
|
package.preload["fp.Throws"] = function() error("fp.Throws loader failed", 0) end
|
|
end
|
|
pcall(require, "fp.Throws")
|
|
|
|
local function capture()
|
|
local log = {}
|
|
local real = { warn = Logger.warn, info = Logger.info, error = Logger.error }
|
|
for level in pairs(real) do
|
|
Logger[level] = function(fmt, ...)
|
|
log[#log + 1] = level .. ":" .. tostring(fmt):format(...)
|
|
end
|
|
end
|
|
return log, function()
|
|
for level, fn in pairs(real) do Logger[level] = fn end
|
|
end
|
|
end
|
|
|
|
local function outcome(chunk, name, owner)
|
|
Loader._resetShimWarned()
|
|
local previousCurrent, previousRequire = Runtime.currentMod, Runtime.modRequire
|
|
if owner.current ~= nil then Runtime.currentMod = owner.current end
|
|
if owner.require ~= nil then Runtime.modRequire = owner.require end
|
|
local log, restore = capture()
|
|
local ok, value = pcall(chunk, name)
|
|
restore()
|
|
Runtime.currentMod, Runtime.modRequire = previousCurrent, previousRequire
|
|
return { ok = ok, value = value, log = table.concat(log, "\n") }
|
|
end
|
|
|
|
local OWNERS = {
|
|
{ label = "no owner" },
|
|
{ label = "currentMod plain", current = "fp_plain" },
|
|
{ label = "currentMod net", current = "fp_net" },
|
|
{ label = "modRequire plain", require = "fp_plain" },
|
|
{ label = "modRequire true", require = true },
|
|
}
|
|
local CALLERS = {
|
|
{ label = "engine chunk", chunk = engineChunk },
|
|
{ label = "mod chunk", chunk = modChunk },
|
|
}
|
|
|
|
local cells, fastTaken = 0, 0
|
|
local hitCount = 0
|
|
local function onHit() hitCount = hitCount + 1 end
|
|
for _, generation in ipairs({ 1, 2, 3 }) do
|
|
for _, dev in ipairs({ false, true }) do
|
|
local run = T.sdk.loadMods({ "mods/fp_plain", "mods/fp_net" },
|
|
{ fs = T.sdk.memfs(FILES), generation = generation, dev = dev,
|
|
data = generation == 3 and T.sdk.gen3Data() or {} })
|
|
for _, owner in ipairs(OWNERS) do
|
|
for _, caller in ipairs(CALLERS) do
|
|
for _, name in ipairs(NAMES) do
|
|
local label = ("gen%d dev=%s %s / %s / %s"):format(generation,
|
|
tostring(dev), caller.label, owner.label, tostring(name))
|
|
local thrower = name == "fp.Throws"
|
|
if thrower then armThrower(); pcall(require, name) end
|
|
Loader._setFastRequire(false)
|
|
local slow = outcome(caller.chunk, name, owner)
|
|
if thrower then armThrower(); pcall(require, name) end
|
|
Loader._setFastRequire(true, onHit)
|
|
local hitsBefore = hitCount
|
|
local fast = outcome(caller.chunk, name, owner)
|
|
Loader._setFastRequire(true)
|
|
if hitCount > hitsBefore then
|
|
fastTaken = fastTaken + 1
|
|
T.check(not owner.current and not owner.require and fast.ok,
|
|
label .. ": fast return only for a clean no-owner success")
|
|
T.eq(fast.value, package.loaded[name], label .. ": fast value is the loaded one")
|
|
end
|
|
if thrower then
|
|
T.check(not fast.ok, label .. ": a failed-load placeholder is never returned")
|
|
end
|
|
cells = cells + 1
|
|
T.eq(fast.ok, slow.ok, label .. ": ok")
|
|
T.eq(fast.value, slow.value, label .. ": value / error text")
|
|
T.eq(fast.log, slow.log, label .. ": log lines")
|
|
end
|
|
end
|
|
end
|
|
run.release()
|
|
end
|
|
end
|
|
T.check(cells > 500, "the matrix ran (" .. cells .. " cells)")
|
|
T.check(fastTaken > 20, "and the fast return actually ran (" .. fastTaken .. ")")
|
|
|
|
-- ------- the cases that must stay denied / served on the fast path
|
|
|
|
local run = T.sdk.loadMods({ "mods/fp_plain", "mods/fp_net" },
|
|
{ fs = T.sdk.memfs(FILES), data = {}, generation = 2 })
|
|
Loader._setFastRequire(true)
|
|
T.check(not outcome(modChunk, "io", {}).ok, "io stays denied to a mod chunk")
|
|
T.check(outcome(engineChunk, "io", {}).ok, "and stays available to the engine")
|
|
T.check(not outcome(modChunk, "socket", {}).ok,
|
|
"socket stays denied to a mod with no network permission")
|
|
T.check(outcome(modChunk, "src.fp.Plain", {}).value == STUBS["src.fp.Plain"],
|
|
"an ordinary loaded module is returned as-is")
|
|
local gameForMod = outcome(modChunk, "src.core.Game", {}).value
|
|
T.check(gameForMod ~= STUBS["src.core.Game"],
|
|
"a Gen 1 module under Gen 2 is answered by the compat adapter for a mod")
|
|
T.eq(outcome(engineChunk, "src.core.Game", {}).value, STUBS["src.core.Game"],
|
|
"while the engine keeps the real module")
|
|
|
|
-- ------- memo behaviour
|
|
|
|
Loader._setFastRequire(true)
|
|
T.eq(next(Loader._fastRequireMemo() or {}), nil, "forcing the toggle clears the memo")
|
|
outcome(engineChunk, "src.fp.Plain", {})
|
|
T.eq(Loader._fastRequireMemo()["src.fp.Plain"], true, "an undeniable name is memoized")
|
|
outcome(engineChunk, "io", {})
|
|
T.eq(Loader._fastRequireMemo()["io"], false, "a denied name is memoized as ineligible")
|
|
outcome(engineChunk, "src.core.Game", {})
|
|
T.eq(Loader._fastRequireMemo()["src.core.Game"], false,
|
|
"a compat-served name is ineligible under Gen 2")
|
|
|
|
-- re-installing (a second loader) drops the memo
|
|
local run2 = T.sdk.loadMods({ "mods/fp_plain" },
|
|
{ fs = T.sdk.memfs(FILES), data = {}, generation = 2 })
|
|
T.eq(next(Loader._fastRequireMemo() or {}), nil, "a fresh load drops the memo")
|
|
run2.release()
|
|
|
|
-- the memo is per generation: Game is served under 2, plain under 1
|
|
outcome(engineChunk, "src.core.Game", {})
|
|
local run3 = T.sdk.loadMods({ "mods/fp_plain" },
|
|
{ fs = T.sdk.memfs(FILES), data = {}, generation = 1 })
|
|
outcome(engineChunk, "src.core.Game", {})
|
|
T.eq(Loader._fastRequireMemo()["src.core.Game"], true,
|
|
"src.core.Game is eligible on a Gen 1 game")
|
|
run3.release()
|
|
|
|
-- endSession drops it too
|
|
Loader.endSession()
|
|
T.eq(next(Loader._fastRequireMemo() or {}), nil, "endSession drops the memo")
|
|
|
|
-- unloading is live: the memo never caches the loaded value itself
|
|
local run4 = T.sdk.loadMods({ "mods/fp_plain" },
|
|
{ fs = T.sdk.memfs(FILES), data = {}, generation = 1 })
|
|
package.loaded["src.fp.Plain"] = { replaced = true }
|
|
T.eq(outcome(engineChunk, "src.fp.Plain", {}).value, package.loaded["src.fp.Plain"],
|
|
"a replaced package.loaded entry is returned, not a stale one")
|
|
package.loaded["src.fp.Plain"] = nil
|
|
T.check(not outcome(engineChunk, "src.fp.Plain", {}).ok,
|
|
"an unloaded module takes the slow path and fails like require does")
|
|
run4.release()
|
|
|
|
Loader._setFastRequire(true)
|
|
for name, was in pairs(saved) do package.loaded[name] = was end
|
|
package.loaded["src.fp.Plain"] = nil
|
|
package.loaded["fp.Throws"], package.preload["fp.Throws"] = nil, nil
|
|
|
|
T.finish("require fast path")
|