From a4bbcc10ab43801997a1e67f2d956eb89ae1e2f9 Mon Sep 17 00:00:00 2001 From: laaledesiempre Date: Sun, 30 Aug 2026 20:56:51 -0300 Subject: [PATCH] fix(core): cut per-frame allocation churn and GC pressure on the main loop On weak single-core handhelds (RK3326/RG351MP) the main thread sits at 100% CPU, so every millisecond of jitter drops a frame: constant stuttering while walking even at PERFORMANCE LOW with all mods off, while average CPU usage stays unchanged. Three sources of per-frame garbage and pause on the hot path: - Game:step / Game:logicSpeed passed inline closures to ModRuntime.call, allocating a fresh function 60 times per second. Hoist both to module-level locals; behavior is identical. - Game:update advanced the incremental collector every rendered frame. Space it to every 4th frame: the explicit frees the comment refers to still do the heavy lifting, collection stays incremental, and the stepping itself stops competing with the 16.6ms frame budget. - checkEmergencyQuit called love.joystick.getJoysticks() (a fresh table) twice per frame to guard a 5-second hold combo. Cache the joystick list and refresh it once per second; a 1s hotplug delay is irrelevant against a 5s hold. Tested on an RG351MP (RK3326, dArkOS, v0.2.41): eliminates the constant walking stutter with all mods off, and with 11 mods re-enabled. Average CPU is unchanged (the cost was variance, not load), which matches the diagnosis. --- main.lua | 13 +++++++++++-- src/core/Game.lua | 25 +++++++++++++++++-------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/main.lua b/main.lua index 7e0d632f..7d9b2d94 100644 --- a/main.lua +++ b/main.lua @@ -34,12 +34,21 @@ end -- Global emergency quit: holding Start + Select for 5 seconds forcefully terminates LOVE. local emergencyQuitTimer = 0 +-- getJoysticks() allocates a fresh table every call; this runs once (twice) +-- per frame, so cache the list and refresh it once a second instead. The 5s +-- hold requirement makes a 1s hotplug delay irrelevant. +local cachedJoysticks = nil +local joystickCacheAge = 1 local function checkEmergencyQuit(dt) local held = false + joystickCacheAge = joystickCacheAge + (dt or 0.016) if love.joystick and love.joystick.getJoysticks then - local joysticks = love.joystick.getJoysticks() - for _, j in ipairs(joysticks) do + if not cachedJoysticks or joystickCacheAge >= 1 then + cachedJoysticks = love.joystick.getJoysticks() + joystickCacheAge = 0 + end + for _, j in ipairs(cachedJoysticks) do if j:isGamepad() then local start = j:isGamepadDown("start") local selectBtn = j:isGamepadDown("back") or j:isGamepadDown("guide") diff --git a/src/core/Game.lua b/src/core/Game.lua index afacc42e..8a755ef1 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -21,6 +21,12 @@ local function renderVisible(stack, state) return state and (not stack.renderVisible or stack:renderVisible(state)) end +-- Vanilla defaults for ModRuntime.call, hoisted to module level: called from +-- the 60Hz logic step / per-frame speed resolution, an inline closure here +-- allocated a fresh function every tick for no behavioral gain. +local function noop() end +local function resolveLogicSpeedVanilla(g) return g:_resolveLogicSpeed() end + -- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev -- module unloaded, so a player boot never touches a byte of dev code local devMode = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true @@ -304,7 +310,7 @@ function Game:step(dt) -- the same fixed-step boundary as a physical controller. Run them before -- Input:step promotes queued edges so a button chosen here is visible to -- this logic tick, not one tick later. With no wrapper this is a no-op. - ModRuntime.call("input.step", function() end, self, dt) + ModRuntime.call("input.step", noop, self, dt) self.input:step() -- A+B+SELECT+START held for 16 steps: SoftReset (home/init.asm) stops the -- audio, whites the palettes out and falls through into Init, i.e. the @@ -390,7 +396,7 @@ function Game:logicSpeed() -- returns a bad value, so an unclamped result would flow straight into -- the FixedStep accumulator math below and freeze or destabilize logic. return GameSpeed.clamp(ModRuntime.call("core.logic_speed", - function(g) return g:_resolveLogicSpeed() end, self)) + resolveLogicSpeedVanilla, self)) end function Game:update(dt) @@ -420,12 +426,15 @@ function Game:update(dt) pcall(function() require("src.core.DiscordPresence").update(dt) end) self:updateSync(dt) -- Steady-state memory backstop: advance the incremental collector one - -- small step every rendered frame. The heavy GPU objects are now freed - -- explicitly (map eviction, battle exit, canvas/renderer swaps), so this - -- only has to keep ordinary Lua-heap garbage (per-frame tables/closures) - -- from drifting upward over a long session, and to spread collection out - -- so the default lazy schedule never batches it into a visible pause. - if collectgarbage then collectgarbage("step", 1) end + -- small step every few rendered frames. The heavy GPU objects are now + -- freed explicitly (map eviction, battle exit, canvas/renderer swaps), so + -- this only has to keep ordinary Lua-heap garbage (per-frame tables) from + -- drifting upward over a long session, and to spread collection out so the + -- default lazy schedule never batches it into a visible pause. Every 4th + -- frame (not every frame) so the stepping itself does not compete with + -- the frame budget on weak single-core handhelds. + self.gcStepFrame = (self.gcStepFrame or 0) + 1 + if collectgarbage and self.gcStepFrame % 4 == 0 then collectgarbage("step", 1) end end -- render.zones' identity default: unhooked, the zone list reaches the blit