Skip to content

Step 4: add the macOS EshiView texture host - #10

Draft
crux161 wants to merge 11 commits into
feat/larimarfrom
codex/larimar-eshiview
Draft

crux161 wants to merge 11 commits into
feat/larimarfrom
codex/larimar-eshiview

Conversation

@crux161

@crux161 crux161 commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Summary

  • add an IOSurface/CVPixelBuffer-backed macOS Flutter texture plugin with CocoaPods and Swift Package Manager metadata
  • render Brush directly into the borrowed Metal texture through a private host symbol, leaving eshi.h unchanged
  • add EshiView, source-backed material uniforms, animated Pong, and serialized resize/lifecycle/disposal
  • add widget lifecycle coverage and a real macOS integration loop across five create/resize/pause/resume/destroy cycles

Verified locally

  • zig build test (238 checks)
  • zig build abi-test (187 checks)
  • flutter analyze && flutter test in bindings/dart/larimar (11 tests)
  • binding drift check
  • flutter analyze && flutter test in examples/larimar_flutter
  • flutter test integration_test/eshiview_smoke_test.dart -d macos
  • flutter build macos --release

Remaining Step 4 gate

  • run the real lifecycle loop under leak/race diagnostics
  • retain a captured animated Pong + Flutter overlay artifact

PLAN.md keeps Step 4 in progress until those two evidence items are complete.

crux161 and others added 11 commits August 17, 2026 14:09
Step 4's last box was diagnostic evidence, and it needed two harnesses
because one binary cannot answer both questions.

check_eshiview_host.sh builds the texture adapter twice from its real
method-channel entry points and drives it from a platform thread against
a stand-in raster queue, with no engine present: once under `leaks
--atExit`, once under ThreadSanitizer. 24 cycles and 408 raster borrows
report zero leaked bytes and no races. Both passes fail when they should
— deleting LarimarTexture's surface lock produces a race report at the
resize that swaps the buffer out from under an in-flight copy, and
dropping the CVPixelBufferRelease produces one rooted CVPixelBuffer per
cycle.

check_eshiview_lifecycle.sh runs the same loop inside a real Flutter
engine and snapshots `leaks` from outside the App Sandbox, because a
sandboxed process cannot open its own task port. Twelve cycles end with
every surface reclaimed and no growth in leaked bytes between the first
cycle and the last.

The host now accounts for what it allocates — surfaces created, resized,
presented, disposed, still registered, still alive — and counts the
engine's borrows separately. That last counter is the only instrument
that separates "frames are being produced" from "frames are being
composited", and captureSurface reads a view's surface back to a PNG
without going through the compositor at all. The visual half of the gate
uses both: RepaintBoundary.toImage does not reliably include an external
texture layer, so a capture that can come back blank while everything
works is not evidence when it comes back bright either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
examples/pong/pong.mat was a hand transliteration of pong.gpu.cpp — same
sdBox, same glow, same constants, same score loop — and two copies of one
material agree right up until somebody edits one of them. It is gone. The
build now runs eshi-matgen over the shader source and pipes the result to
matc, so the Filament material is a *representation* of the file every
other tier already reads. Ripple gets the same treatment, which is what
lets a gallery shader reach Filament at all; it previously failed with
"material has no package_path".

The emitter is a third target in transpile.cpp beside GLSL and MSL, and
running it over the whole corpus is what taught us what the material
domain cannot express. 18 of the 20 programs compile through matc. The
other two refuse, loudly:

  - warp.cpp samples iChannel0, and the material declares no sampler.
  - rainforest.cpp opens its march loop inside #ifdef LOWQUALITY and
    again inside the #else. Every compiler in the chain handles that;
    matc splits a .mat into blocks by counting braces *before*
    preprocessing, so the fragment block ran past its own closing brace
    and matc reported an unexpected character on an innocent line.

A third finding was fixable rather than fatal: Filament's prelude defines
PI and HALF_PI, which lunar.cpp and seascape.cpp also use, so the
material target renames them the way the GLSL target already renames the
reserved noise builtins.

check_tier_conformance.sh turns the 1-LSB claim from a table in
ARCHITECTURE.md into a command: it renders both scenes on every tier the
host offers and compares raw frames, refusing to pass a tier that quietly
fell back to Ink. check_materials.sh holds the refusal list, which cannot
grow without someone editing it.

SHADER_SUBSET.md writes the subset down, answering ARCHITECTURE §9's
fifth open question. PLAN.md folds in the material pipeline as Step 6 and
the hero asset as Step 8, with S2L following in 6b as a frontend that
emits this subset rather than replacing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
zig-out/bin/pong --grade paper, run from any directory but the
repository root, rendered pure black frames, printed one line about a
missing shader source, and exited zero — while the banner still
announced backend=gl. The GPU tiers read their shader at runtime through
a path relative to the working directory, and pong.cpp threw away the
ESHI_ERR_UNSUPPORTED that eshi_material_set correctly returned.

So: the shader sources install to zig-out/share/eshi/shaders and resolve
from there or from ESHI_SHADER_DIR, pong::build returns its material
result, and the host refuses to run a tier that cannot bind, naming the
grade and the override rather than presenting an empty window.

`zig build demo` renders both scenes on every tier the machine offers
into build/demo, as video, beside the Flutter application and a note on
what each file should show. Every other gate in this repository answers a
machine's question; none of them answers "does it look right", and that
question is not optional for a renderer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects, all of them found by running the application rather than
by any gate:

The paddles were outside the window. The material works in
uv = (fragCoord * 2 - iResolution) / iResolution.y, so the horizontal
extent of the scene *is* the view's aspect ratio and only the vertical
one is fixed. The game placed its paddles at the 16:9 arena's edges,
which is off screen in the 4:3 window Flutter opens by default — and the
demo videos are 16:9, so they showed nothing wrong. The session now takes
its arena width from the view, and the visual gate runs at 4:3 and
requires something bright at each edge. Pinning the constant back fails
it.

There was no input at all, and the keys that did nothing also beeped:
KeyboardListener never marks an event handled, so every keystroke walked
the responder chain to AppKit. Both paddles now take the keyboard — W/S
for cyan, the arrows for magenta, matching the SDL host — through a Focus
that claims what it uses.

And a match never ended. Neither host has a win condition; the score ran
past the nine dots the material can draw and kept counting. The
application plays to nine, announces the winner, and starts again, with
an integration test that plays a one-point match and requires the
announcement to appear and then clear. The C++ Pong keeps its endless
scoring for now — its digests are what several other gates compare.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 5's first bullet, and the one the rest of it waits on. Filament is a
large CMake project with its own toolchain expectations, so consumers get
the official prebuilt release: scripts/vendor_filament.sh fetches
v1.75.0, verifies its SHA-256, and extracts it to
third_party/filament/<version>. The checksum is what makes the pin mean
anything — pointing the script at a different version's archive is
refused rather than quietly installed, which is the control that proves
it.

That path is now -Dfilament-path's default, replacing a local source
build under resources/. A renderer that links a different Filament than
the one its materials were compiled by is a debugging afternoon nobody
needs, and the old default made that the easy case.

It also closes the hole Step 6a had to leave open. matc is a build-time
tool and needs no GPU, so both the Linux and macOS jobs now *compile*
every material the emitter produces — 18 of them, with the two documented
refusals still refusing — and the macOS job renders Pong and ripple
through Filament and compares them against Ink, within 1 LSB, on the same
Metal probe the shader validation uses.

Attribution and the Apache-2.0 obligations are recorded in
third_party/NOTICE.md; the build already installs the distribution's
LICENSE beside any binary that links it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 5's substantive half. EshiAsset is an opaque uint32_t and
eshi_asset_load/_instance/_release/_count route to four *optional* vtable
entries, so the capability ladder lives in the backend table rather than
in a grade comparison: Ink, Paper and direct Metal leave them null and
every asset call answers ESHI_ERR_UNSUPPORTED. No Filament type appears
in the public header. The ABI minor moves to 1.1.0 — additive, so a
package built against 1.0 still loads — and the Dart bindings are
regenerated.

The Filament backend gains a real scene. gltfio parses and uploads;
assets are created instanced from the start, so three copies of the logo
report `instances=3 assets=1` rather than three uploads. A world may now
hold geometry and no fullscreen material at all, which is why
filament_create no longer requires a package.

Three things only a rendered frame exposed. Filament was never told to
clear, so the 3D frame came back over a magenta field with staircase
blocks of stale tile memory around the silhouette — the fullscreen quad
had been covering the question. glTF asset conventions had to be stated
rather than inherited: right-handed Y-up metres, 45° vertical FOV,
photographic exposure, one daylight directional light. And a textured
model rendered as a black silhouette, because gltfio drops every image it
has no decoder for, says so once on stderr, and carries on — the greybox
has no textures, so only a real asset showed it. The stb and KTX2
providers are registered now, and the gate fails on that stderr line.

Image-based lighting is still missing, so the reference material is a
dielectric; a metal would render dark and read as a bug rather than as
the absent feature it is.

The reference asset is generated by eshi-gemgen — an icosahedron with
flat facets and a PBR material, written straight out as a GLB — for the
same reason the materials are generated rather than checked in. PLAN Step
8 replaces it with a Blender-authored gem at the same path.

check_asset_render.sh is the gate, and it does not trust a successful
load: it renders the asset with no instances placed and with one, and
fails if the two frames match. It also requires a missing file, a corrupt
file, and the Ink tier each to be refused by name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`zig build demo` aborted with a Zig panic and a stack trace —
`pkg-config failed for library SDL2_ttf` — on a machine where every
dependency is installed. The cause is that devkitpro ships its own
pkg-config, and on PATH it wins: its built-in search path contains only
devkitpro's packages. Setting PKG_CONFIG_PATH fixes it, which is why this
never appeared in a session that exported it.

build.zig now probes pkg-config once and, only when that probe fails,
points PKG_CONFIG_PATH at the Homebrew prefix — including
Library/Homebrew/os/mac/pkgconfig, where SDL2_ttf's transitive freetype
and zlib entries actually live. A machine whose pkg-config already works
is left untouched, and a machine that still cannot find SDL gets a
sentence instead of a stack trace.

Two more things that command surfaced. The Flutter build inside the demo
failed with `library 'gltfio' not found`: a release macOS app is
universal, the pinned Filament distribution ships lib/arm64 only, and the
x86_64 slice had nothing to link. The native-asset hook now builds that
slice without the Filament backend rather than failing the whole app — it
keeps Ink and Metal, which is what a host without a tier is supposed to
do. And the skipped 3D clip now names the flag that would produce it
instead of blaming the host.

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