Skip to content

release: engine 4.2.0-rc.3 with WASM build performance work - #927

Merged
apotema merged 3 commits into
feat/watched-navigation-inputsfrom
release/4.2.0-rc.3
Oct 31, 2026
Merged

apotema merged 3 commits into
feat/watched-navigation-inputsfrom
release/4.2.0-rc.3

Conversation

@apotema

@apotema apotema commented Oct 31, 2026

Copy link
Copy Markdown
Contributor

Prepares labelle-engine v4.2.0-rc.3 on the v4.2.0-rc.2 endpoint-integration line (feat/watched-navigation-inputs) so Flying Platform, which pins 4.2.0-rc.2, can adopt the WASM build performance work. Epic: labelle-toolkit/labelle-cli#528.

Commits on top of v4.2.0-rc.2 (1d0eaca):

Both cherry-picks applied cleanly, with no conflicts. This PR is opened so CI runs on the exact commit that will be tagged v4.2.0-rc.3.

Measured on Flying Platform WASM:

Refs #921 #922 #926 #925, labelle-toolkit/labelle-cli#528

https://claude.ai/code/session_01Hpt1haxcboZPznpG1KqDRS

…tions (#921) (#926)

* test(save): pin serializer output with a golden save fixture (#921)

Captures the exact bytes `serializeGameState` writes today for a fixture
world covering registry components (scalars, enums, optionals, nested
structs, EnumSet), pad and field-less markers, transient and
unregistered components, entity refs, multi-component ref arrays and
every built-in channel (Position, Parent, PrefabInstance, PrefabChild,
Tilemap with layer bindings, Camera with viewport/tag, scene name) with
JSON escaping. Also loads that golden save back as an existing save,
sweeps every allocation failure for clean error propagation without
leaks, and pins the empty-world output.

Committed before the #921 serializer split so the golden is provably
the pre-change output.

Claude-Session: https://claude.ai/code/session_01Hpt1haxcboZPznpG1KqDRS

* perf(save): split the save serializer into bounded per-component functions (#921)

`serializeGameState` walked the ComponentRegistry three times with
`inline for` bodies, so every registered component's entity collection,
serde write and ref-array write was expanded into one function. In
Flying Platform that function reached ~523 KB, and LLVM's super-linear
passes over it dominated the ReleaseSafe compile.

Move the three per-component bodies into `noinline` helpers
(`collectComponentEntities`, `writeRegisteredComponent`,
`writeComponentRefArrays`), instantiated once per component type, and
fold the four copies of the entity dedup into `rememberEntity`. The
helpers execute the same statements in the same order, so the save
bytes, error propagation (`try` through every helper), cleanup
(`defer` of the per-component temporary list stays in its helper) and
safety checks are unchanged; the golden test added in the previous
commit passes unmodified.

Claude-Session: https://claude.ai/code/session_01Hpt1haxcboZPznpG1KqDRS

* tools(save): add the #921 serializer compile-cost harness and results

`measure.py` compiles a game's generated WASM `game` library with a given
save.zig dropped into its vendored engine copy (restoring it afterwards),
samples the compiler process tree for wall time and peak private memory,
and lists every save-mixin function size from `llvm-nm -S`. `run_ab.sh`
drives two interleaved baseline/outlined runs under a shared-machine lock.

results/ holds the Flying Platform runs behind the PR table
(ReleaseSafe, -j2, Windows, Zig 0.16.0): the game compile drops from
~222 s / 10.2 GiB peak private to ~142 s / 5.6 GiB, and
serializeGameState from 523,186 to 58,476 bytes.

Claude-Session: https://claude.ai/code/session_01Hpt1haxcboZPznpG1KqDRS

* tools(save): fail the A/B driver on a failed run, release its lock on interrupt

Review of #926:

- run_ab.sh printed a failed measure.py status and then returned the
  status of the cleanup that followed, so a failed measurement still
  exited 0. It now counts failures, still attempts all four runs, and
  exits 1 if any failed.
- An INT/TERM/HUP after taking the heavy lock left the lock directory
  behind. The driver now records that it owns the lock, and an EXIT trap
  stops the running measurement and releases only that lock. The
  measurement runs in the background and is waited on, so a signal is
  handled immediately rather than after the compile.
- Stopping the measurement uses the new `measure.py --abort PID`, which
  kills the PID's process tree (the zig compilers) and restores the game
  files the run edited. Those originals are now also kept on disk as
  `<file>.ab-backup` until restored, because under Git Bash a plain
  `kill` hard-terminates the native python, so its `finally` never runs
  and the compilers are orphaned. A later run also restores stale
  backups.

Checked with stub compilers (no game build): a failing compile gives
`4 of 4 measurements failed` and exit 1, with files restored and the
lock free. A TERM mid-compile gives exit 143, both stub processes gone,
files restored, no backups left and the lock free. A lock owned by
someone else is never removed. shellcheck and pyflakes are clean.

Claude-Session: https://claude.ai/code/session_01Hpt1haxcboZPznpG1KqDRS
(cherry picked from commit 7e804d4)
…save loader (#922) (#925)

* test(save): pin serializer output with a golden save fixture (#921)

Captures the exact bytes `serializeGameState` writes today for a fixture
world covering registry components (scalars, enums, optionals, nested
structs, EnumSet), pad and field-less markers, transient and
unregistered components, entity refs, multi-component ref arrays and
every built-in channel (Position, Parent, PrefabInstance, PrefabChild,
Tilemap with layer bindings, Camera with viewport/tag, scene name) with
JSON escaping. Also loads that golden save back as an existing save,
sweeps every allocation failure for clean error propagation without
leaks, and pins the empty-world output.

Committed before the #921 serializer split so the golden is provably
the pre-change output.

Claude-Session: https://claude.ai/code/session_01Hpt1haxcboZPznpG1KqDRS

* perf(save): split the save serializer into bounded per-component functions (#921)

`serializeGameState` walked the ComponentRegistry three times with
`inline for` bodies, so every registered component's entity collection,
serde write and ref-array write was expanded into one function. In
Flying Platform that function reached ~523 KB, and LLVM's super-linear
passes over it dominated the ReleaseSafe compile.

Move the three per-component bodies into `noinline` helpers
(`collectComponentEntities`, `writeRegisteredComponent`,
`writeComponentRefArrays`), instantiated once per component type, and
fold the four copies of the entity dedup into `rememberEntity`. The
helpers execute the same statements in the same order, so the save
bytes, error propagation (`try` through every helper), cleanup
(`defer` of the per-component temporary list stays in its helper) and
safety checks are unchanged; the golden test added in the previous
commit passes unmodified.

Claude-Session: https://claude.ai/code/session_01Hpt1haxcboZPznpG1KqDRS

* test(save): pin what an existing save loads into (#922)

Before splitting the loader, add a compatibility fixture: a hand-written
v2 save that exercises every channel `deserializeGameState` reads
(sparse, out-of-order ids; entity refs; ref arrays on two components,
including an id with no saved entity; postLoad; saveable and transient
post_load_add markers; post_load_create; the built-in Parent,
PrefabInstance, PrefabChild, Tilemap with bindings and Camera without
zoom) plus malformed entries the loader must skip without failing.

The world it loads into is pinned by its re-serialization, captured
with the current loader (7ea1d13), and the postLoad / marker / create
effects and save -> load -> save stability are asserted directly. The
serializer golden test from #921 already covers loading a save written
by the pre-#921 serializer.

An allocation-failure sweep like the serializer's is not possible here:
the load resets the ECS, and the mock ECS panics on OOM by design.

Claude-Session: https://claude.ai/code/session_01Hpt1haxcboZPznpG1KqDRS

* perf(save): split the save loader into bounded per-component functions (#922)

`deserializeGameState` walked the ComponentRegistry with `inline for`
bodies in Phase 2 (JSON read + entity-ref remap + addComponent), Step 4
(ref arrays) and Step 6a/6b/6c (postLoad, post_load_add markers,
post_load_create), so every registered component's code was expanded
into one function. Together with the built-in restores it reached
~133 KB in Flying Platform's ReleaseSafe WASM build (3rd largest).

Move each per-component body into a `noinline` helper instantiated once
per type (`restoreRegisteredComponent`, `restoreComponentRefArrays`,
`runComponentPostLoad`, `addPostLoadMarkers`, `createPostLoadEntity`)
and each built-in restore (Parent, PrefabInstance, PrefabChild, Tilemap,
Camera) into its own bounded function, called in the same order. The
helpers run the same statements: `try` still propagates every error,
the per-component temporary lists are still freed by `defer`, and the
"skip malformed input" contract is unchanged (`break :blk` became
`return`). The compatibility test from the previous commit and #921's
golden test pass unmodified.

Claude-Session: https://claude.ai/code/session_01Hpt1haxcboZPznpG1KqDRS

* perf(scene): keep per-scene registration out of the generated main (#922)

`registerScene` takes the scene name and loader as comptime parameters,
so it is instantiated once per scene, and the generated `main` calls
each instantiation exactly once. LLVM therefore inlined every one of
them - hash-map insert, growth path and the #697 error log - into
`main`. Flying Platform registers ~136 scenes, which made its startup
`main.main` the largest function in the ReleaseSafe WASM build:
~335 KB, of which ~281 KB was inlined `registerScene` bodies and ~25 KB
inlined scene-map lookups from `setSceneSource` / `setSceneAssets`
(attributed with llvm-dwarfdump inlined-subroutine ranges).

Keep the comptime shim (it still builds the per-scene loader wrapper)
but do the insert in one shared `noinline putSceneEntry`, and route the
per-scene setters' lookup through one `noinline sceneEntryPtr`. The
calls the generated main makes, their order, the stored entries and the
error log are unchanged, so no assembler template change is needed.

`test/scene_registration_test.zig` replays the generated startup
sequence: every scene dispatches to its own loader, the setters attach
to the right entry, unknown names still fail with SceneNotFound, and
re-registration replaces the loader.

Claude-Session: https://claude.ai/code/session_01Hpt1haxcboZPznpG1KqDRS

* tools: reproducible profile of oversized WASM functions + #922 results

`tools/wasm_function_profile/` is the script set used for #922 on
Flying Platform's ReleaseSafe threaded WASM build:

- profile.sh: one variant, under the shared heavy-build lock: the game
  Zcu alone with `--time-report` (LLVM pass timing, sema/codegen), an
  edit-rebuild compile without it (wall time, peak private memory), and
  the full build (emcc link, final .wasm size).
- profile_run.py / read_timing.py: process-tree memory sampling and the
  build web UI's time report.
- symbols.py: per-function code size (llvm-nm -S), top N and clusters.
- dwarf_inline.py: attributes a function's bytes to the calls LLVM
  inlined into it (DW_TAG_inlined_subroutine ranges) - this is how the
  startup main's 281 KB were traced to `registerScene`.
- sequence.sh / summarize.py: alternating base/after samples and the
  before/after table (results/summary.md).

Paths default to the Windows measurement machine and can be overridden
(PY, ZIG, EMSDK, ZIG_LOCAL_CACHE_DIR).

Claude-Session: https://claude.ai/code/session_01Hpt1haxcboZPznpG1KqDRS
(cherry picked from commit be0991e)
Carries the save serializer split (#926) and the save loader / startup
scene registration outlining (#925) onto the v4.2.0-rc.2 endpoint
integration baseline used by Flying Platform (labelle-cli#528).

Claude-Session: https://claude.ai/code/session_01Hpt1haxcboZPznpG1KqDRS
@coderabbitai

coderabbitai Bot commented Oct 31, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Team
  • Run ID: 96681c73-1897-4819-8a8f-f3ba1a687f62

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-31T04:20:33.668112Z 1a6c9e9 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@apotema

apotema commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

CI passed on 1a6c9e9. That commit is tagged as v4.2.0-rc.3 and published as a prerelease: https://github.com/labelle-toolkit/labelle-engine/releases/tag/v4.2.0-rc.3. The tree CI tested (refs/pull/927/merge) is identical to the tagged commit's tree.

I left this PR open and unmerged. rc.2 was squash-merged into feat/watched-navigation-inputs, but a squash here would create a new, untested SHA that is not the tagged commit. To keep the line branch on the tag, fast-forward it to 1a6c9e9 instead of using the squash or rebase buttons. Otherwise this PR can be closed.

https://claude.ai/code/session_01Hpt1haxcboZPznpG1KqDRS

@apotema
apotema merged commit 1a6c9e9 into feat/watched-navigation-inputs Oct 31, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant