Skip to content

ci(gallery): validate Zig-authored shaders - #6

Merged
crux161 merged 1 commit into
feat/larimarfrom
codex/larimar-complete-gallery-gate
Aug 17, 2026
Merged

crux161 merged 1 commit into
feat/larimarfrom
codex/larimar-complete-gallery-gate

Conversation

@crux161

@crux161 crux161 commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Closes the last false-green hole in PLAN Step 1: the gallery validator only globbed C++ sources and silently omitted the Zig-authored Harlequin binary.\n\nLocal OpenGL validation now reports passed=20, failed=0, skipped=0.

@crux161
crux161 merged commit 23c8092 into feat/larimar Aug 17, 2026
3 checks passed
@crux161
crux161 deleted the codex/larimar-complete-gallery-gate branch August 17, 2026 19:17
crux161 added a commit that referenced this pull request Aug 17, 2026
* added missing vendor scripting

* docs(larimar): architecture proposal for the Filament/S2L direction

Records the original Larimar vision verbatim (PROPOSAL.md) and adds the
engineering plan that continues it with the Gyosho monorepo factored in
(ARCHITECTURE.md).

Central reframe: SumiC's 632-line frontend is the differentiator, its
229-line backend is not. Retarget codegen to emit .mat for matc rather
than competing with Filament on per-backend translation, and keep the
S2L language, the CPU/GPU parity guarantee, and Kantei-aware compilation.

Kantei's Grade ladder becomes the backend selector: Filament covers
Paper/Brush/Gold, the existing OpenMP renderer stays as Ink, which is
below Filament's GLES 2.0 floor and is the tier the gallery and the
embedded targets live on.

Also reorders the phases so First Light (Pong on an abstracted engine)
lands on the Ink backend before Filament is introduced, and records
corrections on Filament's existing ECS and gltfio, the Dart hot-reload
duplicate-entity trap, per-frame FFI cost, and the Phase 4 NPU claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(larimar): Phase 0 — engine core, Ink backend, First Light pong

Carves the OS-oblivious boundary and ports Pong onto it, with no Filament
and no Rust. Proves the API shape against a real game before the hardest
dependency lands, rather than after.

core/include/eshi/eshi.h is the entire public surface, pure C99: no
platform, graphics API, or C++ type crosses it. One header serves three
consumers unchanged — C++ hosts include it, Zig reaches it via @cImport,
Dart will generate bindings from it in Phase 3.

core/ implements a sparse-set SoA ECS with generational handles, a
deterministic system scheduler, generic AABB collision with layers and an
event queue, a fixed timestep with clamped catch-up, and a seeded RNG.
render_ink.cpp is Kantei Grade 1: the CPU/OpenMP fullscreen material path,
which lives below Filament's GLES 2.0 floor and is the tier the gallery and
the embedded targets need. Non-Ink grades are rejected outright rather than
silently downgraded.

The fix that mattered for First Light: the uniform block is opaque to the
engine. On feature/pong-engine, adding one game put a GameData struct into
the shared math header and a fourth parameter onto every backend's
renderFrame(). Here the game declares its own layout, the engine forwards a
void*, and no backend signature names a game. examples/pong is a scene, a
uniform block, three systems and a shader — no main(), no SDL, no IGame,
and no ResolvePaddleBounce: the engine separates bodies and reflects
velocity generically, and the game adds English and speed-up by reading
collision events. Walls are static colliders rather than an if in the loop.

Verified: 38 core checks pass; all 20 gallery examples still build and
render; framebuffer digests are identical across runs at equal seed and
differ across seeds. The encoded .mp4 bytes are not yet reproducible — that
nondeterminism is in the shared FFmpeg/x264 path in encoder.h, not in the
engine, and is recorded in the architecture doc as a Phase 4 prerequisite.

zig build test links core/ alone — no SDL, no FFmpeg, not even libsumi — so
the OS-oblivious rule is enforced by the build rather than by convention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(larimar): salvage eshi's GL and Metal renderers as Kantei grades 2-3

Filament is not needed to fill Paper and Brush for the fullscreen-material
model, because eshi already had those backends. renderer_gl.h,
renderer_metal.mm and renderer_gpu.cu all exposed the same
renderFrame(pixels, stride, time) signature the Ink backend uses — which is
why main.cpp could dispatch between them with nothing but #ifdefs. This
ports the first two in behind a backend vtable, so Filament can later earn
its place on what it uniquely adds (PBR, meshes, glTF, shadows) rather than
on ground already covered.

Backends are selected by Kantei grade through a registry; absent ones get a
stub that reports unavailable, so eshi_grade_available() answers honestly in
a build that lacks them and eshi_world_create() still refuses rather than
silently downgrading. EshiMaterial now carries both a compiled-in
cpu_shader for Ink and a source_path for the GPU tiers, which is what lets
one material run on every grade — and which gives the GPU tiers shader
hot-reload for free, since rebinding recompiles.

Two things the port forced, both improvements. The GL backend had to be
inverted: the original created its own hidden SDL window and called
SDL_GL_GetProcAddress, which the core may not do. The host now owns the
context and passes its loader through eshi_gl_set_proc_loader(), and the
core declares its own GL typedefs rather than including a GL header — which
is also what will let the Flutter embedder drive this backend with no SDL
window in existence. And the shader transpiler, previously duplicated
between the GL and Metal renderers, is now singular in
core/src/render/transpile.cpp with the target-specific rules separated and
commented. That consolidation is the prerequisite for writing the C++ shader
subset down as a spec.

Verified on an M4 Pro: examples/ripple.cpp is compiled in for Ink and handed
to the GPU tiers as a source path — one file, both roles. At 480x270 through
lossy H.264, Brush vs Ink is 53.4 dB PSNR and Paper vs Ink is 53.3 dB, well
above the ~40 dB that reads as visually identical. Bit-exact CPU/GPU
agreement is not achievable and was never the goal; this is the
Ink-as-reference-oracle property working. 51 core checks pass and all 20
gallery examples still build.

Known limit: pong stays Ink-only, since its shader takes a typed Uniforms&
struct a textual transpiler cannot lower. The GPU tiers do support uniform
blocks as a flat eshi_uniforms float array, so a GPU sidecar against that
array would lift it — the pattern examples/gpu/ already uses.

Also records what fluorite.game actually claims, so the comparison rests on
their description rather than guesses, and names the two things a
Filament-only engine structurally cannot have: a tier below Filament's
hardware floor, and offline procedural video export.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(larimar): pong runs on every tier via a GPU sidecar

examples/pong/pong.gpu.cpp is the same material re-expressed against the
flat eshi_uniforms float array, because a textual transpiler cannot lower
pong.cpp's typed `const Uniforms&` parameter into a shader binding. Same
pattern as examples/gpu/, for the same reason. pong.h now static_asserts the
struct stays tightly packed, so a layout drift is a build failure rather than
a garbled GPU frame.

One constraint the transpiler imposes, recorded in the sidecar: every read of
eshi_uniforms has to happen inside mainImage. GLSL exposes it as a global,
but MSL binds it as a kernel argument threaded through mainImage's signature,
so a helper function has no way to see it.

fix(render/gl): flip the readback

glReadPixels returns rows bottom-up while the engine's framebuffer is
top-down, and the readback was copying straight through — so every GL render
came out vertically mirrored against the CPU one. renderer_gl.h:323 has the
same bug; it survived unnoticed because most of the gallery, and Pong, is
close enough to vertically symmetric to hide it.

This is what the Ink-as-oracle property is for. The first cross-tier pixel
diff surfaced it in one run, having gone unseen for the life of the renderer.
The staging buffer for the flip lives in the backend struct so the row
reversal costs no per-frame allocation, and the memset over GlBackend was
narrowed to the function table now that the struct holds a std::vector.

Adds --dump DIR to the host, writing raw PPM frames with no codec in the
path. Comparing tiers through a lossy encoder measures the encoder; this is
the tool the conformance check actually needed. Measured on an M4 Pro at
320x180: pong over 120 frames and ripple over 60 frames both agree with Ink
to a maximum of 1 LSB out of 255 on Paper and Brush alike, mean 0.0000.

Note the --hash digests still differ across tiers, and should — that digest
answers "is this tier deterministic", not "do two tiers agree". Different
questions, different tools.

51 core checks pass; all 20 gallery examples still build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(renderer_gl): correct the readback and the missing glsl:: strip

Applies to the legacy shadertoy path the same fix the Larimar port needed.
This is not dead code: USE_OPENGL is defined by scripts/build.arm64.bat, the
Windows-on-ARM64 / Snapdragon build the README documents as using OpenGL.
Neither the Makefile nor build.zig defines it, which is how the defects below
survived — nothing anyone runs day to day compiles this file.

The one-line readback got three things wrong at once:

  Orientation. glReadPixels returns rows bottom-up, while every consumer here
  is top-down — CpuRenderer writes row 0 from the largest fragCoord.y, and
  both Display and SimpleEncoder read it that way. Copying straight through
  mirrored every GPU frame vertically against the CPU renderer.

  Format. It asked for GL_RGB, three bytes per pixel, into buffers that are
  RGBA: Display uses SDL_PIXELFORMAT_RGBA32 and SimpleEncoder uses
  AV_PIX_FMT_RGBA. The short rows were then read as four-byte ones, skewing
  the image and shifting the channels.

  Stride. The destination pitch parameter was ignored outright. FFmpeg aligns
  linesize, so it is not always width * 4.

Separately, readFile never stripped the glsl:: namespace qualifier, so any
shader written as glsl::length(...) emitted invalid GLSL and failed to
compile — examples/ripple.cpp among them. renderer_metal.mm has stripped it
since it was written; this copy of the transpiler had drifted behind. That
divergence is the concrete argument for the consolidation in
core/src/render/transpile.cpp, which carries both rules in one place.

Verified by building the legacy path with -DUSE_OPENGL and rendering the same
shader through both renderers. examples/deepsea.cpp is vertically asymmetric,
which ripple is not — a flipped ripple looks almost identical to an upright
one, so it cannot test orientation. Against the CPU reference at frame 100,
the GL output now scores mean |diff| 1.86 upright versus 52.99 against a
vertically flipped reference, so the orientation is unambiguously correct.

Still wrong and deliberately left alone: a failed shader compile is logged by
checkShader() but does not stop construction, so the renderer prints
"[GL] Ready." and proceeds to emit black frames. Changing that is an
error-handling decision, not a bug fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(renderer_gl): fall back to CPU instead of emitting black frames

GlRenderer logged shader failures and carried on regardless, so a shader it
could not build produced a silent video of black frames — and printed
"[GL] Ready." on the way. That is how the missing glsl:: strip fixed in the
previous commit stayed hidden.

Construction now throws on every failure it can detect, and main.cpp catches
it the same way it already catches MetalRenderer, leaving gpu_init false so
the existing CPU fallback takes over. No new mechanism: this is the contract
the Metal backend has always used, extended to the one that lacked it.

Four failures were previously unreported or fatal:

  Fragment shader compilation was logged to stderr and ignored. Now throws,
  naming the source file — the transpiled GLSL is generated rather than
  authored, so a bare line number in the driver log points at text the user
  has no way to open.

  Vertex shader compilation was never checked at all.

  Program link status was never checked at all.

  A missing shader file called exit(1) from inside a header, killing the
  process and denying the caller the CPU fallback it already knew how to do.
  Now throws like the rest.

Framebuffer incompleteness was also logged and ignored; it throws too.

Because a destructor does not run for an object whose constructor threw, the
new fail() helper releases the GL context and hidden window before throwing.

Verified against examples/seascape.cpp, whose GLSL genuinely does not compile
("'&' : syntax error" at line 194 of the generated source). With --gpu it now
reports the reason, falls back, and renders: the output is bit-identical to a
plain CPU run, mean |diff| 0.0000 and max 0, with 172330 of 172800 pixels
non-black. examples/deepsea.cpp still initializes and runs on GL, so the good
path is unchanged, and the Metal and Larimar paths are untouched — 51 core
checks pass and all 20 gallery examples still build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(transpile): make the whole gallery compile on GL and Metal

seascape and lunar failed to transpile, and chasing them turned up seven
shaders failing across the two targets for nine distinct reasons. Every one
is a construct that is valid C++ and has no direct equivalent in the target
shading language — which is the actual content of the "C++ shader subset"
that has never been written down.

GLSL-only, all in the reference-parameter and C-syntax families:

  Reference parameters other than `vec4 &` were unhandled, so seascape,
  lunar, mario and rainforest emitted a bare `&`. These now map to `inout`,
  not `out`: mario's sprite helpers take `vec3& color` and composite onto
  what is already there, so `out` would make every sprite erase the
  background. A transpiler cannot assume write-before-read. `const T&` maps
  to `in` instead, and must be matched first — qualifying it `inout` while
  leaving the const yields `const inout vec4`, which GLSL rejects.

  `(void)x;` unused-parameter casts, C-style casts of both bare operands and
  parenthesised expressions, and C++ direct-initialization (`vec2 c(0,1);`)
  were all passed through verbatim.

Metal-only:

  atan2f was mapped to atan, but MSL splits one- and two-argument arctangent
  into atan and atan2 — polar.cpp broke on it. It now maps to atan2, and
  file-scope two-argument overloads restore the GLSL spelling for shaders
  that call atan(y, x) directly, which no define can reach because the fix
  depends on arity.

  `mod` is GLSL's spelling and MSL has only fmod.

Both targets:

  exp2f, log2f, tanhf, roundf and truncf were unmapped.

  A host-only declaration that opened a block had only its signature dropped,
  leaving the body orphaned at program scope. mario and tunnelwisp both
  define `extern "C" vec2 mainSound(...)` for the audio thread; the readers
  now track braces and take the body with the signature.

Two more cases of the GL copy having drifted behind the Metal one, the same
way the missing glsl:: strip had: the swizzle rewrite was a hardcoded list of
eight spellings that silently missed lunar's .xz(), and the global-scope
qualifier in tunnelwisp's ::tanhf was never stripped. Both now use the
general form Metal has always used. This divergence is the argument for
core/src/render/transpile.cpp holding one copy.

Verified: all 19 gallery shaders compile on Metal, and seascape, lunar,
mario, rainforest, polar, tunnelwisp, ripple, warp and voronoi all compile
and run on the legacy GL path rather than falling back. Larimar cross-tier
parity is unchanged at max 1 LSB, and 51 core checks pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* build: add -Dopengl, and fix the last shader it exposed

USE_OPENGL was defined only by scripts/build.arm64.bat, so neither the
Makefile nor build.zig ever compiled renderer_gl.h. That is how a vertical
flip, an RGB/RGBA mismatch, an ignored stride, silent shader-compile
failures and a drawer of transpiler gaps all survived in it. This makes the
path buildable from the normal build graph so it can be checked routinely.

Off by default, deliberately. On macOS enabling it also takes precedence over
Metal, because main.cpp's dispatch tries CUDA, then OpenGL, then Metal; and
elsewhere it would add a libGL requirement to builds that are content on the
CPU path today. The README says both, and says to turn it on when touching
anything shared with the GPU backends — the transpiler above all, whose rules
differ per target and are easy to fix on one while breaking the other.

Larimar's own GL backend loses its -Dgl gate and is now always compiled.
Unlike the legacy renderer it declares its own GL typedefs and resolves every
entry point through the loader the host installs with
eshi_gl_set_proc_loader(), so it links neither a GL library nor a windowing
library. There was nothing for the option to save, and two flags a letter
apart would only have invited confusion.

Building the whole gallery this way turned up one more shader: aurora.cpp
declares `float noise2(vec2)`, and noise1..noise4 are reserved GLSL built-ins
returning genType, so it was a return-type redeclaration. Definition and call
sites are renamed together, which keeps the shader self-consistent and costs
nothing — the built-ins are deprecated, gone from core profiles, and return 0
on most drivers. Metal has no such names, so this is GLSL-only.

Verified with all 19 gallery shaders compiling and running on the legacy GL
path under -Dopengl=true, all 19 still compiling on Metal in the default
build, 51 core checks passing, and Larimar unchanged.

One measurement note for anyone repeating this: running the shaders in
parallel makes six of them appear to fail with empty output. That is
contention between twenty simultaneous GL contexts, not a shader defect —
they pass sequentially. Check this corpus one at a time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: build and validate the OpenGL path on every push

First CI in the repo, scoped to the gap that motivated it. USE_OPENGL was
defined only by scripts/build.arm64.bat, so no routine build ever compiled
renderer_gl.h, and it accumulated a vertical flip, an RGB/RGBA mismatch, an
ignored stride, silent shader-compile failures and a drawer of transpiler
gaps. The transpiler is the standing hazard: its rules differ per target, so
a fix verified on Metal can silently break GLSL. Only building both catches
that.

Adds --validate to main.cpp: initialize the renderer, report which backend
came up, exit without rendering. The CPU fallback is deliberately quiet at
runtime — a shader the backend cannot build should still produce a video
rather than black frames — which is right for users and useless for CI. This
makes the same condition loud on demand, and avoids timing a 240-frame render
to read one line of output.

scripts/check_gpu_shaders.sh drives it over the gallery and is the actual
test, so it runs locally as well as in CI. Two things it does deliberately:

  It requires the [validate] marker, not just a zero exit. A binary predating
  the flag ignores it, renders all 240 frames and exits 0 — which an
  exit-code-only check calls a pass. That is not hypothetical: it reported a
  false 19/19 against a stale zig-out during development, and the hardened
  check correctly failed 18 of those 19.

  It is sequential. Running the shaders concurrently makes unrelated ones
  report empty output and look like compile errors; twenty simultaneous GL
  contexts contend that badly. Six shaders "failed" that way and all passed
  one at a time.

The Linux job supplies what a runner lacks: Xvfb for the X server SDL needs to
create a context, and Mesa llvmpipe for GL 3.3 in software. Slow, and fine —
the assertion is that each shader compiles and links on the GL backend, not
that it renders quickly. A core-tests job runs alongside so a red GL job reads
as "the GL path broke" rather than "something broke"; it installs no
dependencies, which is itself the check that the OS-oblivious boundary in
ARCHITECTURE.md §5 still holds.

Verified locally: 19/19 on the GL build, 19/19 on Metal, the script exits 1
against a build with no GPU backend and against stale binaries, and
`zig build test` passes with pkg-config pointed at a nonexistent path. The
workflow YAML parses and the job graph is as intended, but it has not been
executed on a runner — the Xvfb and llvmpipe steps are unproven until it runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(encoder): hardware video export via the Apple media engine, and a macOS CI job

SimpleEncoder now prefers VideoToolbox when it is present, routing H.264 and
HEVC through the dedicated media engine instead of libx264. Measured on an M4
Pro at 1080p over 240 frames, that is 3.14s to 1.28s end to end — 2.4x — and
it leaves the CPU cores for rendering, which matters most on the Ink tier
where the renderer wants all of them.

Quality measured against the raw rendered frames rather than against each
other, using the host's --dump output as ground truth: libx264 46.3 dB PSNR,
VideoToolbox 45.6 dB. The 0.7 dB is the usual hardware trade at equal bitrate
and both are well above the ~40 dB that reads as visually identical. Comparing
the two encoders to each other gives 26.6 dB and means nothing — that is two
lossy encodes disagreeing, not either one being unfaithful.

`--encoder auto|hw|sw` selects, and `--hevc` switches codec. auto is the
default and falls back to software if VideoToolbox is absent or refuses a
session, which it can do under virtualization; hw demands hardware and fails
loudly; sw pins libx264. Use sw when output must be comparable across
machines — hardware encoders make no bit-reproducibility guarantee across
silicon or driver revisions, which forecloses the reproducible-export work
tracked in ARCHITECTURE.md §7 on that path. Both hosts take the flag; the
encoder needs no platform #ifdef, since the VideoToolbox encoders only exist
in an Apple FFmpeg build and looking them up by name is enough. Colour range
is now stated rather than inferred, which also silences a VideoToolbox warning
about guessing it.

The macOS job covers the other half of the transpiler. Its GLSL and MSL rules
diverged repeatedly — a missing glsl:: strip, a hardcoded swizzle list, atan2f
mapped to the wrong builtin — each compiling cleanly on one target while
failing on the other, so neither job alone would have caught them.

Two deliberate softenings there, both because a hosted macOS runner is
virtualized. Metal is probed before the shader check runs, so "no GPU in the
VM" is a notice and a skip rather than a red build that looks like a
transpiler regression. And the encoder step asserts only that both paths
produce a decodable stream, not that hardware was chosen, because falling back
to software is the designed behaviour.

The probe itself is hardened to fail when the binary is missing. Rehearsing it
locally, an absent eshi made it report "no Metal" and skip silently — the same
false-pass shape as the stale-binary hole closed in the previous commit.

Verified locally: 19/19 shaders on Metal and on the GL path, 51 core checks,
all four encoder modes producing valid streams at the right frame counts, and
the workflow YAML parsing with the intended three jobs. The macOS and Linux
jobs have still not run on a runner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(larimar): Filament First Light — Brush grade on a real renderer

Phase 1b's opening move. core/src/render/filament.cpp implements the same
backend vtable the Ink, GL and Metal tiers already satisfy, driving a headless
offscreen Filament view, and Brush selects it in -Dfilament=true builds.
Nothing about the C API moved to accommodate it: no eshi_* signature names
Filament, which is the §2 rule that keeps the gallery alive through the
transition, and the readback preserves the framebuffer contract so a Flutter
hardware-texture host can later delete that copy without touching the ECS or
the game.

EshiMaterial grows a third representation. cpu_shader is compiled in for Ink
and source_path is transpiled at runtime by the lightweight GPU tiers, but a
packaged renderer wants neither — it wants a package built offline, so
package_path joins them and the backend vtable's create() takes both. The
asymmetry that follows is worth stating: source-backed tiers hot-reload by
rebinding after a file change, package-backed tiers rebind a rebuilt package,
and Ink cannot hot-reload at all because its shader is in the binary. The
material API does not change across any of that.

The entity layout moves from 24+8 to 17+8 bits. Not arbitrary: Filament's
utils::Entity is GENERATION_SHIFT 17 / GENERATION_BITS 8 with a maximum index
of 2^17-1, and matching it exactly is what lets §6.4's plan — adopt
utils::Entity as *the* entity id rather than run a second ECS alongside
Filament's — happen without a translation table. It costs a ceiling of 131071
simultaneous entities, which is not a ceiling this engine is near.

zig build larimar -Dfilament=true compiles examples/pong/pong.mat with matc,
installs the package at share/eshi/pong.filamat, and installs Filament's
LICENSE alongside it, since Apache-2.0's attribution obligations are real even
when the dependency is consumed as a build product. -Dfilament-path points at
an already-installed distribution and -Dfilament-arch overrides the library
directory when it is not the inferred arm64 or x86_64.

It stays off by default, and that is a build-time judgement rather than a
confidence one: the vendored checkout is source, so enabling it by default
would put a multi-minute native dependency build in front of every ordinary
Ink or Paper build. §6.8's preference for official prebuilt archives still
stands and is still not done.

eshi_world_backend_name() is new and small but earns its place — with two
different implementations now reachable at Brush grade, "which renderer did I
actually get" stopped being answerable from the grade alone.

Still to do in this phase: mesh and renderable components, PBR, shadows,
gltfio, and consuming prebuilts rather than building from source. What landed
is a fullscreen material on Filament, which is the tier boundary proven, not
the 3D scene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(larimar): Phase 3's native half — command buffer and scene reconciler

§6.6 and §6.7 built and proven, with no Dart anywhere near them. That ordering
is the point: both are testable in C++ with no ffigen, no embedder and no
Flutter toolchain in the way, so the FFI contract gets settled before the
hardest dependency in the project has a vote on its shape. Building the
acceptance test last is how you find out in month six that the API was wrong.

The transport is a flat array of 32-bit words the world owns and a caller maps
once — Dart wraps the same bytes in an Int32List and a Float32List through
asTypedList, writes packed commands, and calls eshi_commands_flush() once per
frame. Framing is [opcode:16 | payload_words:16] followed by payload; events
travel the same framing in reverse. The length field bounds-checks a buffer
another language wrote and lets a newer writer append payload words an older
reader ignores.

It deliberately does *not* license skipping unknown opcodes, which is the
usual choice for a forward-compatible format and the wrong one here: a core
that quietly drops an opcode its Dart package emits renders a scene that is
wrong rather than one that is missing, and that surfaces as an art bug weeks
later. Unknown opcode stops the flush with ESHI_ERR_UNSUPPORTED. Opcode
compatibility is a version contract, not something to paper over at runtime.

The reconciler is the part §6.6 warned was underestimated, and it was. A
submission is a description of keyed nodes; the reconciler diffs it against the
live ECS and emits create/update/destroy. Dropping a node destroys its entity,
on SCENE_END and nowhere else — so a submission truncated mid-flight leaves the
live scene alone instead of deleting whatever did not arrive, and a scene may
span several flushes for the same reason.

What was not obvious going in: idempotent is not sufficient. The first working
version wrote every described component on every pass. It never duplicated
anything and was still useless, because a reload mid-rally teleported the ball
back to its spawn point. Retained mode has to write a component only when its
*described* value changed, which means holding the previous description and
diffing against it — exactly the role Flutter's Element tree plays between
Widget and RenderObject. Described values are compared as raw words rather than
decoded floats, because NaN != NaN would re-fire a value that never changed and
-0.0 == 0.0 would hide one that did.

Epoch gating covers the case that motivated all of this: a closure that
outlived a reload and still holds a buffer will happily flush the scene it was
built for, and without a gate that submission sweeps away everything the new
code just created. An older epoch returns ESHI_ERR_STALE and sweeps nothing.
The gate is scoped to scene topology — ESHI_CMD_INPUT is not part of the
description and always applies, so a stale scene cannot also eat a frame of
input.

Pong is the proof rather than the tests being it. examples/pong/pong.cpp no
longer calls eshi_entity_create at all; its scene is a description submitted
through the command buffer, and pong::reload() re-submits at a fresh epoch.
Digests at seeds 1, 42 and 99 are bit-identical to the imperative version this
replaces (f3f0d79e16ec78b6, b9321cc6ecbe3e26, 657b25ed9010692e), so the
reconciler reproduces the old scene exactly rather than approximating it.
Cross-tier conformance is unchanged: Brush vs Ink over 120 frames is mean
0.0000/255, max 1 LSB.

--reload N re-submits every N frames and must not move the digest. It does not,
down to --reload 1 — the entire scene re-described on all 400 frames, framebuffer
identical, entities=5 nodes=5 throughout. Built imperatively those same reloads
would leave 2000 entities behind.

What is absent from pong's description turned out to be the interesting part.
The ball has a collider and a restitution but no transform and no velocity:
those are simulation state owned by serve(), and declaring them would put the
reconciler and the game in an argument over the ball every reload. Describe
what the scene *is*, let systems own what it is *doing* — a rule easier to state
now than to retrofit once widgets are writing scene descriptions.

Every new test passed on the first run, so they were checked by sabotage rather
than trusted: disabling change detection fails 2, breaking key lookup fails 13.
One assertion was passing by comparing two nulls and is now guarded. 52 checks
to 132.

The CI gate got the same treatment and needed it. Comparing baseline against
--reload only catches regressions that manifest differently under reload; a
reconciler broken *uniformly* breaks both runs identically and sails through,
which is exactly what a sabotaged key lookup did. It now also rejects a pong
that scores 0-0 over 400 frames, since a scene that never resolved has nothing
to keep invariant. Verified firing under both sabotages and passing clean.

Known limitation, documented rather than left to be discovered: dropping a
component from a node does not remove that component, because the core has no
per-component removal yet. Drop the node instead.

Remaining in this phase: ffigen bindings, the Flutter embedder, and the
external-texture path (§6.11).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci(larimar): establish the RC delivery gate

* ci(opengl): isolate the GPU validation path

* fix(transpile): preserve tanh builtin overloads

* docs(larimar): close the release-gate slice (#5)

* ci(gallery): validate Zig-authored shaders (#6)

* feat(larimar): freeze native contract (#7)

Complete PLAN Step 2 with ABI negotiation, versioned wire fixtures, hostile-input coverage, and sanitizer-backed release gates.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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