Files
gen1recomp/tests/modkit/cases/require_fast_path.lua
T
Shane McGovern 7e2307f58c Use spare CPU cores to remove in-game hitches on low-power devices (#2872)
* 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
2026-10-12 08:38:45 +01:00

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")