Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
01b6b24
perf(A1): instrument world, presentation and Nitro audio phases
Roxas2712 Oct 25, 2026
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
af00e0f
Merge commit '01b6b248' into codex/upstream-perf-p4
Roxas2712 Oct 26, 2026
391dc76
perf(P4): hoist world draw bodies and reuse private cover lists
Roxas2712 Oct 26, 2026
c925f2c
Merge current upstream native fixes while preserving programme interf…
Roxas2712 Oct 26, 2026
4e5e468
test: align updater fixtures with Gen2 release assets
Roxas2712 Oct 22, 2026
a65ec12
test: cover twelve-action rebinding without fixed row indices
Roxas2712 Oct 22, 2026
f24df70
test: exercise profiled Game methods with complete fixtures
Roxas2712 Oct 22, 2026
570d7e1
test: execute quit-event veto and Android shutdown paths
Roxas2712 Oct 22, 2026
8419261
test: pin continuous wide-battle animation anchor mapping
Roxas2712 Oct 22, 2026
281823d
test: audit and pin the current Gen2 link fingerprint surface
Roxas2712 Oct 22, 2026
d29d86c
test: cover six native producers through the public mod API
Roxas2712 Oct 22, 2026
f4106df
Translate new transfer, hatch and athlete fallback messages at displa…
Roxas2712 Oct 26, 2026
c64350e
Audit current native effect registrations and register cure text sources
Roxas2712 Oct 26, 2026
6d2e0c1
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.
34 changes: 34 additions & 0 deletions docs/frame-profile-sections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Frame-profile sections for world, presentation and DS audio

F11 enables the existing `FrameProfile` report. These additional sections use
its shared no-op closer when profiling is disabled; there is no new setting.
All labels begin with spaces, so nested timings do not get counted again as
top-level frame time. Multiple spans with the same label accumulate.

| Label | Work measured |
| --- | --- |
| `world: tiles` | Initial map/neighbour draws, declined-pipeline fallback and the flat upper layer |
| `world: entities` | Flat ghosts, main and above-layer entities, or tilted billboards |
| `world: FX` | Flat/tilted effect groups, pipeline `ctx.drawFx` and final map overlays |
| `tiles: ensureWindow` | Window validation/refill, including cached and missing-art returns |
| `renderer: zone blit` | Actual `blitCanvas` calls, including the unshaded fallback |
| `renderer: present pipeline` | Present processing and final native output |
| `voxel: sun pass` | Shadow preparation, including cache-hit/rate-limit decisions |
| `voxel: terrain` | Main and neighbouring terrain submission, per eye |
| `voxel: water` | Water draw-list construction, planar reflection and water submission, per eye |
| `voxel: cards` | Main character and staged-battle card submission, per eye |
| `audio: Nitro render` | One DS mixer block, including SoundData creation |

These are wall-clock submission timings, not GPU execution times. Reflection
copies of cards contribute to the water section; the cards section measures
the visible scene's card pass. The world labels describe selected drawing
phases rather than an exhaustive partition of `drawWorld`; setup, sorting and
other work remain visible in the enclosing section. Voxel timings run inside
the selected world pipeline. Hook names, arguments and dispatch order are
unchanged. An exception still takes its existing cleanup/error path; an
unfinished span is discarded by the existing profiler contract.

A1 adds observability and makes no performance claim. Desktop before/after
frame timings and ARM/device timings: **not measured**. Capture the same scene,
viewport, camera, mod configuration and profile window when measuring later
optimizations. Profiling itself is disabled by F11 / `FrameProfile.setEnabled(false)`.
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.
52 changes: 52 additions & 0 deletions docs/world-draw-hot-paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# P4: native world draw dispatch and private worklists

`Performance.worldDrawHoist` defaults to `true`. Set it to `false` to disable
private list reuse and whole-loop protection and restore per-frame bound FX,
height/entity/focus adapters and individual protected entity draws. The switch
is independent of P1–P3.

Native FX bodies, entity drawing, ground rise and canopy focus are methods with
explicit frame arguments. Ordinary flat draws use these methods directly.
Fresh no-argument bindings remain at public pipeline and billboard callback
seams: a retained FX callback must keep its own frame/camera. No record, method
signature, hook order or render pipeline field changes.

Reflectors use a private counted instance list; removed entities are released.
Reflection cover cells use two private numeric columns and a reusable seen set.
Nested draws borrow separate work storage while the outer lease is busy.
An overridden public `rippleCells` still receives fresh pair tables in the old
shape. None of the new pools is included in a public callback record.

The ordinary entity loop has one protected call. Its original sprite-depth
cleanup still runs before the original error is rethrown. The deferred above-
terrain pass retains its original ordering after the top layer.

The guarded native host runs 64 synthetic rectangle actors through actual
`drawWorld`, reflection coverage, FX and native canvas composition. There is no
ROM, save/profile, extracted asset or mod scene. Each phase has 120 warm-up and
120 measured frames; VSync 1, 60 Hz, P1/P3 enabled. GC is stopped in each measured
window to expose allocation growth, then restarted. Heap growth includes all
host/profiler allocations and is not retained memory or a leak measurement.

Observed macOS arm64, switch off → on:

| Tree / backend | F11 world entities, ms/frame | F11 FX, ms/frame | Raw draw mean, ms | CPU, ms/frame | Heap growth / 120 frames, KiB |
| --- | ---: | ---: | ---: | ---: | ---: |
| Fork / LÖVE 11.5 OpenGL | 0.04 → 0.05 | 0.01 → 0.01 | 0.395 → 0.468 | 1.101 → 1.243 | 2758 → 1059 |
| Fork / LÖVE 12 Metal | 0.02 → 0.02 | 0.01 → 0.01 | 16.405 → 16.408 | 0.728 → 0.703 | 2748 → 1043 |
| Public adaptation / LÖVE 11.5 OpenGL | 0.03 → 0.02 | 0.01 → 0.01 | 0.270 → 0.241 | 0.911 → 0.801 | 2758 → 1059 |
| Public adaptation / LÖVE 12 Metal | 0.02 → 0.02 | 0.01 → 0.01 | 16.417 → 16.407 | 0.612 → 0.597 | 2749 → 1043 |

Untimed protected calls fall from 55 to 1 in this scene (nine actors are deferred
to the above-terrain pass). Heap growth is about 61% lower. Short timing results
are mixed, and Metal can place GPU/VSync waiting in submission. No general FPS
or CPU improvement is established. Full game, Android, dedicated ARM handheld
and long session: not measured.

Reproduce with `tools/check_frame_pacing.py --world-draw --love /path/to/love
--report /owned/qa/world.json`. The guarded runner retains raw F11 logs and
measured-source fingerprints. `tests/engine/world_draw_hot.lua` exercises four
editions, operation order/coordinates, batch-call count, private list reuse,
shrinking lists, public ripple records, reentrant covers, retained FX callbacks
and sprite-depth cleanup. A separate local comparison with the unchanged
pre-P4 controller also matched all draw traces in those four editions.
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
11 changes: 11 additions & 0 deletions mods/DRAMATIC_SHAPE/lib/VoxelScene.lua
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ local V = ...

local Mat4 = V.require("Mat4")
local Perf = V.require("Perf")
local FrameProfile = require("src.core.FrameProfile")
-- The PERFORMANCE tier's ceilings (lib/Tier.lua). Top-level here rather
-- than lazily as in ShadowMap/Water/AntiAlias: Tier requires nothing from
-- this mod, so there is no load order to protect, and the sun's rate
Expand Down Expand Up @@ -2725,21 +2726,26 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
-- reaches far north and barely south, which is right for every rung
-- but a head free to face south.
local shCx, shCy = FirstPerson.shadowCenter(cx, cy, vh)
local closeSun = FrameProfile.section(" voxel: sun pass")
castShadows(state, terrain, nbMesh, posed, shCx, shCy, vw, vh, atlasFor,
water, nbWater, battleCards, battleToken)
closeSun()

-- Everything between beginScene and endScene, as one function: the flat
-- path runs it once, a VR frame runs it once PER EYE -- same posed
-- list, same shadow map, same glint, so the two eyes can never disagree
-- about anything but their viewpoint.
local function drawScene()

local closeTerrain = FrameProfile.section(" voxel: terrain")
Voxel3D.draw(terrain, atlasFor(state.map), nil)
for i, nb in ipairs(state.neighbors or {}) do
Voxel3D.draw(nbMesh[i], atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
end

closeTerrain()

-- Without a shadow map (headless, or a driver that could not make the
-- canvas) the old flat decals stand in: ground-only, characters only,
-- but better than a world with nothing under anybody. They go down
Expand Down Expand Up @@ -2824,6 +2830,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
-- lake would otherwise wear one as a black smear. Water covers them,
-- which is the same answer the shadow map's own pass gives (see
-- ShadowMap.sprites) -- people do not shadow water either way.
local closeWater = FrameProfile.section(" voxel: water")
local waterDraws = {}
if water then
waterDraws[#waterDraws + 1] = { water, atlasFor(state.map), nil }
Expand Down Expand Up @@ -2859,6 +2866,8 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
planarTex, planarY)
end

closeWater()

-- AND THE RIPPLE RINGS ON TOP OF IT, BEFORE ANYBODY IS STANDING IN IT.
--
-- THIS LINE'S POSITION IS THE WHOLE FIX. Reported from play: "the ripples
Expand Down Expand Up @@ -2920,10 +2929,12 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
-- drawEntity resolves the lean-over-the-wall-in-front case, and a
-- character genuinely behind a building is far deeper and loses the
-- test, so buildings and trees really occlude.
local closeCards = FrameProfile.section(" voxel: cards")
drawCast(state, posed, atlasFor)
-- and the staged fight's mons over them, exactly as they were painted
-- into the water's reflection copy a few lines up (see drawBattleCards)
drawBattleCards()
closeCards()
-- tall grass last, pulled camera-ward exactly as far as the characters
-- were (same per-vertex shader bias, so grass never drifts either):
-- relative depth between a walker and the tuft row south of their feet
Expand Down
Loading