Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 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
28e158f
perf(P5): cache native palette vectors and pixel-aligned zone geometry
Roxas2712 Oct 26, 2026
3c4a5e3
Merge current upstream native fixes while preserving programme interf…
Roxas2712 Oct 26, 2026
09ce390
test: align updater fixtures with Gen2 release assets
Roxas2712 Oct 22, 2026
03b7ecb
test: cover twelve-action rebinding without fixed row indices
Roxas2712 Oct 22, 2026
41458bf
test: exercise profiled Game methods with complete fixtures
Roxas2712 Oct 22, 2026
ca26f6c
test: execute quit-event veto and Android shutdown paths
Roxas2712 Oct 22, 2026
86aada9
test: pin continuous wide-battle animation anchor mapping
Roxas2712 Oct 22, 2026
03fc570
test: audit and pin the current Gen2 link fingerprint surface
Roxas2712 Oct 22, 2026
f07b48b
test: cover six native producers through the public mod API
Roxas2712 Oct 22, 2026
1c29953
Translate new transfer, hatch and athlete fallback messages at displa…
Roxas2712 Oct 26, 2026
f88942e
Audit current native effect registrations and register cure text sources
Roxas2712 Oct 26, 2026
c893695
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.
65 changes: 65 additions & 0 deletions docs/palette-zone-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# P5: palette uploads and cropped zone quads

`Performance.paletteZoneCache` defaults to `true`; set it to `false` to restore
four fresh uniform vectors per zone and the original full-canvas/scissor blit.
This switch is independent of P1–P4.

Native uploads compute effective palette row indices without allocating the
intermediate inverted/shade-mapped palette tables. The public `effectiveColors`
and `permute` methods retain their existing return shape/identity behavior.
The names `c0`, `c1`, `c2`, `c3` remain unchanged; no uniform packing is introduced.

Each shader has a weakly held private last-value record and four reusable
vectors. All twelve normalized floats are compared; only changed rows are sent
inside one contiguous native blit. Every later blit starts with a full upload,
so direct raw shader writes between blits cannot leave stale colors. Public
`sendColors` calls always upload. Custom table send providers still receive fresh
vectors. Replacing public palette methods, graphics draw or shader dispatch
disables batch assumptions and preserves the original call seam.

The native nearest-filtered texture path uses one private cached Quad with
`setViewport`, clipped to the source canvas and the old pixel coverage. It is
limited to identity transforms, positive whole-framebuffer-pixel magnification
and origins, matching X/Y target DPI and nonnegative clipping boxes. Fractional
zoom/origins, flipped/scaled/rotated views, anisotropic DPI, linear filtering,
high-DPI source textures and compound render targets use the original path.
LÖVE 12 additionally requires target DPI 1/2/4 for exact half-pixel rounding.
Zones and their palettes are never changed or pooled.

LÖVE 11 truncates logical scissor arguments before DPI conversion; LÖVE 12 uses
float arguments and rounds physical scissor values. The crop deliberately
matches the existing behavior in each supported path. Primary API/backend
sources: [11.5 Lua API](https://github.com/love2d/love/blob/11.5/src/modules/graphics/wrap_Graphics.cpp#L524),
[11.5 backend](https://github.com/love2d/love/blob/11.5/src/modules/graphics/opengl/Graphics.cpp#L968),
[12 Lua API](https://github.com/love2d/love/blob/main/src/modules/graphics/wrap_Graphics.cpp),
[12 Metal backend](https://github.com/love2d/love/blob/main/src/modules/graphics/metal/Graphics.mm).

The guarded `tools/check_frame_pacing.py --palette-zones --love /path/to/love
--report /owned/qa/palette.json` first compares actual GPU readbacks byte for
byte. Each backend passes 432 cases across Red/Gold, four display modes,
shade-register remapping, overlapping/true-color zones, clipping, transforms,
three target densities, fractional origins/zoom and source filtering/density.
OpenGL exercises 48 optimized and 384 fallback cases; Metal 16 optimized and
416 fallback cases. First versions of the crop failed these tests and were
restricted/corrected before qualification. Uniform behavior has 491 focused
checks, including in-place palette mutations, raw shader writes between blits,
public wrappers and fresh consumer-owned vectors.

The timed workload draws 96 equal-palette zones over owned generated source
pixels, with 120 warm-up + 120 measured frames per switch phase, macOS arm64,
60 Hz/VSync 1. GC is stopped during each measured window to expose allocation
growth, then restarted. Heap growth includes host/profiler work and is not
retained memory or a leak measurement. Shader:send calls fall from 384 to four
per contiguous identical-palette blit; later blits still resynchronize.

| Tree / backend | F11 zone blit, ms/frame | Raw draw mean, ms | CPU, ms/frame | Heap growth / 120 frames, KiB |
| --- | ---: | ---: | ---: | ---: |
| Fork / LÖVE 11.5 OpenGL | 0.34 → 0.42 | 0.358 → 0.451 | 0.956 → 1.210 | 4368 → 49 |
| Fork / LÖVE 12 Metal | 16.39 → 16.40 | 16.397 → 16.407 | 0.630 → 0.613 | 4369 → 49 |
| Public adaptation / LÖVE 11.5 OpenGL | 0.41 → 0.22 | 0.445 → 0.243 | 0.968 → 0.705 | 4368 → 49 |
| Public adaptation / LÖVE 12 Metal | 16.37 → 16.35 | 16.376 → 16.364 | 0.759 → 0.735 | 4368 → 49 |

Timing/CPU results are mixed across short runs; no general FPS/CPU gain is
established. Metal queue submission can include GPU/VSync waiting. Full-game,
Android, dedicated ARM handheld and long session: not measured. Reports retain
the exact measured source fingerprints and raw F11 logs.
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.
Loading