Repository navigation
feat(#737): Script Runtime Contract v1 — C-ABI surface for language plugins - #749
Conversation
…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
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
|
Warning Review limit reached
Next review available in: 23 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
📝 WalkthroughWalkthroughAdds 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. ChangesScript Runtime Contract
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
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
💡 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".
| 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); |
There was a problem hiding this comment.
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 👍 / 👎.
| * 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 |
There was a problem hiding this comment.
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 👍 / 👎.
| const T = Components.getType(comp_name); | ||
| game.removeComponent(ent, T); |
There was a problem hiding this comment.
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 👍 / 👎.
| pub export fn labelle_prefab_spawn( | ||
| name_ptr: [*]const u8, | ||
| name_len: usize, | ||
| json_ptr: [*]const u8, | ||
| json_len: usize, |
There was a problem hiding this comment.
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 👍 / 👎.
| const last = fp.frame_times[(fp.index + n - 1) % n]; | ||
| return last * game.time_scale; |
There was a problem hiding this comment.
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
|
All 5 findings fixed in the latest push — each with a pinning test, negative-verified (reverting each fix fails exactly its test):
909/909 tests (+5). |
There was a problem hiding this comment.
💡 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".
| const ent = castEntity(id) orelse return 0; | ||
| if (std.mem.eql(u8, name, "Position")) { | ||
| const pos = game.getComponent(ent, core.Position) orelse return 0; |
There was a problem hiding this comment.
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 👍 / 👎.
| if (!isSubscribed(@tagName(tag))) continue; | ||
| enqueueEvent(alloc, @tagName(tag), data); |
There was a problem hiding this comment.
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 👍 / 👎.
| 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); |
There was a problem hiding this comment.
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
|
Round-2 findings all fixed in the latest push (each negative-verified):
916/916 tests (+7 from baseline 909). |
There was a problem hiding this comment.
💡 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".
| // 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); |
There was a problem hiding this comment.
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 👍 / 👎.
| 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) |
There was a problem hiding this comment.
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 👍 / 👎.
| // 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; |
There was a problem hiding this comment.
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
|
One more contract refinement in the latest push, prompted by review on the consumer PR (labelle-toolkit/labelle-scripting#1): |
There was a problem hiding this comment.
💡 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".
| const comp = std.json.parseFromSliceLeaky( | ||
| T, | ||
| componentAlloc(), | ||
| json, | ||
| .{ .ignore_unknown_fields = true }, |
There was a problem hiding this comment.
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 👍 / 👎.
| const payload = std.json.parseFromSliceLeaky( | ||
| f.type, | ||
| arena, | ||
| json, | ||
| .{ .ignore_unknown_fields = true }, |
There was a problem hiding this comment.
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 👍 / 👎.
| var parser = jsonc.JsoncParser.init(arena.allocator(), json); | ||
| const value = parser.parse() catch return -1; |
There was a problem hiding this comment.
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 👍 / 👎.
| if (comptime f.type == void) { | ||
| game.emit(@unionInit(G.GameEvents, f.name, {})); | ||
| return 0; |
There was a problem hiding this comment.
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
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Rounds 3+3b in the latest push (all negative-verified):
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. |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/jsonc/component_apply.zig (1)
208-275: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueLGTM!
The helper extraction is clean — each preserves the original allocation, deserialization, and attachment semantics. The
boolreturn correctly signals success/failure for the contract'sfalse→-1mapping while scene loading ignores it.One minor consistency note:
applyImageuses the module-levelImageCompimport whileapplyTilemapandapplyCamerauseGameType.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
📒 Files selected for processing (7)
build.zigbuild.zig.zoncontract/labelle_script.hsrc/jsonc/component_apply.zigsrc/root.zigsrc/script_contract.zigtest/script_contract_test.zig
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
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…_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
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
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 → comptimeHolderpublishes a type-erased vtable; the 15export fn labelle_*symbols carry no comptime types. Pre-bind, every export is a safe no-op.root.script_contract— a build that never references it emits no symbols (the samenm-verified gateeditor_apiuses).bind(&g)after init;drainEvents(&g)after tick, beforedispatchEvents(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 atcontract/labelle_script.h(LABELLE_CONTRACT_VERSION 1).Notable implementation decisions (all documented in-file):
Positionincluded and routed throughsetPositionso render dirty-tracking fires; slice fields use the scene bridge'snested_entity_arenalifetime convention; visual built-ins with renderer side-tables (Sprite/Shape/Camera/…) are explicitly out of v1."<name> <json>"FIFO, truncated entry still consumed, 256-cap drop-newest, subscribe dedupes, rebind = fresh session.time_dtreads the frame-profiler ring ×time_scale(no extra generated-main splice);querywrites 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 = voidand empty-registry (engine.Game) compile-and-degrade paths.Epic: #237 · next consumer: labelle-scripting bootstrap (#738)
https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j