Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5507ef2
perf(P1): skip redundant software pacing after VSync presentation
Roxas2712 Oct 26, 2026
65c3087
perf(P2): tune incremental GC and spend bounded idle deadline time
Roxas2712 Oct 26, 2026
0956e2e
Respect already-loaded native GC delegation without loading a provider
Roxas2712 Oct 26, 2026
cb62f94
Validate measured source fingerprints before and after native probes
Roxas2712 Oct 26, 2026
f0d4234
perf(P3): cache hot dependencies and retain private render scratch
Roxas2712 Oct 26, 2026
1c78d10
Merge remote-tracking branch 'upstream/main' into codex/upstream-perf-p3
Roxas2712 Oct 26, 2026
56a6948
Cover current native draw imports and refresh upstream qualification
Roxas2712 Oct 26, 2026
561ad27
Merge current upstream native fixes while preserving programme interf…
Roxas2712 Oct 26, 2026
059074d
test: align updater fixtures with Gen2 release assets
Roxas2712 Oct 22, 2026
11173c7
test: cover twelve-action rebinding without fixed row indices
Roxas2712 Oct 22, 2026
fb1b2c2
test: exercise profiled Game methods with complete fixtures
Roxas2712 Oct 22, 2026
a87a157
test: execute quit-event veto and Android shutdown paths
Roxas2712 Oct 22, 2026
d926ab8
test: pin continuous wide-battle animation anchor mapping
Roxas2712 Oct 22, 2026
0bb1fb8
test: audit and pin the current Gen2 link fingerprint surface
Roxas2712 Oct 22, 2026
ae84a25
test: cover six native producers through the public mod API
Roxas2712 Oct 22, 2026
5ea8292
Translate new transfer, hatch and athlete fallback messages at displa…
Roxas2712 Oct 26, 2026
97dfceb
Audit current native effect registrations and register cure text sources
Roxas2712 Oct 26, 2026
7e3d306
Verify current native Gen4 fog and poison-step band presentation
Roxas2712 Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/frame-pacing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# P1: VSync and the software deadline

The run loop records whether it actually presented a frame. After presentation,
`FrameCap.pace` skips the software wait when VSync is active and the FPS cap is
at least the reported display refresh rate. Without presentation, with VSync
off, with an unknown rate, or with a lower FPS cap, the existing carried
deadline, one-millisecond sleep chunks and stall re-anchor remain active.

The default-on rollback switch is independent of all other performance work:

```lua
require("src.core.FrameCap").vsyncPacing = false
```

Display flags are sampled at most once per second to avoid allocating their
table each frame. Provider/VSync changes invalidate that sample; other display
rate changes are observed on the next periodic sample. Existing option values,
simulation timing, mod interfaces and callback call sites are unchanged.

`frame pacing: sleep` in the existing F11 FrameProfile measures the helper,
including its software wait. `present (GPU wait / vsync)` retains its original
scope. Removing a redundant software wait can move wall time into present
without changing FPS.

The owned-window probe runs the real helper and FrameProfile with the switch
off/on, each after 120 warm-up frames and over 120 measured frames. Run through
the guarded launcher:

```sh
python3 tools/check_frame_pacing.py --love /path/to/love --report /path/to/owned/qa/report.json
```

An older public checkout can use `--qa-tools /path/to/fork/tools`. The launcher
pins inputs, uses a separate QA identity, retains raw profile logs and records
SHA-256 hashes of the actual measured source files alongside the Git parent.

Observed macOS/arm64, 60-Hz display, VSync 1, cap 60:

| Backend | Software wait off/on, ms/frame | Present off/on, ms/frame | CPU off/on, ms/frame |
| --- | ---: | ---: | ---: |
| LÖVE 11.5 OpenGL | 0.00 / 0.01 | 16.59 / 16.54 | 0.672 / 0.790 |
| LÖVE 12 Metal | 16.43 / 0.01 | 0.13 / 16.56 | 0.769 / 0.629 |

Both enabled runs made zero extra sleep calls and stayed near 60 FPS, with no
measured interval exceeding 1.5 display periods. These short blank-window
checks establish the pacing decision and its profiler scope. They do not
establish a universal CPU/FPS gain, loaded-world behavior, or handheld results.
51 changes: 51 additions & 0 deletions docs/gc-frame-budget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# P2: incremental GC in unused deadline time

`Performance.gcBudget` defaults to `true`. At boot the collector uses pause 120
and step multiplier 300. Each concrete `Performance.CAPS` row additionally has
`gcPause`, `gcStepMul` and `gcBudgetSeconds`; tiers may reduce the default
0.0005-second idle allowance. No options are rewritten.

The ordinary engine's fixed `step(1)` moves out of `Game:update`. The run loop
uses no-argument incremental steps before its remaining software wait. It starts
no further step after the earlier of the actual deadline and the half-millisecond
allowance, and stops on completion of a cycle. An expired deadline or a stopped
collector receives no work. VSync never gets an invented future budget; only
the actual remainder of its carried deadline is eligible, and no extra sleep
is added after VSync.

The budget is cooperative: a single indivisible collector step can finish past
the boundary. It is not a preemption guarantee for an arbitrary heap/finalizer.
The F11 child section `frame pacing: gc budget` is nested in the existing
`frame pacing: sleep` section, so the parent includes collection plus waiting.

The fork's pre-existing Gen3 `Game3.stepGC` delegation stays at its original
update boundary with the original arguments/behavior. A frame owned by that
public seam gets no second idle GC service. Its delegated/custom work remains
outside the engine idle allowance. This preserves consumers that replace it.

The independent rollback switch is:

```lua
require("src.core.Performance").gcBudget = false
```

The next frame restores the previous pause/multiplier and uses the old native
update step. The separate P1 switch and callback interfaces are unchanged.

`tools/check_frame_pacing.py --gc --love /path/to/love --report /owned/qa/gc.json`
runs the guarded native comparison. P1 stays enabled for both phases; only P2
changes. The workload allocates 512 short tables per frame, with 120 warm-up
and 120 measured frames per phase. Raw FrameProfile logs, original tuning,
heap samples and measured-source SHA-256 hashes are retained.

Observed composed fork, macOS/arm64, 60 Hz, VSync 1, cap 60:

| Backend | Original fixed step, ms/frame | New idle mean / max, ms | Heap peak before / after, KiB |
| --- | ---: | ---: | ---: |
| LÖVE 11.5 OpenGL | 0.00 (rounded) | 0.098 / 0.499 | 918 / 775 |
| LÖVE 12 Metal | 0.00 (rounded) | 0.120 / 0.278 | 1,179 / 692 |

Short-workload CPU means were 1.012 → 0.920 ms/frame and 0.717 → 0.651 ms/frame,
respectively. Both retained approximately 60 FPS and restored the original
200/200 tuning when disabled. These are bounded synthetic allocation checks,
not full-game heap, long-session, arbitrary-finalizer or handheld measurements.
51 changes: 51 additions & 0 deletions docs/hot-path-dispatch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# P3: native update/draw dependency dispatch

`Performance.hotPathHoist` defaults to `true`. Set it to `false` to restore
per-call require/pcall dispatch, the per-frame blit closure and fresh native
presentation records independently of P1/P2.

`HotModules` lazily retains successful module values. Live fields and explicit
replacement or clearing of `package.loaded` remain observable; failed optional
loads are not cached. The Discord update uses its existing protected call with
the original `dt`, without allocating a wrapper closure after initialization.
No module path, hook argument or hook ordering changes.

The renderer's blit adapter carries DPI as explicit arguments and still calls
the public `blitCanvas` method. Its existing `uiPresentation` field becomes an
engine-owned live view. A table installed there by a consumer is never mutated
by the reuse path.

Only the unwrapped native renderer may borrow the private viewport scratch.
Public `endFrame` calls always return detached records, and replacing that
method bypasses the private shortcut. HUD hook arguments are detached at the
moment of dispatch, including a hook registered during compose/output. The
fork's window UI also retains its public record path. Scratch is never included
in a hook record.

Run `tools/check_frame_pacing.py --hot-path --love /path/to/love --report
/owned/qa/hot.json` through the QA storage guard. The owned synthetic state draws
a rectangle through real `Game:draw`, canvases and composition. Each switch
phase has 120 warm-up and 120 measured frames at 60 Hz, VSync 1, P1 enabled.
The log contains the actual F11 `draw: whole frame` section; raw JSON also records
total draw wall time, process CPU, source fingerprints and an untimed require
counter. This is not a complete overworld, battle or mod scene. Profiling is on
in both measured phases.

Measured on macOS arm64 (milliseconds/frame, switch off → on):

| Tree / backend | F11 draw (rounded) | Raw draw mean | Process CPU | require calls |
| --- | ---: | ---: | ---: | ---: |
| Fork / LÖVE 11.5 OpenGL | 0.16 → 0.21 | 0.166 → 0.218 | 0.787 → 0.919 | 15 → 6 |
| Fork / LÖVE 12 Metal | 16.36 → 16.38 | 16.373 → 16.383 | 1.134 → 1.026 | 15 → 6 |
| Public adaptation on ed5189c2 / LÖVE 11.5 OpenGL | 0.21 → 0.21 | 0.223 → 0.215 | 0.952 → 0.936 | 14 → 5 |
| Public adaptation on ed5189c2 / LÖVE 12 Metal | 16.44 → 16.44 | 16.455 → 16.449 | 0.844 → 0.831 | 14 → 5 |

Queue submission can absorb GPU/VSync waiting in Metal; these are wall-time
sections, not isolated GPU durations. The short runs show reduced dependency
dispatch and known allocations, with mixed CPU/timing results. They do not
establish a general frame-rate or CPU improvement. Dedicated ARM handheld,
Android, long-session and full-game measurements: not measured.

Regression checks cover cached dependency identity/replacement/retry, detached
public and late HUD records, method wrappers, DPI forwarding and private native
reuse. Existing output/hook tests and the compatibility acceptance are retained.
27 changes: 9 additions & 18 deletions main.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1221,6 +1221,9 @@ end

function love.run()
BootTrace.mark("love.run enter")
local Performance = require("src.core.Performance")
local GCFrameBudget = require("src.core.GCFrameBudget")
GCFrameBudget.configure(Performance)
if love.load then love.load(love.arg.parseGameArguments(arg), arg) end
BootTrace.mark("love.load returned")

Expand Down Expand Up @@ -1258,6 +1261,7 @@ function love.run()

-- update dt
if love.timer then dt = love.timer.step() end
GCFrameBudget.beginFrame(Performance)

-- call update and draw
--
Expand All @@ -1274,12 +1278,14 @@ function love.run()
closeU()
end

local presented = false
if love.graphics and love.graphics.isActive() then
love.graphics.origin()
love.graphics.clear(love.graphics.getBackgroundColor())
if love.draw then love.draw() end
local closeP = FrameProfile.section("present (GPU wait / vsync)")
love.graphics.present()
presented = true
closeP()
end
-- Counted after present, so a frame in the trace means a frame that was
Expand Down Expand Up @@ -1312,24 +1318,9 @@ function love.run()

if love.timer then
if paced then
-- Sleep out the remainder of the frame budget, measured from the
-- carried deadline, in small chunks so the OS timer stays
-- responsive. vsync is untouched: when it already paces slower
-- than the cap the remainder is <= 0 and this rounds to a no-op.
local budget = 1 / FrameCap.current
nextFrame = nextFrame + budget
local now = love.timer.getTime()
-- A stall (alt-tab, a GC pause, a blocked import) can leave the
-- deadline more than a full budget in the past; re-anchor to now so
-- we pace the next frame rather than burst uncapped to catch up.
if now - nextFrame > budget then
nextFrame = now
end
while true do
local remaining = nextFrame - love.timer.getTime()
if remaining <= 0 then break end
love.timer.sleep(remaining < 0.001 and remaining or 0.001)
end
local closeSleep = FrameProfile.section("frame pacing: sleep")
nextFrame = FrameCap.pace(love.timer, love.window, presented, nextFrame, GCFrameBudget.idle)
closeSleep()
else
love.timer.sleep(0.001)
end
Expand Down
10 changes: 7 additions & 3 deletions src/battle/Gen4MoveEffects.lua
Original file line number Diff line number Diff line change
Expand Up @@ -509,9 +509,13 @@ F.REMOVE_PROTECT = {
local FLAVOR_STAT = { [11] = "attack", [12] = "spatk", [13] = "speed", [14] = "spdef", [15] = "defense" }
local STAT_UP = { [16] = "attack", [17] = "defense", [18] = "speed", [19] = "spatk", [20] = "spdef" }
local CURE = { [1] = "PAR", [2] = "SLP", [3] = "PSN", [4] = "BRN", [5] = "FRZ" }
local CURE_TEXT = { PAR = "%s was\ncured of paralysis!", SLP = "%s woke up!",
PSN = "%s was\ncured of poisoning!", BRN = "%s's\nburn was healed!",
FRZ = "%s thawed out!" }
local CURE_TEXT = {
PAR = Strings.source("%s was\ncured of paralysis!"),
SLP = Strings.source("%s woke up!"),
PSN = Strings.source("%s was\ncured of poisoning!"),
BRN = Strings.source("%s's\nburn was healed!"),
FRZ = Strings.source("%s thawed out!"),
}

local function eat(battle, eater, effect, param, def, out)
local max = maxHp(eater)
Expand Down
49 changes: 49 additions & 0 deletions src/core/FrameCap.lua
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,55 @@ FrameCap.DEFAULT = 60
-- save editor are paced before any save applies its stored option.
FrameCap.current = FrameCap.DEFAULT

-- P1 rollback switch. Each frame still falls back to software pacing when
-- there was no present, VSync is off, or the display rate is unavailable.
FrameCap.vsyncPacing = true
local _refreshWindow, _refreshVsync, _refreshAt, _refreshRate
local REFRESH_POLL_SECONDS = 1

local function presentationPaces(window, now)
if not (window and window.getVSync and window.getMode) then return false end
local vsync = window.getVSync()
if type(vsync) ~= "number" or vsync == 0 then
_refreshVsync = vsync
return false
end
-- getMode returns a new flags table: sample it once a second rather than
-- allocate one each frame. Provider/VSync changes invalidate the sample;
-- display-rate changes are observed on the next periodic sample.
if window ~= _refreshWindow or vsync ~= _refreshVsync or not _refreshAt
or now < _refreshAt or now - _refreshAt >= REFRESH_POLL_SECONDS then
local _, _, flags = window.getMode()
local rate = type(flags) == "table" and tonumber(flags.refreshrate)
_refreshWindow, _refreshVsync, _refreshAt = window, vsync, now
_refreshRate = rate and rate > 0 and rate < math.huge and rate or nil
end
return _refreshRate ~= nil and FrameCap.current >= _refreshRate
end

-- The run loop owns the deadline and passes back the returned value. Keeping
-- the original fallback here lets tests exercise the real pacing operation.
function FrameCap.pace(timer, window, presented, nextFrame, idleWork)
local now = timer.getTime()
local budget = 1 / FrameCap.current
nextFrame = nextFrame + budget
if FrameCap.vsyncPacing and presented and presentationPaces(window, now) then
if idleWork then
idleWork(timer, nextFrame)
return timer.getTime()
end
return now
end
if now - nextFrame > budget then nextFrame = now end
if idleWork then idleWork(timer, nextFrame) end
while true do
local remaining = nextFrame - timer.getTime()
if remaining <= 0 then break end
timer.sleep(remaining < 0.001 and remaining or 0.001)
end
return nextFrame
end

-- Nearest valid step for an arbitrary value (a hand-edited options.lua or
-- an old save with no fpsCap key), so a bad number degrades to something
-- sane; nil / non-numbers fall back to the default. A value below MIN or
Expand Down
86 changes: 86 additions & 0 deletions src/core/GCFrameBudget.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
-- Cooperative incremental collection in otherwise unused frame time.
local Profile = require("src.core.FrameProfile")
local Budget = {}
local MAX_SECONDS = 0.0005
local _collector, _priorPause, _priorMul, _pause, _mul
local _enabled, _seconds, _canQueryRunning, _facadeStepped = false, 0, false, false

local function nonnegative(value, fallback)
if type(value) ~= "number" or value ~= value or value < 0 or value == math.huge then
return fallback
end
return value
end

local function restoreTuning()
if _collector and _priorPause ~= nil then
_collector("setpause", _priorPause)
_collector("setstepmul", _priorMul)
end
_collector, _priorPause, _priorMul, _pause, _mul = nil, nil, nil, nil, nil
end

function Budget.configure(performance, collector)
collector = collector or collectgarbage
if not performance.gcBudget or type(collector) ~= "function" then
restoreTuning()
_enabled = false
return
end
if _collector ~= collector then
restoreTuning()
end
local caps = performance.CAPS[performance.tier] or {}
local pause = math.floor(nonnegative(caps.gcPause, performance.GC_PAUSE))
local mul = math.floor(nonnegative(caps.gcStepMul, performance.GC_STEP_MUL))
if not _collector then
_collector = collector
_priorPause = collector("setpause", pause)
_priorMul = collector("setstepmul", mul)
_canQueryRunning = pcall(collector, "isrunning")
else
if pause ~= _pause then
collector("setpause", pause)
end
if mul ~= _mul then
collector("setstepmul", mul)
end
end
_pause, _mul = pause, mul
_seconds = math.min(MAX_SECONDS, nonnegative(caps.gcBudgetSeconds, performance.GC_BUDGET_SECONDS))
_enabled = true
end

function Budget.beginFrame(performance, collector)
_facadeStepped = false
Budget.configure(performance, collector)
end

-- An existing public compatibility method retains ownership of its frame.
-- Its call, arguments and return behavior are not replaced by idle collection.
function Budget._facadeHandled()
_facadeStepped = true
end

function Budget.idle(timer, deadline)
if not _enabled or _facadeStepped or _seconds <= 0 then
return 0
end
local start = timer.getTime()
local stop = math.min(deadline, start + _seconds)
if stop <= start or (_canQueryRunning and not _collector("isrunning")) then
return 0
end
local close = Profile.section(" frame pacing: gc budget")
local steps = 0
while timer.getTime() < stop do
steps = steps + 1
if _collector("step") then
break
end
end
close()
return steps
end

return Budget
Loading