Skip to content

feat(#737): Script Runtime Contract v1 — C-ABI surface for language plugins - #749

Merged
apotema merged 7 commits into
mainfrom
feat/737-script-contract
Jul 10, 2026
Merged

apotema merged 7 commits into
mainfrom
feat/737-script-contract

Conversation

@apotema

@apotema apotema commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Script Runtime Contract v1 — the C-ABI surface every scripting language binds. Closes #737 (Phase 1 core of the language-plugins epic #237; design: RFC-LANGUAGE-PLUGINS.md #730; shapes proven by the POC #734).

Architecture (editor_api-mirrored)

  • bind(&g) once from the generated main → comptime Holder publishes a type-erased vtable; the 15 export fn labelle_* symbols carry no comptime types. Pre-bind, every export is a safe no-op.
  • Zero cost when unused: reachable only through the lazily-analyzed root.script_contract — a build that never references it emits no symbols (the same nm-verified gate editor_api uses).
  • Generated-main touchpoints documented for the assembler: bind(&g) after init; drainEvents(&g) after tick, before dispatchEvents (dispatch swaps the event buffer out — tapping after it always sees an empty list; the ordering is load-bearing and documented).

Surface

contract_version · entity_create/destroy · prefab_spawn · component_set/get/has/remove · query · event_emit/subscribe/poll · scene_change · log · time_dt — consumer header at contract/labelle_script.h (LABELLE_CONTRACT_VERSION 1).

Notable implementation decisions (all documented in-file):

  • Components by name over the registered set via the scene-bridge dispatch (not editor_api's Camera allowlist — scripts are the game's own code); Position included and routed through setPosition so render dirty-tracking fires; slice fields use the scene bridge's nested_entity_arena lifetime convention; visual built-ins with renderer side-tables (Sprite/Shape/Camera/…) are explicitly out of v1.
  • Double-buffered emit arenas: JSON-parsed event payloads with slice fields must survive until end-of-frame dispatch; a single reset-at-drain arena would UAF them. Two arenas flip per drain; pinned by a slice-bearing-payload test.
  • Poll semantics POC-pinned: "<name> <json>" FIFO, truncated entry still consumed, 256-cap drop-newest, subscribe dedupes, rebind = fresh session.
  • time_dt reads the frame-profiler ring × time_scale (no extra generated-main splice); query writes always-valid JSON with truncation rollback.

Verification

904/904 tests, 170/170 steps (baseline 885/885) — 19 new tests: pre-bind no-ops, component round-trips incl. unknown-name, 2-component query + filter, emit-by-name into the real buffer, subscribe/poll FIFO + filtering, slice-payload lifetime, GameEvents = void and empty-registry (engine.Game) compile-and-degrade paths.

Epic: #237 · next consumer: labelle-scripting bootstrap (#738)

https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j

…ge plugins

The one versioned surface every scripting language binds (RFC-LANGUAGE-
PLUGINS; POC PR #734 pinned the shapes): flat export fn labelle_*
symbols, strings ptr+len, payloads JSON.

- editor_api-mirrored architecture: bind(&g) once from the generated
  main -> comptime Holder publishes a type-erased vtable; every export
  is a safe no-op pre-bind; reachable only via the lazily-analyzed
  root.script_contract, so script-less builds emit ZERO symbols.
- Components by name over the registered set (scene-bridge dispatch,
  NOT editor_api's Camera allowlist) + Position (routed through
  setPosition so dirty tracking fires); set-slices live in the scene
  bridge's nested_entity_arena convention; visual built-ins with
  renderer side-tables are out of v1.
- Events: emit-by-name via a comptime GameEvents field-loop with
  DOUBLE-BUFFERED payload arenas (a single reset-at-drain arena would
  UAF slice-bearing payloads emitted this frame); subscribe/poll FIFO
  with POC-pinned '<name> <json>' semantics; drainEvents(&g) splices
  AFTER tick and BEFORE dispatchEvents — dispatch swaps the buffer
  out, so tapping after it always sees an empty list (load-bearing
  ordering, documented).
- Query: first-name view + has-filter, snapshot ids as JSON, truncation
  rolls back to the last whole id. prefab_spawn via game.spawnPrefab;
  time_dt from the frame-profiler ring x time_scale (no extra
  generated-main touchpoint); scene_change; log.
- contract/labelle_script.h: the consumer header, 15 exports,
  LABELLE_CONTRACT_VERSION 1.
- 19 tests in test/script_contract_test.zig (build.zig-wired):
  904/904 suite green (baseline 885).

Epic: #237

Claude-Session: https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j
@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@coderabbitai

coderabbitai Bot commented Jul 10, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@apotema, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 23 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: cfdfbb3b-b548-4e54-8635-a35189b25875

📥 Commits

Reviewing files that changed from the base of the PR and between 5bf29fe and 551efcd.

📒 Files selected for processing (4)
  • build.zig
  • build.zig.zon
  • src/root.zig
  • test/script_contract_test.zig
📝 Walkthrough

Walkthrough

Adds Script Runtime Contract v1 as a versioned C ABI for native language plugins, including ECS, component, query, event, scene, logging, and timing operations. It wires the contract into the engine, refactors built-in component application, packages the header, and adds comprehensive tests.

Changes

Script Runtime Contract

Layer / File(s) Summary
Contract surface and package registration
contract/labelle_script.h, build.zig.zon, build.zig
Defines the documented C ABI, adds the contract directory to package paths, and registers the contract test file.
Bound runtime and exported operations
src/script_contract.zig, src/root.zig
Implements binding lifecycle, type-erased game dispatch, ECS operations, JSON serialization, event buffering, scene changes, logging, timing, and lazy module export wiring.
Built-in component routing
src/jsonc/component_apply.zig
Moves Sprite, Shape, Tilemap, Camera, and Image deserialization into reusable helpers while preserving registry precedence and camera tag handling.
Contract validation
test/script_contract_test.zig
Tests ABI no-op behavior, ECS and component semantics, queries, events, scene and prefab operations, pointer conventions, timing, logging, and rebinding.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Plugin
  participant ScriptContract
  participant Engine
  participant EventQueue
  Plugin->>ScriptContract: Call labelle_* API
  ScriptContract->>Engine: Dispatch ECS, scene, log, or time operation
  Engine->>EventQueue: Buffer emitted events
  ScriptContract->>EventQueue: Drain subscribed events after tick
  ScriptContract-->>Plugin: Return JSON, status, or polled event
Loading

Possibly related PRs

Poem

A bunny hops through C ABI,
With JSON carrots stacked nearby.
Events queue in FIFO rows,
Components bloom where serde flows.
Bind, poll, and scenes take flight—
Contract paths now run just right!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately highlights the main change: the Script Runtime Contract v1 C-ABI surface for language plugins.
Description check ✅ Passed The description is directly related to the changeset and summarizes the contract, gating, and test coverage.
Linked Issues check ✅ Passed The PR covers the Script Runtime Contract v1 APIs, comptime gating, header shipping, and real-game tests requested by #737.
Out of Scope Changes check ✅ Passed The changes stay focused on the script contract, its build integration, and supporting tests without unrelated additions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/737-script-contract

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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: ab5fd21fe3

ℹ️ 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/script_contract.zig
Comment on lines +512 to +514
fn appendInboxEntry(alloc: std.mem.Allocator, name: []const u8, payload: []const u8) void {
const entry = std.fmt.allocPrint(alloc, "{s} {s}", .{ name, payload }) catch return;
inbox.append(alloc, entry) catch alloc.free(entry);

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 Reuse freed inbox slots before appending

If a subscriber polls some events each tick but leaves at least one pending, inbox_head advances and those entries are freed, but new drained events are always appended after inbox.items.len; compaction only runs when the inbox becomes completely empty. In that budgeted-polling scenario the pending count stays under max_pending_events, yet the ArrayList backing storage grows every frame, defeating the cap's heap-growth protection.

Useful? React with 👍 / 👎.

Comment thread contract/labelle_script.h
Comment on lines +4 to +11
* The one versioned C-ABI surface every scripting language binds. The
* host game (assembler-generated, engine `src/script_contract.zig`)
* exports these flat symbols; language plugins consume them:
*
* - embedded-VM languages (Lua, mruby, QuickJS, CoreCLR) call them
* from binding closures;
* - native-compiled languages (Rust, Crystal, Go) declare them
* `extern "C"` — this header IS the binding — and link against the

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 Include the C header in packaged releases

This adds the public binding header that native language plugins are supposed to include, but build.zig.zon's .paths list still only packages build.zig, src, scene, jsonc, codegen, audio_backend, and spec; contract/ is omitted. Consumers that fetch the engine as a Zig package/archive therefore won't receive contract/labelle_script.h, so the advertised C ABI cannot be consumed from released packages.

Useful? React with 👍 / 👎.

Comment thread src/script_contract.zig
Comment on lines +679 to +680
const T = Components.getType(comp_name);
game.removeComponent(ent, T);

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 Skip removal when the component is absent

When a script removes a known component from a live entity that does not currently have it, this still calls game.removeComponent; that helper invokes T.onRemove before asking the backend to remove the component. The contract documents absent-but-known removal as idempotent, so components with onRemove side effects will observe false removals unless this path checks game.hasComponent(ent, T) first.

Useful? React with 👍 / 👎.

Comment thread src/script_contract.zig
Comment on lines +287 to +291
pub export fn labelle_prefab_spawn(
name_ptr: [*]const u8,
name_len: usize,
json_ptr: [*]const u8,
json_len: usize,

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 Accept the documented NULL prefab params safely

The public header tells C plugins to pass NULL/len 0 for params_json, but this export declares json_ptr as a non-optional, non-allowzero Zig pointer. A native caller following the header therefore passes an invalid value for this ABI before the json_len == 0 branch can substitute ""; make this parameter optional/allowzero or require callers to pass a non-null dummy pointer.

Useful? React with 👍 / 👎.

Comment thread src/script_contract.zig
Comment on lines +847 to +848
const last = fp.frame_times[(fp.index + n - 1) % n];
return last * game.time_scale;

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 Use the tick's captured scaled dt

If time_scale changes after Game.tick starts but before a language script calls this function, labelle_time_dt() returns last_real_dt * current time_scale instead of the scaled_dt value that tick() computed once and passed to the scripts for this frame. That makes C/plugin scripts observe a different dt from Zig scripts in the same frame, e.g. after an earlier script pauses or changes slow-mo.

Useful? React with 👍 / 👎.

…ooks, dt

All 5 findings from the #749 review, each with a pinning test
(negative-verified: reverting each fix fails exactly its test):

- inbox: compact pending tail to front once head >= 16 or >= half the
  list (copyForwards + shrinkRetainingCapacity) — budgeted polling with
  a standing backlog no longer grows backing storage every frame;
  fully-drained keeps the cheap clearRetainingCapacity path.
- packaging: add 'contract' to build.zig.zon .paths so released
  packages ship contract/labelle_script.h.
- idempotent remove: guard Position + registry branches with
  hasComponent — absent-component remove returns 0 without firing
  T.onRemove (components_mixin.zig:268 fires it unconditionally).
- NULL-able ABI, made consistent header<->code: nullable JSON payload
  inputs (prefab_spawn/component_set/event_emit -> ?[*]const u8 via a
  shared optionalJson helper); out buffers nullable only with cap 0,
  and a NULL/cap-0 poll is now a documented no-consume probe
  (previously silently consumed an entry); required strings stay
  non-optional per a new header Conventions bullet.
- dt: new labelle_time_dt_stamp(f32) export (plugin-only, once per
  tick, pre-scripts) stored in the module's session state and reset on
  bind/unbind; labelle_time_dt returns the stamp verbatim (mid-tick
  time_scale changes no longer skew script dt vs Zig scripts), falling
  back to the profiler path when unstamped.

170/170 steps, 909/909 tests (+5).

Claude-Session: https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j
@apotema

apotema commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

All 5 findings fixed in the latest push — each with a pinning test, negative-verified (reverting each fix fails exactly its test):

  1. Inbox slot reuse → compactInbox(): pending tail slides to front at head ≥ 16 or ≥ half the list; capacity bounded under a standing backlog (100-frame test).
  2. Header packaging → "contract" added to .paths.
  3. Idempotent remove → hasComponent guard on both branches; absent-remove fires no onRemove (hook-counter test via the OnReadyBump pattern).
  4. NULL-able ABI → convention made consistent header↔code: nullable JSON payload inputs (?[*]const u8 + shared optionalJson), out buffers NULL-only-with-cap-0, and a bonus fix discovered here: a cap-0 poll previously silently consumed an entry — now a documented no-consume probe.
  5. dt consistency → labelle_time_dt_stamp(f32) (plugin-only, once per tick); labelle_time_dt returns the stamp verbatim, so mid-tick time_scale changes can't skew script dt vs Zig scripts; profiler fallback when unstamped.

909/909 tests (+5).

https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j

@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: 0cac9cf9bd

ℹ️ 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/script_contract.zig
Comment on lines +710 to +712
const ent = castEntity(id) orelse return 0;
if (std.mem.eql(u8, name, "Position")) {
const pos = game.getComponent(ent, core.Position) orelse return 0;

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 Guard component reads with entity liveness

When a script keeps an id after the entity was destroyed, this read path calls getComponent without the entityExists guard used by set/remove. The engine comments around parent unlinking note that direct sparse-set component lookups can alias dead/recycled ids, so labelle_component_get (and the analogous componentHasImpl path below) can expose another entity's component instead of returning the documented 0 for dead ids; check liveness before resolving the component name.

Useful? React with 👍 / 👎.

Comment thread src/script_contract.zig
Comment on lines +264 to +265
if (!isSubscribed(@tagName(tag))) continue;
enqueueEvent(alloc, @tagName(tag), data);

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 Don't replay current-frame events to new subscriptions

If a script subscribes after events have already been buffered in the current tick, drainEvents still applies the final subscription set to the whole event_buffer. For example Game.tick emits engine__tick before active-scene scripts run, so a script that first subscribes to engine__tick during that tick receives the already-emitted event even though the contract promises delivery only from the next frame/drain; stage new subscriptions or snapshot the subscription set before the tick.

Useful? React with 👍 / 👎.

Comment thread src/script_contract.zig
Comment on lines +685 to +688
const comp_names = comptime Components.names();
inline for (comp_names) |comp_name| {
if (std.mem.eql(u8, name, comp_name)) {
const T = Components.getType(comp_name);

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 Support JSONC built-in components in the contract

For built-ins such as Sprite, Shape, Tilemap, Camera, and Image, this path only checks Position and then the project ComponentRegistry, but the JSONC loader accepts those names through dedicated branches (src/jsonc/component_apply.zig). In a script that tries to set/query the same component names scenes can author, labelle_component_set returns -1 because these built-ins are exposed on Game separately rather than in Components.names(), so either mirror the loader's built-in dispatch here or narrow the documented surface.

Useful? React with 👍 / 👎.

…parity

- Liveness guard on reads: componentGet/HasImpl check entityExists
  before any lookup (dead/stale id -> 0), matching set/remove. Pinned
  via a LeakyReadEcs fixture (mock backends liveness-check their own
  reads, which would mask the bug — the fixture serves the dead row
  like a real sparse set).
- Effective-next-drain subscriptions: subscribe appends to a pending
  set; drainEvents filters against ACTIVE only and absorbs pending at
  drain end (defer, so the no-active early-out still activates) — a
  script subscribing mid-tick no longer receives events emitted
  earlier that tick (e.g. engine__tick). Documented module+header.
- JSONC built-in parity: component_apply.zig's five branches extracted
  to pub applySprite/Shape/Tilemap/Camera/Image (behavior-identical,
  now bool); the contract dispatches the same fns with the scene
  loader's precedence gates (registryHas + camera_is_builtin belt).
  Full support matrix shipped in the header: set/get/has/remove/query
  for all five — remove via the typed channels (removeSprite/Shape
  untrack the renderer, removeTilemap frees the runtime), get via a
  comptime jsonRoundTrips filter (renderer-handle fields omitted,
  re-derived on set -> get->set lossless; Camera tag as string).
  Registered same-name components keep precedence (pinned).

170/170 steps, 916/916 tests (+7). Negative-verified per finding.

Claude-Session: https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j
@apotema

apotema commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Round-2 findings all fixed in the latest push (each negative-verified):

  1. Read liveness — get/has now guard entityExists like set/remove; pinned via a LeakyReadEcs fixture that serves dead rows the way a real sparse set can (the standard mock would mask the bug).
  2. No same-tick subscription replay — subscribe lands in a pending set absorbed at drain end; effective for events emitted after the current tick's drain (documented module + header).
  3. Built-in parity — component_apply.zig's Sprite/Shape/Tilemap/Camera/Image branches extracted as pub fns and dispatched by the contract with the scene loader's own precedence gates. Full support matrix in the header — set/get/has/remove/query for all five, removes via the typed channels (renderer untrack / tilemap runtime free), gets via a comptime round-trip filter (handle fields omitted and re-derived on set). Registered same-name components keep precedence.

916/916 tests (+7 from baseline 909).

https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j

@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: 0f9b66f2f8

ℹ️ 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/script_contract.zig
Comment on lines +303 to +306
// Pending subscriptions activate at the END of the drain — the
// defer covers the no-active-subs early-out below, so a session's
// FIRST subscription still becomes active for the next drain.
defer activatePendingSubs(alloc);

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 Activate setup subscriptions before the first tick

When a plugin subscribes during setup after bind but before the first frame, the documented generated-main sequence has no drainEvents call before the first g.tick(dt). Because pending subscriptions are only activated by this deferred call after the buffer scan, setup subscriptions miss all first-frame events and only start receiving from frame 2. The end-of-drain delay is needed for mid-tick subscriptions, but setup/no-drain-yet subscriptions need an activation point before the first tick or they should be inserted into the active set immediately.

Useful? React with 👍 / 👎.

Comment thread src/script_contract.zig
Comment on lines +885 to +888
const applied = if (comptime std.mem.eql(u8, comp_name, "Sprite"))
Apply.applySprite(game, ent, value)
else if (comptime std.mem.eql(u8, comp_name, "Shape"))
Apply.applyShape(game, ent, value)

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 Avoid re-adding visual built-ins on replace

When labelle_component_set is used to replace an existing Sprite or Shape, this dispatch reuses the scene apply helpers, which call game.addSprite/game.addShape; those helpers unconditionally call renderer.trackEntity. Re-setting the same visual component therefore double-tracks the entity, and a later labelle_component_remove only untracks once, leaving stale renderer state. Use a set/update path, or untrack before add, when the component is already present.

Useful? React with 👍 / 👎.

Comment thread src/script_contract.zig
// request must not nuke the running scene).
if (game.scenes.get(name) == null and game.jsonc_scenes.get(name) == null)
return -1;
game.setScene(name) catch return -1;

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 Defer script scene changes instead of unloading inline

When a language script calls labelle_scene_change during its tick, this immediately runs setScene, which tears down the current scene while loop_mixin is still inside the previously captured active_scene_update_fn for that tick. In that context the active scene or script runner can be freed while control returns to the old update, so the contract should pre-validate here but queue the scene change for the loop's existing pending-scene path rather than committing synchronously.

Useful? React with 👍 / 👎.

Silent truncation was undetectable: the caller saw valid JSON with no
signal that entities were dropped. labelle_query now returns bytes
REQUIRED for the complete result; writing is unchanged (fill to cap,
roll back to the last whole id, always-valid JSON), so truncation
detection is required > cap and the retry contract is one sized call.
NULL/cap-0 out becomes a legal pure sizing probe. component_get /
event_poll deliberately stay written-bytes — query is the lone
unbounded-cardinality op (documented as the one asymmetry, module +
header).

Tests rework the truncation case: NULL probe == sized call, every
undersized cap returns the full required, written prefix is valid
JSON, canary bytes past cap untouched, retry at exactly required
yields all 50 ids. 170/170 steps, 916/916.

Claude-Session: https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j
@apotema

apotema commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

One more contract refinement in the latest push, prompted by review on the consumer PR (labelle-toolkit/labelle-scripting#1): labelle_query now returns bytes REQUIRED (snprintf semantics) — silent truncation was undetectable. Writing behavior unchanged (valid-JSON prefix, last-whole-id rollback); required > cap is the truncation signal; NULL/cap-0 is a pure sizing probe. component_get/event_poll deliberately stay written-bytes — query is the lone unbounded-cardinality op (asymmetry documented in module + header). 916/916.

https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j

@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: 60add8ffd6

ℹ️ 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/script_contract.zig Outdated
Comment on lines +879 to +883
const comp = std.json.parseFromSliceLeaky(
T,
componentAlloc(),
json,
.{ .ignore_unknown_fields = true },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Copy registered component strings out of the caller buffer

With std.json.parseFromSliceLeaky on a complete input, the default allocation mode is alloc_if_needed, so unescaped []const u8 fields in registered components can point back into the caller's json buffer instead of componentAlloc(). When a native plugin passes a stack/reused buffer to labelle_component_set for a component with string fields, the ECS stores dangling or corrupted slices after the call returns; set .allocate = .alloc_always or otherwise copy slice fields to match the documented nested-entity-arena lifetime.

Useful? React with 👍 / 👎.

Comment thread src/script_contract.zig Outdated
Comment on lines +1187 to +1191
const payload = std.json.parseFromSliceLeaky(
f.type,
arena,
json,
.{ .ignore_unknown_fields = true },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Copy event payload strings into the emit arena

This parse has the same default alloc_if_needed behavior, so slice fields in event payloads, such as a []const u8 event name, can borrow the plugin's temporary JSON buffer instead of the active emit arena. Because emitted events stay buffered until dispatchEvents after the script call returns, a native plugin that builds JSON in a stack or reused buffer can leave the game event buffer with dangling payload slices despite the two-arena lifetime guarantee.

Useful? React with 👍 / 👎.

Comment thread src/script_contract.zig
Comment on lines +906 to +907
var parser = jsonc.JsoncParser.init(arena.allocator(), json);
const value = parser.parse() catch return -1;

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 Reject trailing garbage before applying built-ins

For built-in component sets, this parses the first JSONC value and then immediately applies it without verifying that the parser reached the end of the input. A payload like {"sprite_name":"hero"}not-json therefore mutates the entity and returns 0 even though the C contract says malformed JSON must return -1 and leave the entity untouched; check parser.pos for leftover non-whitespace/comment input before dispatching to the apply helpers.

Useful? React with 👍 / 👎.

Comment thread src/script_contract.zig
Comment on lines +1178 to +1180
if (comptime f.type == void) {
game.emit(@unionInit(G.GameEvents, f.name, {}));
return 0;

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 Reject malformed JSON for void events

When a known event tag has a void payload, this branch never validates the supplied JSON, so labelle_event_emit returns 0 and buffers the event even if the plugin passed malformed payload bytes such as {. The C contract documents parse failures as -1, so bindings can silently emit events despite a broken serializer; validate that the payload is empty/{} (or at least syntactically valid) before emitting void variants.

Useful? React with 👍 / 👎.

Round 3 (consumer-driven):
- labelle_component_get -> required-size, ALL-OR-NOTHING write (a
  truncated object prefix is useless, unlike query's valid id prefix);
  counting pass via std.Io.Writer.Discarding before the write pass so
  over-cap never scribbles; NULL/cap-0 = pure probe; 0 keeps its
  absent sentinel.
- labelle_event_poll's NULL/cap-0 probe now actually returns the next
  entry's size WITHOUT consuming (round-1's probe returned 0 — the
  premise the consumer's probe->grow->poll scheme needed made real).
  Sizing model documented in one header section: required-size for
  the unbounded ops (query/get), written-bytes + probe pairing for
  poll.

Round 3b (codex wave 3):
- .alloc_always on the registry component parse AND the event payload
  parse — parseFromSliceLeaky's alloc_if_needed default let unescaped
  slice fields BORROW the caller's temporary json buffer (dangling
  after return); copies now land in componentAlloc()/the active emit
  arena. Pinned with poison-after-call tests asserting the stored
  slices live outside the caller buffer.
- setBuiltinComponent refuses trailing garbage (parser.pos != len)
  BEFORE any apply; registry path verified safe by construction;
  JSONC comment tails stay legal. Single-JSON-document rule documented.
- Void-payload events accept exactly empty/{}/null; anything else -1,
  nothing buffered (documented accept set).
- Stale header comment fixed (query no longer 'the one exception').

170/170 steps, 920/920 tests. All fixes negative-verified.

Claude-Session: https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@apotema

apotema commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Rounds 3+3b in the latest push (all negative-verified):

  • component_get → required-size, all-or-nothing write (finishing what query started); the poll probe now truly returns the next entry's size without consuming (round-1's returned 0 — consumer review caught the gap). Final sizing model in one header section: required-size for the unbounded ops, written-bytes+probe for poll.
  • .alloc_always on component AND event-payload parses — alloc_if_needed let unescaped slice fields borrow the caller's temporary buffer (dangling after return); poison-after-call tests pin the copies.
  • Trailing-garbage rejection on built-in component sets (before any mutation; JSONC comment tails stay legal) + the single-document rule documented.
  • Void-payload accept set: exactly empty/{}/null; anything else -1, nothing buffered.

920/920 tests. With the consumer PR (labelle-toolkit/labelle-scripting#1) green at 19/19 against the mirrored mock, the surface is stable across four review rounds.

https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j

@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.

🧹 Nitpick comments (1)
src/jsonc/component_apply.zig (1)

208-275: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

LGTM!

The helper extraction is clean — each preserves the original allocation, deserialization, and attachment semantics. The bool return correctly signals success/failure for the contract's false→-1 mapping while scene loading ignores it.

One minor consistency note: applyImage uses the module-level ImageComp import while applyTilemap and applyCamera use GameType.TilemapComp / GameType.CameraComp. Both resolve to the same engine built-in type under the !Components.has(...) guard, so this is cosmetic only.

🤖 Prompt for 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.

In `@src/jsonc/component_apply.zig` around lines 208 - 275, For consistency in the
extracted built-in apply helpers, update applyImage to deserialize using
GameType.ImageComp instead of the module-level ImageComp symbol, matching
applyTilemap and applyCamera while preserving its existing allocation,
deserialization, and attachment behavior.
🤖 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.

Nitpick comments:
In `@src/jsonc/component_apply.zig`:
- Around line 208-275: For consistency in the extracted built-in apply helpers,
update applyImage to deserialize using GameType.ImageComp instead of the
module-level ImageComp symbol, matching applyTilemap and applyCamera while
preserving its existing allocation, deserialization, and attachment behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0ba50fb3-bc02-41b4-95e9-c10d940f6bf7

📥 Commits

Reviewing files that changed from the base of the PR and between 6b3a4c4 and 5bf29fe.

📒 Files selected for processing (7)
  • build.zig
  • build.zig.zon
  • contract/labelle_script.h
  • src/jsonc/component_apply.zig
  • src/root.zig
  • src/script_contract.zig
  • test/script_contract_test.zig

apotema added 2 commits July 10, 2026 18:59
Main removed the legacy 'entities' scene key (v2.0 breaking, #592) —
CI runs the PR merge commit, so the two prefab tests' minimal scene
literal failed ubuntu-only while local/branch runs passed. Literal
now uses the root/children form. Clean merge otherwise; suite on the
merged state: 174/174 steps, 941/941 tests.

Claude-Session: https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@apotema
apotema merged commit 580a74a into main Jul 10, 2026
3 checks passed
apotema added a commit that referenced this pull request Jul 10, 2026
…_find + input (#237) (#756)

The merged v1 (#749) shipped a subset of the RFC §1 surface. This adds the
four functions the RFC lists but that landing omitted, so a version-1 host
binary actually conforms to the documented v1 surface:

  labelle_entity_find(name)        -> u64
  labelle_input_key_down(key)      -> i32 (bool)
  labelle_input_key_pressed(key)   -> i32 (bool)
  labelle_input_mouse(x_out,y_out) -> void

- entity_find resolves through a registered `Name`/`Tag` component carrying
  a string field (name/value/tag), comptime-detected over the game's own
  ComponentRegistry — a zero-cost always-0 no-op for games that register no
  such component ("by the Name/tag component if one exists"). First-match
  snapshot over the ECS view.
- input_* poll the game's static InputInterface (the same path
  game.isKeyDown/getMouse take, minus the KeyboardKey enum conversion the
  C surface skips). Main-thread, during the plugin tick. Safe pre-bind
  no-ops (keys not-down, mouse at origin).
- All four wired through the existing type-erased vtable/Holder(GP) bridge;
  contract version stays 1 (additive symbols complete v1, they don't break
  it). Header (contract/labelle_script.h) updated.

Tests: +4 (entity_find happy/no-match/destroyed + comptime-gated-out;
input key down/pressed; input mouse incl. NULL out-pointers) and the
pre-bind no-op test extended. Full engine suite green (945 tests).

Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw
apotema added a commit that referenced this pull request Jul 10, 2026
Complete the Script Runtime Contract v1 C-ABI surface (#237, Language
Plugins Phase 1). Adds the four RFC §1 symbols the initial contract
(#749/#737) omitted: labelle_entity_find + labelle_input_key_down /
_key_pressed / _mouse. entity_find resolves through a comptime-detected
Name/Tag component (folds to a zero-cost always-0 no-op when the game
registers none); input reads poll the unified InputInterface. Additive
only — contract version stays 1, all pre-bind no-ops preserved.

Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw
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.

Language plugins P1: Script Runtime Contract v1 (C-ABI, comptime-gated)

1 participant