Skip to content

feat: frame-boundary hook — the i18n resetFrameArena splice point - #812

Merged
apotema merged 3 commits into
mainfrom
feat/frame-hook
Aug 8, 2026
Merged

apotema merged 3 commits into
mainfrom
feat/frame-hook

Conversation

@apotema

@apotema apotema commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

What

Adds the engine-side splice point RFC-I18N §4 needs: a frame-boundary hook the assembler-generated main.zig uses to reset the generated i18n module's tf() frame arena once per frame.

  • Game.frame_boundary_fn: ?*const fn () void = null — new field
  • Game.setFrameBoundaryFn(callback) — registration (lifecycle mixin)
  • Invoked as the first thing in tick(), before the pause gate, so it fires exactly once per frame on every frame — paused ones included (the pause menu is the i18n-heaviest screen, so the "tf() results live for the current frame" contract must hold there too)
  • test/frame_boundary_test.zig — fires once per tick, fires on both pause paths (paused flag and time_scale == 0), runs before the frame_start hook, null slot is safe/clearable, plus the exact generated-main wiring shape against a mock i18n module

The generated main will wire it with one line:

game.setFrameBoundaryFn(i18n.resetFrameArena);

Why this mechanism

Matches the engine's established generated-code splice idiom — a nullable function-pointer field set at startup (gizmo_reconcile_fn, spawn_prefab_fn, active_scene_update_fn) — rather than inventing a registry. Two alternatives were rejected:

  • The frame_start hook payload: fires only on unpaused frames (wrong lifetime for a pause menu rendering translated text) and routes through the game's script-hook receivers — dispatcher ceremony for a fixed, engine-known concern.
  • A callback list: the only registrant is generated code with a single author (the assembler), which can compose multiple per-frame resets into one generated function if a second concern ever appears. A single fn () void slot — signature-identical to the generated resetFrameArena — keeps the wiring to one line and the cost for games without i18n to one branch per frame (RFC Goal 6).

RFC-I18N's frame-arena section + Open Question 1 are updated on the RFC's own branch (#811, commit 2baf381): ownership resolves as module-owned buffer, engine-owned reset.

Assembler follow-up: emit the setFrameBoundaryFn call in the generated main when the project has a locales/ directory.

🤖 Generated with Claude Code

https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

The assembler-generated i18n module's tf() formats into a module-owned
16KiB ring whose designed lifetime is "results live for the current
frame" — something must call its resetFrameArena() at each frame
boundary, and only the engine's main loop knows where a frame begins
(RFC-I18N §4 / Open Question 1).

Add `Game.frame_boundary_fn: ?*const fn () void` + `setFrameBoundaryFn`,
invoked as the FIRST thing in `tick()` — before the pause gate, so it
fires exactly once per frame on every frame, paused ones included (the
pause menu is the i18n-heaviest screen). The generated main wires it
with one line:

    game.setFrameBoundaryFn(i18n.resetFrameArena);

Deliberately a single fn-pointer slot, not a registry: the registrant
is generated code with one author (the assembler), which can compose
multiple per-frame resets into one generated function if a second
concern ever appears. Same generated-code splice idiom as
`gizmo_reconcile_fn` / `spawn_prefab_fn`. `null` (no i18n) costs one
branch per frame.

Not the `frame_start` hook payload because that fires only on unpaused
frames and routes through the game's script-hook receivers — wrong
lifetime and needless dispatcher ceremony for a fixed, engine-known
concern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk
@coderabbitai

coderabbitai Bot commented Aug 7, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 28 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a42d4c35-6e61-45db-81f6-fad9a0e040bc

📥 Commits

Reviewing files that changed from the base of the PR and between 8789912 and 620778c.

📒 Files selected for processing (1)
  • test/frame_boundary_test.zig
📝 Walkthrough

Walkthrough

The game now supports one nullable frame-boundary callback. tick() invokes it at the start of every frame, including paused frames. Editor frames invoke it when paused execution skips tick(). Tests cover ordering, clearing, per-tick execution, and i18n arena reset wiring.

Changes

Frame-boundary callback

Layer / File(s) Summary
Callback API and storage
src/game.zig, src/game/lifecycle_mixin.zig
Game stores an optional frame_boundary_fn. The public setFrameBoundaryFn method sets or clears the callback.
Tick and editor execution paths
src/game/loop_mixin.zig, src/editor_api.zig
tick() invokes the callback before pause handling. frame() invokes it for paused frames that skip tick() and avoids duplicate calls for stepped frames.
Callback validation and i18n wiring
test/frame_boundary_test.zig, build.zig
Tests validate registration, execution count, pause behavior, ordering, clearing, editor stepping, and generated i18n arena reset wiring. The test file is included in the test build step.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant EditorFrame
  participant GameTick
  participant FrameBoundaryFn
  EditorFrame->>GameTick: invoke tick for active or stepped frame
  GameTick->>FrameBoundaryFn: invoke at tick start
  EditorFrame->>FrameBoundaryFn: invoke for paused frame that skips tick
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the frame-boundary hook and its purpose as an i18n reset splice point.
Description check ✅ Passed The description directly explains the frame-boundary hook, its behavior, implementation choices, and test coverage.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/frame-hook

Comment @coderabbitai help to get the list of available commands.

@apotema

apotema commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@apotema

apotema commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 8, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Already reviewed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4c35fb673d

ℹ️ 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/game/loop_mixin.zig
Comment on lines +34 to +35
if (self.frame_boundary_fn) |frame_boundary_fn| {
frame_boundary_fn();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move the boundary outside the preview tick gate

In Studio preview mode, pausing without a pending step makes editor_api.shouldTick() return false while editor_api.frame(&g) and g.render() still run unconditionally, as documented in src/editor_api.zig. Because this callback is reachable only through tick(), those rendered frames never reset the i18n frame arena, violating the promised once-per-frame lifetime and allowing editor-driven translated UI work during a paused preview to retain or consume arena storage across frames. Invoke the boundary from an unconditional frame-loop splice point rather than inside the simulation tick gate.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8789912 — real finding, thanks. editor_api.frame() now fires the boundary when the iteration's tick was skipped (editor_paused and !stepped_this_frame); ticked frames keep the tick()-start fire and can never double-fire (which would free the frame's strings before render). New test drives the real editor_pause/editor_step exports through both paths: two paused frames → two fires from frame(); a stepped frame → one fire from tick(), none from frame().

Studio preview renders while paused — editor_api.frame + g.render run with
no tick, so the arena's once-per-frame reset never fired on exactly the
frames the editor lives in. frame() now fires the boundary when this
iteration's tick was skipped (editor_paused and !stepped_this_frame);
ticked frames keep firing it first-thing in tick(), never twice.
@hasField-gated like the camera paths for reduced fixtures. New test
drives the real editor_pause/editor_step exports through both paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/frame_boundary_test.zig`:
- Around line 152-173: Reset editor module state in this test by calling
editor_api.unbind() during setup and teardown, ensuring stepped_this_frame and
other state do not leak between tests. Keep the existing pause/step assertions
unchanged, and replace the deferred editor_pause(0) cleanup with teardown that
unbinds the editor API.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e48aaaa0-b6c9-4100-9608-bd8f313b7ec4

📥 Commits

Reviewing files that changed from the base of the PR and between 4c35fb6 and 8789912.

📒 Files selected for processing (3)
  • src/editor_api.zig
  • src/game/loop_mixin.zig
  • test/frame_boundary_test.zig
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/game/loop_mixin.zig

Comment thread test/frame_boundary_test.zig
The deferred editor_pause(0) discards pending steps but stepped_this_frame
survives until the next unpaused shouldTick() — run one so later tests in
the binary start clean (CodeRabbit on #812).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk
@apotema
apotema merged commit b1c9328 into main Aug 8, 2026
4 checks passed
@apotema
apotema deleted the feat/frame-hook branch August 8, 2026 14:41
apotema added a commit that referenced this pull request Aug 11, 2026
Minor: adds public API, changes no behaviour.

`game.nativeTextureId(handle)` resolves an engine texture handle to the
BACKEND's own texture id — the value a backend-native accessor is keyed
by. It exists so game scripts stop reaching around the engine boundary
with `game.renderer.getTextureInfo(...).backend_texture.id`, which is
what a downstream UI kit had to do once gfx began minting its own keys
(labelle-gfx#326).

    const backend_id = game.nativeTextureId(handle) orelse return;
    const native = backend_gfx.nativeTextureHandle(backend_id);

Accepts the engine's public `u32` handle (what `loadTextureFromMemory`
and `AssetTexture` hand out) as well as an already-typed one.
`@hasDecl`-gated, so a renderer predating the seam degrades to null
rather than failing to compile — the engine still builds against gfx
older than 1.30.0.

Also carries #812's frame-boundary hook and the #811 RFC docs.

`.version` was reading 2.10.0 against a v2.11.0 tag; bumped to match.
apotema added a commit that referenced this pull request Aug 12, 2026
…al gfx

Patch. v2.12.0's `game.nativeTextureId` could not compile against the
real `GfxRenderer`: the normalization keyed on a `Renderer.TextureId`
decl that only the test mock declared.

    atlas_mixin.zig: struct 'GfxRendererWith(...)' has no member
    named 'TextureId'

Now derives the handle type from the seam's own signature, which is
correct for any renderer. The mock is reshaped to match the real one so
the suite stops hiding this class of mismatch.

Anyone on v2.12.0 wanting the seam should take this; v2.12.0's other
contents (the #812 frame-boundary hook, #811 docs) are unaffected.
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