diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b39a0b..31ec5db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,24 @@ jobs: - name: Fetch libsumi run: ./scripts/vendor.sh + # Pinned by scripts/vendor_filament.sh and verified by checksum. Cached + # on the version, so an upgrade is a cache miss rather than a stale hit. + - name: Cache the Filament distribution + uses: actions/cache@v4 + with: + path: third_party/filament + key: filament-${{ runner.os }}-v1.75.0 + + - name: Vendor Filament + run: ./scripts/vendor_filament.sh + + # matc is a build-time tool, so this half of the material gate needs no + # GPU and runs on the Linux job as well as the macOS one. + - name: Compile a Filament material for every shader + run: | + zig build tools + ./scripts/check_materials.sh + - name: Build with the OpenGL backend # This job gates the GPU/transpiler path. Keep the CPU OpenMP backend # out of this build so a runner's GCC runtime layout cannot prevent the @@ -56,6 +74,21 @@ jobs: GALLIUM_DRIVER: llvmpipe run: xvfb-run -a ./scripts/check_gpu_shaders.sh + # Compiling is not agreeing. The transpiler can produce a shader that + # builds cleanly and draws the wrong picture — an inverted axis, a lost + # swizzle — and only a pixel comparison against the CPU tier catches that. + # llvmpipe is slow and entirely adequate: the assertion is agreement, not + # frame rate. + - name: Paper and Ink agree to 1 LSB + env: + LIBGL_ALWAYS_SOFTWARE: '1' + GALLIUM_DRIVER: llvmpipe + LARIMAR_CONFORMANCE_FRAMES: '20' + LARIMAR_CONFORMANCE_RES: 160x90 + run: | + zig build larimar tools -Dopengl=true -Dopenmp=false + xvfb-run -a ./scripts/check_tier_conformance.sh + # The other half of the transpiler check. Its GLSL and MSL rules diverged # repeatedly — a missing glsl:: strip, a hardcoded swizzle list, atan2f # mapped to the wrong builtin — each time compiling cleanly on one target @@ -76,6 +109,15 @@ jobs: - name: Fetch libsumi run: ./scripts/vendor.sh + - name: Cache the Filament distribution + uses: actions/cache@v4 + with: + path: third_party/filament + key: filament-${{ runner.os }}-v1.75.0 + + - name: Vendor Filament + run: ./scripts/vendor_filament.sh + # Metal is on by default here and LTO is off by default on macOS, since # Zig's LLD cannot link Mach-O with it. - name: Build everything @@ -107,6 +149,30 @@ jobs: if: steps.metal.outputs.available == 'true' run: ./scripts/check_gpu_shaders.sh + - name: Brush and Ink agree to 1 LSB + if: steps.metal.outputs.available == 'true' + run: ./scripts/check_tier_conformance.sh + + # Every shader still produces a material, the two documented refusals are + # still exactly two, and matc accepts the other eighteen. + - name: Compile a Filament material for every shader + run: ./scripts/check_materials.sh + + # The Brush tier through Filament rather than direct Metal, from the same + # generated materials, against the same CPU reference. + - name: Filament and Ink agree to 1 LSB + if: steps.metal.outputs.available == 'true' + run: | + zig build larimar tools -Dfilament=true + ./scripts/check_tier_conformance.sh + + # glTF through gltfio, and the failure paths that matter more than the + # happy one: an empty scene must not pass for a drawn one, and a missing + # or corrupt asset must be refused by name. + - name: The reference asset loads, instances, and draws + if: steps.metal.outputs.available == 'true' + run: ./scripts/check_asset_render.sh + - name: Larimar core tests run: zig build test @@ -208,14 +274,14 @@ jobs: - name: Check generated bindings working-directory: bindings/dart/larimar run: | - dart pub get + flutter pub get ../../../scripts/check_dart_bindings.sh - - name: Analyze and test the Dart package + - name: Analyze and test the Flutter FFI package working-directory: bindings/dart/larimar run: | - dart analyze - dart test --reporter expanded + flutter analyze + flutter test --reporter expanded - name: Analyze and test the Flutter reference app working-directory: examples/larimar_flutter @@ -236,12 +302,12 @@ jobs: flutter-version: 3.38.9 cache: true - - name: Analyze and test the Dart package + - name: Analyze and test the Flutter FFI package working-directory: bindings/dart/larimar run: | - dart pub get - dart analyze - dart test --reporter expanded + flutter pub get + flutter analyze + flutter test --reporter expanded - name: Analyze and test the Flutter reference app working-directory: examples/larimar_flutter @@ -249,3 +315,29 @@ jobs: flutter pub get flutter analyze flutter test --reporter expanded + + # The texture adapter's own gate, with no engine in the way. `leaks + # --atExit` reports any surface the adapter failed to release, and the + # ThreadSanitizer pass reports any unsynchronised access between the + # platform thread and the raster queue. Removing the surface lock from + # LarimarPlugin.mm makes the second pass fail, which is the point: the + # engine-level test below cannot distinguish a held lock from a lucky one. + - name: Texture host under leak and race diagnostics + run: ./scripts/check_eshiview_host.sh + + # The engine-level half. The reference application really does create, + # resize, background, foreground and destroy an EshiView, and `leaks` + # snapshots the process from outside the App Sandbox — a sandboxed process + # cannot open its own task port — after the first cycle and after the + # last. Growth between the two is the leak, not the absolute count: the + # OS frameworks arrive with leaks of their own. + - name: EshiView lifecycle under leak diagnostics + run: ./scripts/check_eshiview_lifecycle.sh + + - name: Keep the diagnostics and the composited frames + if: always() + uses: actions/upload-artifact@v4 + with: + name: eshiview-diagnostics + path: build/larimar-diagnostics + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 206823f..0b00fe1 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,12 @@ clean.py *.o.tmp .zig-cache/ zig-out/ +PLAN.md +ARCHITECTURE.md +resources/filament/ + +# Fetched distributions; scripts/vendor_filament.sh pins and verifies them. +third_party/*/ # --- Extras --- VENDORED @@ -63,6 +69,8 @@ x/ *.jpg # Exception: Uncomment the next line if you want to keep texture assets # !texture.jpg +# Exception: gate evidence — captured frames a release step is judged against +!docs/larimar/media/*.png # --- OS Junk --- Thumbs.db diff --git a/PLAN.md b/PLAN.md index c7b5c06..89791f5 100644 --- a/PLAN.md +++ b/PLAN.md @@ -21,6 +21,10 @@ complete product slice: 7. The same core still runs Pong and the shader gallery through the Ink, Paper, and Brush grades, including deterministic headless rendering and video export. +8. One authored material source produces both the Filament material and the CPU + entry point, with no shader maintained twice and the tiers agreeing to 1 LSB. +9. A hero asset — a spinning, refractive logo — renders that whole stack as one + image: glTF geometry, a compiled material, native rotation, Flutter UI above. That is the smallest honest release candidate comparable in nature to [Fluorite's public description](https://fluorite.game/): Dart authoring, a @@ -61,6 +65,9 @@ claim shipping console support or superiority that has not been measured. and application lifecycle handling. - Filament mesh/renderable, transform, camera, light, material, and glTF asset support sufficient for one small PBR scene. +- A dual-target material compiler: one authored source emits both a `.mat` + definition that `matc` compiles for Filament and the `mainImage` entry point + the Ink tier executes, with the shader subset written down as a specification. - Retained Dart scene declarations, live hot reload, shared state, two views, picking, and one glTF `extras` trigger event. - Automated native, Dart, widget, integration, conformance, and smoke tests. @@ -72,9 +79,13 @@ claim shipping console support or superiority that has not been measured. - Console SDK integration, certification, or a public "runs on consoles" claim. - Windows, iOS, Android, Web, and production Embedded Linux hosts. - SDL3 migration unless it blocks an RC gate; SDL2 remains a supported harness. -- Jolt/3D physics, audio redesign, Gold/NPU work, SumiC retargeting, an editor, - and a broadphase rewrite. +- Jolt/3D physics, audio redesign, Gold/NPU work, an editor, and a broadphase + rewrite. - General-purpose 3D coverage beyond the reference glTF vertical slice. +- S2L as an authoring language for anything outside the gallery corpus, and the + WGSL/`hanga` live-coding loop. Step 6b brings the language in as a source + generator; making it the only way to author a Larimar material does not + belong in RC0. ## 4. Ordered execution plan @@ -136,42 +147,224 @@ analysis/widget test on Linux x86_64 and macOS Apple Silicon. Both Dart FFI jobs are strict required checks on `feat/larimar`; the scaffolded macOS application also builds locally with the native library embedded. -### Step 4 — Land `EshiView` on macOS +### Step 4 — Land `EshiView` on macOS — **complete** Dependency: Step 3. -- Implement a macOS Flutter texture registrar adapter backed by a Metal texture +- [x] Implement a macOS Flutter texture registrar adapter backed by a Metal texture that Larimar/Filament can render into without CPU framebuffer readback. -- Keep the texture, camera, and swapchain per view; keep the world and assets - shareable. Never expose platform or Filament types through `eshi.h`. -- Specify render-thread ownership and marshal resize, frame-available, app +- [x] Keep the texture target per view and the world shareable. Camera and + Filament swapchain ownership lands with Step 5's scene backend; the current + fullscreen Brush backend owns neither resource. Never expose platform or + Filament types through `eshi.h`. +- [x] Specify render-thread ownership and marshal resize, frame-available, app suspend/resume, and disposal operations to it. -- First render existing Pong through the widget; this isolates host/texture +- [x] First render existing Pong through the widget; this isolates host/texture correctness from unfinished 3D scene work. - -Gate: a widget integration test repeatedly creates, resizes, backgrounds, -foregrounds, and destroys `EshiView` under leak/race diagnostics, while a visual -smoke test shows animated Pong with Flutter UI layered above it. - -### Step 5 — Promote Filament First Light into a 3D scene backend +- [x] Exercise five real create/resize/pause/resume/destroy cycles in the macOS + integration runner and cover delayed creation/disposal with widget tests. +- [x] Run the real lifecycle loop under leak/race diagnostics and retain a + captured visual artifact. + +Gate met, in two halves, because one binary cannot answer both questions. + +`scripts/check_eshiview_host.sh` builds the 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: 24 create/resize/suspend/destroy cycles and +408 raster borrows report `0 leaks for 0 total leaked bytes` under +`leaks --atExit`, and no findings under ThreadSanitizer. 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. + +`scripts/check_eshiview_lifecycle.sh` runs the same loop inside a real Flutter +engine. Twelve cycles of create, resize, background, foreground, and destroy end +with `created: 12, disposed: 12, registered: 0, live: 0, liveSurfaceBytes: 0`, +and `leaks`, snapshotted from outside the App Sandbox after the first cycle and +after the last, reports the same 14320 bytes both times — no growth. Removing +the widget's disposal call fails it at cycle 0. + +The visual half took two attempts, and the first one was wrong. It captured the +app with `RepaintBoundary.toImage` and asserted Pong appeared inside the view +rect, which passed — until it didn't: on a later run the same assertion failed +with the surface holding a complete frame and the engine borrowing it sixty +times a second. `toImage` rasterizes the layer tree offscreen and does not +reliably include an external texture layer. A capture that can come back blank +while everything works proves nothing when it is bright, either, so both of +those earlier passes have to be read as luck. + +What replaced it splits the claim in two, because it is two claims: + +- *Larimar drew the frame.* The host reads its own surface back to a PNG — + [`eshiview-pong-01.png`](docs/larimar/media/eshiview-pong-01.png) and + [`-02`](docs/larimar/media/eshiview-pong-02.png), twenty frames apart, with + the ball and paddles in different places. Peak luminance rules out a cleared + surface; the difference between them rules out a frozen one. +- *Flutter composited it.* The engine borrows the surface exactly when it draws + the texture layer, so the host's borrow count is the only instrument that can + answer this, and the test now waits on it rather than on a fixed delay. + +Running the application then found what neither half covered: 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. The demo videos are 16:9 and showed nothing wrong. The reference +application now takes its arena width from the view, and the visual gate runs at +4:3 and asserts something bright at each edge — a test that would have failed +before the fix and does, when the constant is put back. + +Playing it found the rest, none of which any pixel or lifecycle gate covers: +the Flutter application had no input at all, its keys reached AppKit unhandled +and beeped, and a match never ended — the score ran past the nine dots the +material can draw and kept counting. Both paddles now take the keyboard (W/S and +the arrows, matching the SDL host), the key handler claims what it uses, and a +match plays to nine and announces its winner. That last one has a gate: an +integration test plays a one-point match and requires the announcement to appear +and then clear. The C++ Pong has the same missing win condition and keeps it for +now; its deterministic digests are what several other gates compare. + +Not gated, and recorded as such: `footprint` reports the process's IOSurface +count rising by two across the run. That number also covers the engine's own +swapchain surfaces, so it cannot isolate the view's, and the in-process +accounting above answers the question it was meant to answer. + +### Step 5 — Promote Filament First Light into a 3D scene backend — **in progress** Dependency: Step 4. -- Add renderable/mesh, camera, light, and asset handles to the internal backend - interface and command protocol without leaking Filament into the public ABI. -- Use `gltfio` for glTF/GLB parsing and resource loading; use instancing rather - than duplicate asset uploads. -- Establish PBR defaults, image-based lighting, color space, units, camera - convention, and deterministic asset failure behavior. -- Vendor a pinned official Filament distribution with checksums and Apache-2.0 - NOTICE compliance; do not build Filament from source in ordinary consumer - builds. +- [x] Vendor a pinned official Filament distribution with checksums and + Apache-2.0 NOTICE compliance; do not build Filament from source in ordinary + consumer builds. `scripts/vendor_filament.sh` fetches v1.75.0, verifies its + SHA-256, and extracts it to `third_party/filament/`, which is now + `-Dfilament-path`'s default — a build cannot silently link a different + Filament than the one its materials were compiled by. Attribution lives in + [`third_party/NOTICE.md`](third_party/NOTICE.md). +- [x] Add asset handles to the internal backend interface and the public ABI + without leaking Filament into it. `EshiAsset` is an opaque `uint32_t`; + `eshi_asset_load`, `_instance`, `_release` and `_count` route to four optional + vtable entries that a backend without a scene simply leaves null. The ABI + minor moved to 1.1.0 — additive, so a package built against 1.0 still loads. +- [x] Use `gltfio` for glTF/GLB parsing and resource loading; use instancing + rather than duplicate asset uploads. Assets are created instanced from the + start, so three copies of the logo report `instances=3 assets=1`. +- [x] Establish PBR defaults, units, camera convention, and deterministic asset + failure behaviour: glTF's own right-handed Y-up metres, a 45° vertical field + of view, photographic exposure, one daylight directional light, and an + explicit opaque clear. +- [x] Use the hero asset's greybox `.glb` (Step 8) as the reference asset rather + than authoring a throwaway one — `eshi-gemgen` writes it, for the same reason + the materials are generated rather than checked in. +- [ ] Image-based lighting, which is why a fully metallic material still renders + dark; the reference material is a dielectric so the gap is visible rather + than disguised. +- [ ] Renderable/mesh, camera and light *commands*, so a Dart scene can place + geometry rather than a host calling the C API directly. +- [ ] The `EshiView` half of the gate: one upload shared across two views. Gate: the reference GLB renders lit PBR geometry through `EshiView`, assets are uploaded once across two views, and missing/corrupt assets return actionable errors rather than blank output or crashes. -### Step 6 — Prove Dart hot reload, shared state, multi-view, and touch +### Step 6 — One material source, every tier + +Dependency for 6a: Filament First Light, which landed — `matc` runs from +`build.zig` today and the Brush tier renders a `.filamat`. This step does *not* +wait on Step 5; the fullscreen material path it gates already exists, and +leaving it until after Step 5 would mean carrying a hand-maintained duplicate +through the 3D work. + +The duplicate is the reason this step exists. `examples/pong/pong.mat` is a +hand-written transliteration of `examples/pong/pong.gpu.cpp`: same `sdBox`, same +`glow`, same constants, same score loop, two files to keep in step. Every +material added under Filament multiplies that. `core/src/render/transpile.h` +already calls itself "the de-facto S2L" and already treats the 20 gallery +programs as its conformance corpus; the missing emitter is `.mat`. + +#### 6a — Emit `.mat` from the shader subset — **complete** + +- [x] Write the subset down as a specification, closing + [`ARCHITECTURE.md`](docs/larimar/ARCHITECTURE.md) §9 open question 5 — + [`SHADER_SUBSET.md`](docs/larimar/SHADER_SUBSET.md). It is what 20 programs + already conform to; the document is a description, not a redesign. +- [x] Add a `.mat` target to `transpile.cpp` beside GLSL and MSL, and a + build-time generator (`eshi-matgen`) that writes the material for `matc`. +- [x] Generate `pong.filamat` from `pong.gpu.cpp` and delete the hand-written + `pong.mat`. Refuse to emit, with a diagnostic naming the cause, for any + construct the `.mat` fragment domain cannot express — never degrade silently. +- [x] Build the cross-tier comparison the plan had so far measured by hand: + dump raw frames per tier and compare them, so "within 1 LSB" is a command + rather than a table in a document. + +Gate met. `examples/pong/pong.mat` is gone: the build runs `eshi-matgen` over +`pong.gpu.cpp` and pipes the result to `matc`, so Pong's material has one +definition again. The same pipeline gives `examples/ripple.cpp` a package, which +is what lets a gallery shader reach Filament at all — it previously failed with +"material has no package_path". + +`scripts/check_tier_conformance.sh` renders both scenes on every tier the host +offers and compares raw frames: Pong and ripple, on Paper (OpenGL) and Brush +(Filament), against Ink — four comparisons, all `mean=0.0000/255 max=1/255` at +320x180 over 60 frames. It refuses to pass a tier that quietly fell back to Ink, +because comparing Ink to Ink would be green and meaningless. + +`scripts/check_materials.sh` runs the emitter over the whole corpus: 18 of the +20 programs compile through `matc`. The other two refuse, and finding out *why* +was the useful part of this step: + +- `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. The emitter now says that instead. + +A third finding was fixable rather than a limit: 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 +`noise1`–`noise4` builtins. + +Fully gated in CI as of Step 5's vendoring bullet. `matc` is a build-time tool +that needs no GPU, so *both* the Linux and macOS jobs now compile every emitted +material, and the macOS job additionally renders Pong and ripple through +Filament and compares them against Ink. The macOS runner may have no Metal +device, so that last comparison is gated on the same probe the shader +validation uses. + +Building something to *look* at found two defects none of the gates could. The +first was in the demo itself: `scripts/build_demo.sh` shipped whatever bundle +sat in the Flutter build directory, and `flutter test -d macos` writes its own +bundle there — with the integration test as the application's entry point. The +demo therefore shipped an app that ran the test suite, tore its widget tree +down, and left a blank window. It now builds the application explicitly. + +The second was older: +`zig-out/bin/pong --grade paper` rendered pure black from any directory but the +repository root, printed one line about a missing shader source, and exited +zero, while the banner still announced `backend=gl`. The shader sources now +install to `zig-out/share/eshi/shaders` and resolve from there (or from +`ESHI_SHADER_DIR`), a tier that cannot bind its material refuses to run instead +of presenting black frames, and `zig build demo` renders both scenes on every +available tier into `build/demo` — with the Flutter application beside them — +so the question "does it still look right" has an answer that is not a digest. + +#### 6b — S2L as an additive frontend + +Dependencies: 6a, and `resources/gyosho` remaining a tool rather than a +dependency (§4 of the architecture record: Rust is build-time only). + +- Give `sumic` a `CppGenerator` that emits the subset 6a specified, so an S2L + material reaches Filament, OpenGL, Metal, and Ink through the path 6a already + gates. Nothing in the runtime links Rust; CI checks the generated C++ in. +- Port a representative slice of the gallery — not all 20 — to `.sumi`, chosen + to exercise the constructs the subset spec names. +- Reject at compile time what the declared Kantei grade cannot execute. + +Gate: a `.sumi` source renders on Brush and Ink within 1 LSB through generated +C++, and a drift check fails if the checked-in generated source no longer +matches what `sumic` produces — the same gate shape as the `ffigen` bindings. + +### Step 7 — Prove Dart hot reload, shared state, multi-view, and touch Dependencies: Steps 3 and 5. @@ -189,9 +382,44 @@ Gate: an automated integration scenario hot-reloads the scene 100 times during simulation, retains the expected entity/asset counts and state digest, shows both views updating, and receives the expected tagged hit event. -### Step 7 — Harden the release candidate - -Dependencies: Steps 1–6. +### Step 8 — Cut the hero asset + +Dependencies: Steps 5, 6, and 7. Detailed in +[`docs/larimar/HERO_ASSET.md`](docs/larimar/HERO_ASSET.md). + +The spinning refractive logo is the release's one image, and it is also the +first thing in the plan that is *not* a fullscreen material: a caustic surface +shader on real geometry, driven by a native component, composited under Flutter +UI. Each of those is a capability Steps 5–7 build; this step is where they have +to hold together at once. + +- Author `larimar_logo.glb` and use it as Step 5's reference asset from that + step onward, so the release has one asset rather than a test one and a pretty + one. +- Write the caustic material against the subset from Step 6, mapping Voronoi + noise to `baseColor`, `clearCoat`, and transmission. This is the first surface + material: the fullscreen unlit mapping Step 6 gates does not cover it, and + what the subset cannot express must fail loudly. +- Add an `AngularVelocity` component and the system that integrates it, so + rotation is simulation the core owns rather than an animation Dart drives. +- Give `EshiView` and the Filament view a transparent clear color, and layer + the widget beneath ordinary Flutter controls. + +Presentation prototype landed ahead of the full gate: `lib/hero.dart` loads the +supplied textured `resources/larimar-model.glb`, declares a native Y-axis spin +once, renders Filament directly into `EshiView` through a CVPixelBuffer +swapchain, and composes HarmonyOS Sans UI plus host-owned soundtrack/gradient +choreography. The general `AngularVelocity` ECS component and caustic surface +material above remain required before Step 8 is complete. + +Gate: the logo spins under Flutter UI with one FFI call to declare it and none +per frame; a C++ unit test shows `AngularVelocity` advancing a transform over 60 +ticks with no host involvement; and a captured frame is retained the way Step 4's +composition evidence was. + +### Step 9 — Harden the release candidate + +Dependencies: Steps 1–8. - Add a CI matrix for native core, Flutter package, example app, sanitizer, release-mode smoke, cross-tier pixel conformance, and package assembly. @@ -209,9 +437,9 @@ Dependencies: Steps 1–6. Gate: every measurement is published as a build artifact, no critical defect is open, and every required test is green from a clean checkout. -### Step 8 — Cut RC0 +### Step 10 — Cut RC0 -Dependency: Step 7. +Dependency: Step 9. - Select a SemVer prerelease (`0.1.0-rc.1`), freeze the ABI and dependencies, and create a release branch from a green commit. @@ -231,13 +459,15 @@ hot-reload demo without a repository checkout or an undocumented dependency. |---|---|---| | Native ECS and deterministic simulation | 132 core checks; stable Pong digest | Implemented | | Retained scene + bulk FFI transport | Reload invariant and epoch tests | Dart/native transport implemented; Flutter reload proof remains | -| Render capability ladder | Ink/Paper/Brush; 1-LSB conformance target | Implemented for fullscreen materials | -| Filament | Pong First Light through `.filamat` | 3D scene work missing | +| Render capability ladder | Ink/Paper/Brush; 1-LSB conformance target | Implemented for fullscreen materials, and now checked by a command | +| Filament | Pong First Light through `.filamat` | Distribution pinned; glTF loads, instances and draws; IBL and scene commands remain | | Dart API | Generated, version-checked package | Implemented and drift-gated | -| Flutter composition | macOS `EshiView` external texture | Missing | +| Flutter composition | macOS `EshiView` external texture | Implemented and gated on leak/race diagnostics | +| Custom materials | One source emits `.mat` and the Ink entry point | Fullscreen materials generated and gated; S2L frontend (6b) remains | | Hot reload from Dart | 100-reload integration scenario | Missing | | Multi-view/shared state | Two cameras, one world/assets | Missing | -| glTF PBR + touch tag | One reference GLB and event | Missing | +| glTF PBR + touch tag | One reference GLB and event | Reference GLB renders lit PBR; `extras` event missing | +| Hero asset | Spinning refractive logo under Flutter UI | Presentation prototype works; caustic material and general ECS rotation remain | | Release engineering | Green required CI and installable artifacts | Steps 1 and 3 gated; packaging remains | ## 6. Decision rules and risks @@ -252,8 +482,18 @@ hot-reload demo without a repository checkout or an undocumented dependency. assets, and unavailable backends fail visibly with actionable diagnostics. - **Avoid dependency drift.** Pin Zig, Flutter, Filament, and generator versions; record checksums and upgrade them in isolated changes with full gate runs. -- **Control RC scope.** Physics, audio, SumiC, and additional platforms are - important but cannot enter RC0 unless they close a required gate above. - -The immediate next task after Step 1 turns green is Step 2: freeze the ABI and -wire-format fixtures before Dart code depends on them. +- **Control RC scope.** Physics, audio, and additional platforms are important + but cannot enter RC0 unless they close a required gate above. +- **One source per material, always.** A shader that exists twice is a bug with a + delay fuse: the copies agree until someone edits one. Step 6 exists to remove + the only such pair in the repository and to make adding another impossible. +- **The language is not the deliverable.** Step 6's outcome is one source + reaching every tier. S2L makes that source nicer to write, which is why 6b + follows 6a rather than gating it: the conformance corpus must not depend on a + parser that is still being built. + +The immediate next task is the rest of Step 5: image-based lighting, the scene +commands that let Dart place geometry rather than a host calling the C API, and +the two-view upload-sharing half of the gate. Loading, instancing and drawing a +glTF now work end to end, with `scripts/check_asset_render.sh` proving a drawn +frame differs from an empty one. diff --git a/README.md b/README.md index d3b3e33..c316b7f 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,23 @@ zig build eshi -Dsumi-path=../libsumi zig build examples -Dopengl=true ``` +#### Something to look at + +```bash +zig build demo +open build/demo/README.md +``` + +`build/demo` holds the same two scenes rendered on every tier this machine can +offer, as playable video, plus the Flutter application when it has been built. +The gates in this repository answer machine questions — does it compile, do the +tiers agree to one least-significant bit, were the surfaces reclaimed — and none +of them answers *does it look right*. That one needs eyes. + +The installed binaries are self-contained: `zig build` copies each shader source +to `zig-out/share/eshi/shaders`, so `zig-out/bin/pong --live --grade brush` runs +from any directory. Set `ESHI_SHADER_DIR` to read them from somewhere else. + #### Larimar First Light with Filament Glow Pong is the first vertical slice of Larimar's data-oriented engine: the @@ -139,25 +156,44 @@ game owns only its scene, components, systems, and material while the SDL host and renderer remain interchangeable. Its Ink, Paper, and direct-Metal paths are built by `zig build larimar`. -To run the same ECS-driven game through Google Filament, first install the -vendored Filament distribution (one-time, incremental afterward), then enable -the optional backend: +To run the same ECS-driven game through Google Filament, fetch the pinned +distribution once, then enable the optional backend: ```bash -cd resources/filament -./build.sh -i release filament matc -cd ../.. - +./scripts/vendor_filament.sh zig build larimar -Dfilament=true ./zig-out/bin/pong --grade brush --live ``` -The build compiles `examples/pong/pong.mat` with Filament's `matc`, installs the -package at `zig-out/share/eshi/pong.filamat`, and maps Kantei Brush to Filament. -The public C API does not expose Filament types. To consume an already-installed -distribution instead, pass `-Dfilament-path=/path/to/filament`; use -`-Dfilament-arch=...` when its library directory is not the inferred `arm64` or -`x86_64`. +The script downloads the official prebuilt release into +`third_party/filament/` and verifies its SHA-256 before extracting it, +so an upgrade is a version and two checksums changed together in one commit. +Filament is not built from source here: it is a large CMake project with its own +toolchain expectations and would dominate this build. Attribution and the +Apache-2.0 obligations are recorded in +[`third_party/NOTICE.md`](third_party/NOTICE.md). + +The build generates each material from the shader source the other tiers +already use — `eshi-matgen` emits a `.mat`, `matc` compiles it — and installs +the packages at `zig-out/share/eshi/`. No `.mat` is checked in, because a +material that exists as both a shader and a hand-written copy stays correct only +until somebody edits one of them. See +[`docs/larimar/SHADER_SUBSET.md`](docs/larimar/SHADER_SUBSET.md) for the subset +those sources are written in, and the two gallery programs the material domain +cannot express. + +Kantei Brush maps to Filament, and the public C API does not expose Filament +types. To build against a different distribution — a local source build, say — +pass `-Dfilament-path=/path/to/filament`; use `-Dfilament-arch=...` when its +library directory is not the inferred `arm64` or `x86_64`. + +Whether the tiers actually agree is a command rather than a claim: + +```bash +zig build larimar tools -Dfilament=true +./scripts/check_tier_conformance.sh # every available tier vs Ink, within 1 LSB +./scripts/check_materials.sh # every shader still emits a material +``` #### Hot-reload safety, without Dart diff --git a/bindings/dart/larimar/CHANGELOG.md b/bindings/dart/larimar/CHANGELOG.md index a1db8a4..7aeec99 100644 --- a/bindings/dart/larimar/CHANGELOG.md +++ b/bindings/dart/larimar/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.1.0-dev.2 + +- Add the macOS `EshiView` external-texture host using IOSurface, CoreVideo, + and Metal without framebuffer readback. +- Add lifecycle/resize/disposal serialization, source-backed material uniforms, + and the animated Pong reference integration. +- Add `MacOSEshiViewHost.diagnostics()` and the host-side surface accounting it + reads, so a lifecycle loop can assert that every surface was reclaimed. +- Count the engine's surface borrows as `copies`: frames presented without + matching borrows mean the view is not being composited. +- Add `MacOSEshiViewHost.captureSurface()`, which reads a view's surface back to + a PNG without going through the compositor. +- Flush the Metal texture cache when a view's surface is deallocated. + ## 0.1.0-dev.1 - Establish ABI v1 loading, deterministic world ownership, and command/event diff --git a/bindings/dart/larimar/README.md b/bindings/dart/larimar/README.md index 9905631..2bf833d 100644 --- a/bindings/dart/larimar/README.md +++ b/bindings/dart/larimar/README.md @@ -36,3 +36,38 @@ usingLarimarWorld((world) { Use `LarimarWorld` directly when its lifetime follows a Flutter `State`; call `dispose()` from that state's `dispose()` method. Shared command/event views are borrowed from their world and reject use after it is disposed. + +On macOS, a Brush-grade world can render through `EshiView`. The widget owns an +IOSurface-backed Flutter texture, serializes resize and application lifecycle +operations, and submits Metal directly without copying a framebuffer through +the CPU: + +```dart +EshiView( + world: world, + onFrame: (world, elapsed, delta) { + world.tick(delta.inMicroseconds / Duration.microsecondsPerSecond); + }, +) +``` + +Bind a source-backed material with `world.bindShaderMaterial(...)` before the +first frame. Its returned `LarimarMaterialBuffer` remains world-owned and is +invalidated when the material is replaced or the world is disposed. + +`MacOSEshiViewHost.diagnostics()` reports the host's surface accounting — +surfaces created, resized, presented, disposed, still registered, and still +alive. `EshiView` never calls it; it exists so a lifecycle test can prove the +host released what it allocated. Both halves of that gate run from the +repository root: + +```sh +./scripts/check_eshiview_host.sh && ./scripts/check_eshiview_lifecycle.sh +``` + +The first builds the adapter twice — once under `leaks --atExit`, once under +ThreadSanitizer — and drives it from a platform thread and a stand-in raster +queue with no engine present. The second runs the reference application's real +create/resize/background/foreground/destroy loop and snapshots the process from +outside the App Sandbox. Both write their reports, and the composited frames the +visual test captured, to `build/larimar-diagnostics/`. diff --git a/bindings/dart/larimar/analysis_options.yaml b/bindings/dart/larimar/analysis_options.yaml index c939011..76392dd 100644 --- a/bindings/dart/larimar/analysis_options.yaml +++ b/bindings/dart/larimar/analysis_options.yaml @@ -3,6 +3,13 @@ include: package:lints/recommended.yaml analyzer: exclude: - lib/src/generated/** + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** linter: rules: diff --git a/bindings/dart/larimar/assets/pong.gpu.cpp b/bindings/dart/larimar/assets/pong.gpu.cpp new file mode 100644 index 0000000..f559ef4 --- /dev/null +++ b/bindings/dart/larimar/assets/pong.gpu.cpp @@ -0,0 +1,52 @@ +// Flutter asset form of examples/pong/pong.gpu.cpp. +// Uniform layout: left paddle xy, right paddle xy, ball xy, hit, score l/r. +const float kPaddleHalfW = 0.02f; +const float kPaddleHalfH = 0.2f; +const float kBallRadius = 0.03f; +const float kArenaY = 1.0f; + +SHADER_CTX float sdBox(vec2 p, vec2 b) { + vec2 d = abs(p) - b; + return length(max(d, vec2(0.0f, 0.0f))) + min(max(d.x, d.y), 0.0f); +} + +SHADER_CTX float glow(float d, float intensity) { + return intensity / (d * d + 0.0001f); +} + +SHADER_CTX void mainImage(vec4 &fragColor, vec2 fragCoord, vec2 iResolution, float iTime) { + vec2 paddleL = vec2(eshi_uniforms[0], eshi_uniforms[1]); + vec2 paddleR = vec2(eshi_uniforms[2], eshi_uniforms[3]); + vec2 ball = vec2(eshi_uniforms[4], eshi_uniforms[5]); + float hitTimer = eshi_uniforms[6]; + float scoreL = eshi_uniforms[7]; + float scoreR = eshi_uniforms[8]; + + vec2 uv = (fragCoord * 2.0f - iResolution) / iResolution.y; + vec3 col = vec3(0.0f, 0.0f, 0.0f); + float dLeft = sdBox(uv - paddleL, vec2(kPaddleHalfW, kPaddleHalfH)); + float dRight = sdBox(uv - paddleR, vec2(kPaddleHalfW, kPaddleHalfH)); + float dBall = length(uv - ball) - kBallRadius; + + col += vec3(0.2f, 0.8f, 1.0f) * glow(dLeft, 0.002f); + col += vec3(1.0f, 0.2f, 0.5f) * glow(dRight, 0.002f); + col += vec3(1.0f, 1.0f, 1.0f) * glow(dBall, 0.005f); + col += vec3(1.0f, 1.0f, 1.0f) * hitTimer * 0.35f * + exp(-length(uv - ball) * 2.0f); + + if (abs(uv.x) < 0.004f && fract(uv.y * 6.0f + iTime * 0.1f) < 0.5f) { + col += vec3(0.18f, 0.18f, 0.18f); + } + for (int i = 0; i < 9; ++i) { + float fx = 0.08f * float(i + 1); + if (scoreL > float(i)) { + col += vec3(0.2f, 0.8f, 1.0f) * + glow(length(uv - vec2(-fx, kArenaY - 0.08f)) - 0.012f, 0.0004f); + } + if (scoreR > float(i)) { + col += vec3(1.0f, 0.2f, 0.5f) * + glow(length(uv - vec2(fx, kArenaY - 0.08f)) - 0.012f, 0.0004f); + } + } + fragColor = vec4(col.x, col.y, col.z, 1.0f); +} diff --git a/bindings/dart/larimar/hook/build.dart b/bindings/dart/larimar/hook/build.dart index 051d12b..08c998f 100644 --- a/bindings/dart/larimar/hook/build.dart +++ b/bindings/dart/larimar/hook/build.dart @@ -1,25 +1,92 @@ +import 'dart:io'; + +import 'package:code_assets/code_assets.dart'; import 'package:hooks/hooks.dart'; import 'package:logging/logging.dart'; import 'package:native_toolchain_c/native_toolchain_c.dart'; const _core = '../../../core'; +const _filament = '../../../third_party/filament/v1.75.0'; + +const _filamentLibraries = [ + 'gltfio', + 'gltfio_core', + 'filament', + 'backend', + 'filabridge', + 'filaflat', + 'geometry', + 'dracodec', + 'ktxreader', + 'image', + 'meshoptimizer', + 'stb', + 'uberarchive', + 'uberzlib', + 'basis_transcoder', + 'bluegl', + 'bluevk', + 'smol-v', + 'utils', + 'zstd', +]; void main(List args) async { await build(args, (input, output) async { + if (!input.config.buildCodeAssets) return; + final isMacOS = input.config.code.targetOS == OS.macOS; + final filamentArch = + input.config.code.targetArchitecture == Architecture.arm64 + ? 'arm64' + : 'x86_64'; + final filamentLibraryDirectory = input.packageRoot + .resolve('$_filament/lib/$filamentArch') + .toFilePath(); + // The pinned macOS distribution ships arm64 only, and a release build of a + // macOS app is universal — so the x86_64 slice has no Filament to link and + // used to fail the whole build with `library 'gltfio' not found`. Build + // that slice without the Filament backend instead: it still has Ink and + // Metal, and a Mac that runs it falls back the way any tier-less host does. + final hasFilament = + isMacOS && Directory(filamentLibraryDirectory).existsSync(); final builder = CBuilder.library( name: 'larimar_core', assetName: 'src/generated/larimar_bindings_generated.dart', - language: Language.cpp, - std: 'c++11', - includes: const ['$_core/include'], - sources: const [ + // The macOS build mixes .cpp and .mm. Leaving language selection to + // clang by extension preserves Objective-C++ for metal.mm; libc++ is + // linked explicitly below. Other hosts retain the ordinary C++ path. + language: isMacOS ? Language.objectiveC : Language.cpp, + std: 'c++20', + includes: ['$_core/include', if (hasFilament) '$_filament/include'], + sources: [ '$_core/src/abi.cpp', '$_core/src/world.cpp', '$_core/src/scene.cpp', '$_core/src/render/registry.cpp', '$_core/src/render/ink.cpp', + if (isMacOS) ...[ + '$_core/src/render/transpile.cpp', + '$_core/src/render/metal.mm', + ], + if (hasFilament) '$_core/src/render/filament.cpp', + ], + frameworks: isMacOS + ? const ['Cocoa', 'CoreVideo', 'Foundation', 'Metal', 'QuartzCore'] + : const [], + libraries: [ + if (hasFilament) ..._filamentLibraries, + if (isMacOS) 'c++', + ], + libraryDirectories: hasFilament ? [filamentLibraryDirectory] : const [], + defines: { + if (isMacOS) 'ESHI_HAVE_METAL': null, + if (hasFilament) 'ESHI_HAVE_FILAMENT': null, + }, + flags: [ + '-Wall', + '-Wextra', + if (isMacOS) ...['-fobjc-arc', '-Wno-nullability-completeness'], ], - flags: const ['-Wall', '-Wextra'], ); await builder.run( input: input, diff --git a/bindings/dart/larimar/lib/larimar.dart b/bindings/dart/larimar/lib/larimar.dart index abd90d7..9228cc2 100644 --- a/bindings/dart/larimar/lib/larimar.dart +++ b/bindings/dart/larimar/lib/larimar.dart @@ -2,15 +2,23 @@ library; import 'dart:ffi' as ffi; +import 'dart:io'; import 'dart:isolate'; import 'dart:typed_data'; import 'package:ffi/ffi.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'src/generated/larimar_bindings_generated.dart' as native; part 'src/commands.dart'; +part 'src/asset.dart'; part 'src/contract.dart'; part 'src/events.dart'; part 'src/exceptions.dart'; +part 'src/material.dart'; +part 'src/view.dart'; part 'src/world.dart'; diff --git a/bindings/dart/larimar/lib/src/asset.dart b/bindings/dart/larimar/lib/src/asset.dart new file mode 100644 index 0000000..903d9b8 --- /dev/null +++ b/bindings/dart/larimar/lib/src/asset.dart @@ -0,0 +1,67 @@ +part of '../larimar.dart'; + +/// One glTF/GLB upload owned by a [LarimarWorld]. +/// +/// Geometry and textures are uploaded once. Calling [instantiate] only adds a +/// transform and renderable entities, so multiple instances remain cheap. +final class LarimarAsset { + LarimarAsset._(this._world, this._handle); + + LarimarWorld? _world; + final int _handle; + + bool get isDisposed => _world == null; + + /// Places one instance in glTF's right-handed, Y-up coordinate system. + /// + /// [spinY] is integrated inside the native renderer. Declaring a non-zero + /// rate therefore does not add per-frame Dart-to-native traffic. + void instantiate({ + double x = 0, + double y = 0, + double z = 0, + double scale = 1, + double spinY = 0, + }) { + final world = _world; + if (world == null) { + throw const LarimarDisposedException('LarimarAsset has been disposed.'); + } + for (final value in <(String, double)>[ + ('x', x), + ('y', y), + ('z', z), + ('scale', scale), + ('spinY', spinY), + ]) { + if (!value.$2.isFinite) { + throw ArgumentError.value(value.$2, value.$1, 'must be finite'); + } + } + if (scale <= 0) { + throw RangeError.value(scale, 'scale', 'must be greater than zero'); + } + _checkNative( + native.eshi_asset_instance_animated( + world._requirePointer(), + _handle, + x, + y, + z, + scale, + spinY, + ), + 'eshi_asset_instance_animated', + ); + } + + /// Releases the upload and all of its instances. Idempotent. + void dispose() { + final world = _world; + if (world == null) return; + world._releaseAsset(this); + _world = null; + } + + void _worldDisposed() => _world = null; +} diff --git a/bindings/dart/larimar/lib/src/generated/larimar_bindings_generated.dart b/bindings/dart/larimar/lib/src/generated/larimar_bindings_generated.dart index 0ad8988..43db7a5 100644 --- a/bindings/dart/larimar/lib/src/generated/larimar_bindings_generated.dart +++ b/bindings/dart/larimar/lib/src/generated/larimar_bindings_generated.dart @@ -50,6 +50,131 @@ EshiResult eshi_abi_query(ffi.Pointer out_info, int out_size) { return EshiResult.fromValue(_eshi_abi_query(out_info, out_size)); } +/// Number of assets this world currently holds. Zero on grades without 3D. +@ffi.Native)>() +external int eshi_asset_count(ffi.Pointer w); + +/// Places an instance of a loaded asset into the world's scene. +/// +/// Instances share the asset's uploaded geometry and materials; the cost of a +/// second one is a transform and a set of renderable entities, not a second +/// copy of the mesh. +/// +/// `x`, `y`, `z` position the instance, and `scale` is uniform. The convention +/// is right-handed, Y up, metres, matching glTF's own. +@ffi.Native< + ffi.Int Function( + ffi.Pointer, + EshiAsset, + ffi.Float, + ffi.Float, + ffi.Float, + ffi.Float, + ) +>(symbol: 'eshi_asset_instance') +external int _eshi_asset_instance( + ffi.Pointer w, + int asset, + double x, + double y, + double z, + double scale, +); + +EshiResult eshi_asset_instance( + ffi.Pointer w, + DartEshiAsset asset, + double x, + double y, + double z, + double scale, +) { + return EshiResult.fromValue(_eshi_asset_instance(w, asset, x, y, z, scale)); +} + +/// Places an instance whose root rotates around glTF's Y-up axis in the native +/// render system. The host declares the rate once; no per-frame FFI update is +/// required. `radians_per_second` may be negative and zero is stationary. +@ffi.Native< + ffi.Int Function( + ffi.Pointer, + EshiAsset, + ffi.Float, + ffi.Float, + ffi.Float, + ffi.Float, + ffi.Float, + ) +>(symbol: 'eshi_asset_instance_animated') +external int _eshi_asset_instance_animated( + ffi.Pointer w, + int asset, + double x, + double y, + double z, + double scale, + double radians_per_second, +); + +EshiResult eshi_asset_instance_animated( + ffi.Pointer w, + DartEshiAsset asset, + double x, + double y, + double z, + double scale, + double radians_per_second, +) { + return EshiResult.fromValue( + _eshi_asset_instance_animated(w, asset, x, y, z, scale, radians_per_second), + ); +} + +/// Loads a glTF or GLB file for this world's backend. +/// +/// Loading is separate from instancing because the expensive half — parsing, +/// decoding buffers, uploading vertex data and textures — happens once per +/// asset no matter how many copies of it a scene holds, and no matter how many +/// views draw that scene. +/// +/// Returns ESHI_ERR_UNSUPPORTED when the grade has no 3D scene, +/// ESHI_ERR_INVALID when the file is missing or is not a glTF the loader +/// accepts, and ESHI_ERR_NOMEM when the upload fails. A failed load leaves +/// `out_asset` untouched and the world renderable; it never half-loads. +@ffi.Native< + ffi.Int Function( + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ) +>(symbol: 'eshi_asset_load') +external int _eshi_asset_load( + ffi.Pointer w, + ffi.Pointer path, + ffi.Pointer out_asset, +); + +EshiResult eshi_asset_load( + ffi.Pointer w, + ffi.Pointer path, + ffi.Pointer out_asset, +) { + return EshiResult.fromValue(_eshi_asset_load(w, path, out_asset)); +} + +/// Releases an asset and every instance of it. +/// +/// Assets are also released with the world, so a host that exits does not need +/// to call this; a host that swaps scenes does. +@ffi.Native, EshiAsset)>( + symbol: 'eshi_asset_release', +) +external int _eshi_asset_release(ffi.Pointer w, int asset); + +EshiResult eshi_asset_release(ffi.Pointer w, DartEshiAsset asset) { + return EshiResult.fromValue(_eshi_asset_release(w, asset)); +} + /// Clamps the transform to an axis-aligned range after motion integrates. /// Generic replacement for the hand-written paddle clamping in the old Pong. @ffi.Native< @@ -631,7 +756,7 @@ external void eshi_world_size( ffi.Pointer out_h, ); -const int ESHI_ABI_VERSION = 16777216; +const int ESHI_ABI_VERSION = 16842752; const int ESHI_COMMAND_PROTOCOL_VERSION = 1; @@ -904,6 +1029,10 @@ final class EshiAbiInfo extends ffi.Struct { ..ref.material_uniform_size_offset = material_uniform_size_offset; } +/// An asset the backend has loaded. Zero is "no asset". +typedef EshiAsset = ffi.Uint32; +typedef DartEshiAsset = int; + /// Collider response flags. enum EshiColliderFlags { ESHI_COLLIDER_NONE(0), diff --git a/bindings/dart/larimar/lib/src/material.dart b/bindings/dart/larimar/lib/src/material.dart new file mode 100644 index 0000000..a456181 --- /dev/null +++ b/bindings/dart/larimar/lib/src/material.dart @@ -0,0 +1,43 @@ +part of '../larimar.dart'; + +/// Caller-owned uniform storage bound to a source-backed GPU material. +/// +/// The view reads this block directly during render submission. The buffer is +/// invalidated when another material is bound or its world is disposed. +final class LarimarMaterialBuffer { + LarimarMaterialBuffer._( + this._world, + this._sourcePath, + this._uniforms, + this._values, + ); + + final LarimarWorld _world; + final ffi.Pointer _sourcePath; + final ffi.Pointer _uniforms; + final Float32List _values; + bool _active = true; + + bool get isActive => _active && !_world.isDisposed; + + Float32List get values { + requireActive(); + return _values; + } + + void requireActive() { + _world._requirePointer(); + if (!_active) { + throw const LarimarDisposedException( + 'The material buffer was replaced by a newer binding.', + ); + } + } + + void _release() { + if (!_active) return; + _active = false; + calloc.free(_uniforms); + calloc.free(_sourcePath); + } +} diff --git a/bindings/dart/larimar/lib/src/view.dart b/bindings/dart/larimar/lib/src/view.dart new file mode 100644 index 0000000..ff6e61b --- /dev/null +++ b/bindings/dart/larimar/lib/src/view.dart @@ -0,0 +1,403 @@ +part of '../larimar.dart'; + +const _textureChannel = MethodChannel('dev.larimar/texture'); +const _nativeAssetId = + 'package:larimar/src/generated/larimar_bindings_generated.dart'; + +@ffi.Native< + ffi.Int Function( + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Int32, + ffi.Int32, + ffi.Float, + ) +>(assetId: _nativeAssetId, symbol: 'eshi__metal_render_texture') +external int _eshiMetalRenderTexture( + ffi.Pointer world, + ffi.Pointer metalTexture, + ffi.Pointer pixelBuffer, + int width, + int height, + double time, +); + +/// The per-view host resources returned by the macOS texture adapter. +@immutable +final class EshiViewSurface { + const EshiViewSurface({ + required this.textureId, + required this.metalTexture, + required this.pixelBuffer, + required this.width, + required this.height, + }); + + final int textureId; + final int metalTexture; + final int pixelBuffer; + final int width; + final int height; +} + +/// The macOS texture host's surface accounting, for lifecycle leak gates. +/// +/// [registered] counts the surfaces the host still owns; [live] counts the ones +/// that have not been deallocated yet, which stays above zero for as long as +/// Flutter's raster thread is still borrowing a disposed surface. A lifecycle +/// loop has reclaimed everything only when both reach zero and +/// [liveSurfaceBytes] follows them down. +/// +/// [copies] is the one that answers "is anything on screen": the engine borrows +/// the surface only while it is compositing the texture layer, so [presented] +/// climbing while [copies] stays at zero means frames are being produced into a +/// view nobody is drawing. +@immutable +final class EshiViewDiagnostics { + const EshiViewDiagnostics({ + required this.created, + required this.resized, + required this.presented, + required this.disposed, + required this.suspensions, + required this.registered, + required this.live, + required this.liveSurfaceBytes, + required this.copies, + required this.metalAvailable, + }); + + final int created; + final int resized; + final int presented; + final int disposed; + final int suspensions; + final int registered; + final int live; + final int liveSurfaceBytes; + final int copies; + final bool metalAvailable; + + @override + String toString() => + 'EshiViewDiagnostics(created: $created, resized: $resized, ' + 'presented: $presented, disposed: $disposed, ' + 'suspensions: $suspensions, registered: $registered, live: $live, ' + 'liveSurfaceBytes: $liveSurfaceBytes, copies: $copies, ' + 'metalAvailable: $metalAvailable)'; +} + +/// Host seam kept public for deterministic widget and embedder tests. +abstract interface class EshiViewHost { + Future create({required int width, required int height}); + Future resize( + EshiViewSurface surface, { + required int width, + required int height, + }); + void render(EshiViewSurface surface, LarimarWorld world, double time); + Future present(EshiViewSurface surface); + Future setSuspended(EshiViewSurface surface, bool suspended); + Future dispose(EshiViewSurface surface); +} + +/// Flutter method-channel host backed by an IOSurface and Metal texture. +final class MacOSEshiViewHost implements EshiViewHost { + const MacOSEshiViewHost(); + + @override + Future create({ + required int width, + required int height, + }) async { + final value = await _textureChannel.invokeMapMethod( + 'create', + {'width': width, 'height': height}, + ); + return _surfaceFromMap(value); + } + + @override + Future resize( + EshiViewSurface surface, { + required int width, + required int height, + }) async { + final value = await _textureChannel.invokeMapMethod( + 'resize', + { + 'textureId': surface.textureId, + 'width': width, + 'height': height, + }, + ); + return _surfaceFromMap(value); + } + + @override + void render(EshiViewSurface surface, LarimarWorld world, double time) { + final result = _eshiMetalRenderTexture( + world._requirePointer(), + ffi.Pointer.fromAddress(surface.metalTexture), + ffi.Pointer.fromAddress(surface.pixelBuffer), + surface.width, + surface.height, + time, + ); + _checkNative( + native.EshiResult.fromValue(result), + 'eshi__metal_render_texture', + ); + } + + @override + Future present(EshiViewSurface surface) => _textureChannel.invokeMethod( + 'present', + {'textureId': surface.textureId}, + ); + + @override + Future setSuspended(EshiViewSurface surface, bool suspended) => + _textureChannel.invokeMethod('setSuspended', { + 'textureId': surface.textureId, + 'suspended': suspended, + }); + + @override + Future dispose(EshiViewSurface surface) => _textureChannel.invokeMethod( + 'dispose', + {'textureId': surface.textureId}, + ); + + /// Reads the host's surface accounting. [EshiView] never calls this; it + /// exists so a lifecycle test can prove the host let go of what it allocated. + Future diagnostics() async { + final value = await _textureChannel.invokeMapMethod( + 'diagnostics', + ); + if (value == null) { + throw PlatformException( + code: 'invalid-diagnostics', + message: 'The macOS texture host returned no diagnostics.', + ); + } + int count(String name) => value[name] is int ? value[name]! as int : 0; + return EshiViewDiagnostics( + created: count('created'), + resized: count('resized'), + presented: count('presented'), + disposed: count('disposed'), + suspensions: count('suspensions'), + registered: count('registered'), + live: count('live'), + liveSurfaceBytes: count('liveSurfaceBytes'), + copies: count('copies'), + metalAvailable: value['metalAvailable'] == true, + ); + } + + /// Writes the view's surface to [path] as a PNG, bypassing the compositor. + /// + /// The pixels Larimar submitted, as the host holds them — which is the only + /// way to tell an empty surface apart from a layer the engine did not draw. + Future captureSurface(EshiViewSurface surface, String path) => + _textureChannel.invokeMethod('captureSurface', { + 'textureId': surface.textureId, + 'path': path, + }); + + EshiViewSurface _surfaceFromMap(Map? value) { + if (value == null) { + throw PlatformException( + code: 'invalid-surface', + message: 'The macOS texture host returned no surface.', + ); + } + int field(String name) { + final fieldValue = value[name]; + if (fieldValue is! int) { + throw PlatformException( + code: 'invalid-surface', + message: 'The macOS texture host returned an invalid $name.', + ); + } + return fieldValue; + } + + return EshiViewSurface( + textureId: field('textureId'), + metalTexture: field('metalTexture'), + pixelBuffer: field('pixelBuffer'), + width: field('width'), + height: field('height'), + ); + } +} + +typedef EshiViewFrameCallback = + void Function(LarimarWorld world, Duration elapsed, Duration delta); + +/// Composes one Larimar camera/swapchain surface into an ordinary Flutter tree. +/// +/// Native world access and Metal submission occur synchronously on the world's +/// owner isolate. Registration, resize, frame notification, suspension, and +/// disposal are serialized through the platform channel. Flutter's raster +/// thread only receives an owning CVPixelBuffer reference. +final class EshiView extends StatefulWidget { + const EshiView({ + super.key, + required this.world, + this.onFrame, + this.onError, + this.placeholder = const SizedBox.expand(), + this.host = const MacOSEshiViewHost(), + }); + + final LarimarWorld world; + final EshiViewFrameCallback? onFrame; + final ValueChanged? onError; + final Widget placeholder; + + @visibleForTesting + final EshiViewHost host; + + @override + State createState() => _EshiViewState(); +} + +final class _EshiViewState extends State + with SingleTickerProviderStateMixin, WidgetsBindingObserver { + late final Ticker _ticker; + EshiViewSurface? _surface; + Size? _requestedPhysicalSize; + Duration? _lastElapsed; + Future _operations = Future.value(); + bool _frameInFlight = false; + bool _suspended = false; + bool _disposed = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _ticker = createTicker(_frame); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + final suspended = state != AppLifecycleState.resumed; + if (_suspended == suspended) return; + _suspended = suspended; + _lastElapsed = null; + final surface = _surface; + if (surface != null) { + _serialize(() => widget.host.setSuspended(surface, suspended)); + } + } + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final logicalWidth = constraints.maxWidth.isFinite + ? constraints.maxWidth + : constraints.minWidth; + final logicalHeight = constraints.maxHeight.isFinite + ? constraints.maxHeight + : constraints.minHeight; + if (logicalWidth > 0 && logicalHeight > 0) { + final ratio = MediaQuery.devicePixelRatioOf(context); + final physical = Size( + (logicalWidth * ratio).ceilToDouble(), + (logicalHeight * ratio).ceilToDouble(), + ); + if (_requestedPhysicalSize != physical) { + _requestedPhysicalSize = physical; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_disposed) _ensureSurface(physical); + }); + } + } + final surface = _surface; + if (surface == null) return widget.placeholder; + return Texture(textureId: surface.textureId, freeze: _suspended); + }, + ); + + void _ensureSurface(Size physicalSize) { + final width = physicalSize.width.toInt().clamp(1, 16384); + final height = physicalSize.height.toInt().clamp(1, 16384); + _serialize(() async { + var surface = _surface; + if (surface == null) { + surface = await widget.host.create(width: width, height: height); + if (_disposed) { + await widget.host.dispose(surface); + return; + } + if (_suspended) await widget.host.setSuspended(surface, true); + } else if (surface.width != width || surface.height != height) { + surface = await widget.host.resize( + surface, + width: width, + height: height, + ); + } + if (_disposed) return; + if (!identical(_surface, surface)) { + setState(() => _surface = surface); + } + if (!_ticker.isActive) _ticker.start(); + }); + } + + void _frame(Duration elapsed) { + final surface = _surface; + if (surface == null || _frameInFlight || _suspended || _disposed) return; + final previous = _lastElapsed; + final delta = previous == null ? Duration.zero : elapsed - previous; + _lastElapsed = elapsed; + try { + widget.onFrame?.call(widget.world, elapsed, delta); + widget.host.render( + surface, + widget.world, + elapsed.inMicroseconds / Duration.microsecondsPerSecond, + ); + } on Object catch (error) { + _report(error); + return; + } + _frameInFlight = true; + widget.host.present(surface).catchError(_report).whenComplete(() { + _frameInFlight = false; + }); + } + + void _serialize(Future Function() operation) { + _operations = _operations.then((_) => operation()).catchError(_report); + } + + void _report(Object error) { + if (!_disposed && _ticker.isActive) _ticker.stop(); + widget.onError?.call(error); + if (widget.onError == null) { + FlutterError.reportError( + FlutterErrorDetails(exception: error, library: 'larimar EshiView'), + ); + } + } + + @override + void dispose() { + _disposed = true; + WidgetsBinding.instance.removeObserver(this); + _ticker.dispose(); + final surface = _surface; + _surface = null; + if (surface != null) { + _serialize(() => widget.host.dispose(surface)); + } + super.dispose(); + } +} diff --git a/bindings/dart/larimar/lib/src/world.dart b/bindings/dart/larimar/lib/src/world.dart index 821b0da..ad2c709 100644 --- a/bindings/dart/larimar/lib/src/world.dart +++ b/bindings/dart/larimar/lib/src/world.dart @@ -84,6 +84,8 @@ final class LarimarWorld { final Isolate _ownerIsolate; LarimarCommandBuffer? _commandBuffer; LarimarEventBuffer? _eventBuffer; + LarimarMaterialBuffer? _materialBuffer; + final Set _assets = {}; int _nextEpoch = 1; bool _disposed = false; @@ -94,6 +96,7 @@ final class LarimarWorld { int get sceneNodeCount => native.eshi_scene_node_count(_requirePointer()); int get frameIndex => native.eshi_frame_index(_requirePointer()); double get simulationTime => native.eshi_sim_time(_requirePointer()); + int get assetCount => native.eshi_asset_count(_requirePointer()); LarimarGrade get grade { final value = native.eshi_world_grade(_requirePointer()).value; @@ -146,13 +149,173 @@ final class LarimarWorld { void clearScene() => native.eshi_scene_clear(_requirePointer()); + /// Loads a glTF or GLB from an ordinary filesystem path. + LarimarAsset loadAssetFile(String path) { + final pointer = _requirePointer(); + if (path.isEmpty) { + throw ArgumentError.value(path, 'path', 'must not be empty'); + } + final encodedPath = path.toNativeUtf8(); + try { + return using((arena) { + final handle = arena(); + _checkNative( + native.eshi_asset_load(pointer, encodedPath.cast(), handle), + 'eshi_asset_load', + ); + final asset = LarimarAsset._(this, handle.value); + _assets.add(asset); + return asset; + }); + } finally { + malloc.free(encodedPath); + } + } + + /// Loads a bundled GLB without making the native loader understand Flutter's + /// archive format. Use [loadAssetFile] for a glTF with companion resources. + /// The temporary copy is deleted after gltfio has synchronously uploaded all + /// geometry and textures. + Future loadAssetBundle(String key) async { + _requirePointer(); + if (key.isEmpty) { + throw ArgumentError.value(key, 'key', 'must not be empty'); + } + if (!key.toLowerCase().endsWith('.glb')) { + throw ArgumentError.value(key, 'key', 'must identify a bundled GLB'); + } + final data = await rootBundle.load(key); + final directory = await Directory.systemTemp.createTemp('larimar-asset-'); + final file = File('${directory.path}/asset.glb'); + try { + await file.writeAsBytes( + data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes), + flush: true, + ); + return loadAssetFile(file.path); + } finally { + if (directory.existsSync()) directory.deleteSync(recursive: true); + } + } + + void _releaseAsset(LarimarAsset asset) { + if (!_assets.remove(asset)) return; + _checkNative( + native.eshi_asset_release(_requirePointer(), asset._handle), + 'eshi_asset_release', + ); + } + + /// Binds a runtime-transpiled GPU material and returns its live uniform block. + /// + /// [sourcePath] must remain readable when this call is made. Larimar copies + /// the path and owns the uniform allocation until this world is disposed or + /// another material replaces it. + LarimarMaterialBuffer bindShaderMaterial({ + required String sourcePath, + required int uniformFloatCount, + }) { + final pointer = _requirePointer(); + if (sourcePath.isEmpty) { + throw ArgumentError.value(sourcePath, 'sourcePath', 'must not be empty'); + } + RangeError.checkValueInInterval( + uniformFloatCount, + 0, + 64, + 'uniformFloatCount', + ); + final source = sourcePath.toNativeUtf8(); + final allocationCount = uniformFloatCount == 0 ? 1 : uniformFloatCount; + final uniforms = calloc(allocationCount); + final buffer = LarimarMaterialBuffer._( + this, + source, + uniforms, + uniforms.asTypedList(uniformFloatCount), + ); + try { + using((arena) { + final material = arena(); + material.ref + ..cpu_shader = ffi.nullptr + .cast>() + ..source_path = source.cast() + ..package_path = ffi.nullptr + ..uniform_data = uniforms.cast() + ..uniform_size = uniformFloatCount * ffi.sizeOf(); + _checkNative( + native.eshi_material_set(pointer, material), + 'eshi_material_set', + ); + }); + } catch (_) { + buffer._release(); + rethrow; + } + _materialBuffer?._release(); + _materialBuffer = buffer; + return buffer; + } + + /// Resolves the current entity handle for a retained scene key. + int sceneEntity(int key) { + _requireU32(key, 'key'); + if (key == 0) throw RangeError.value(key, 'key', 'must be non-zero'); + final entity = native.eshi_scene_entity(_requirePointer(), key); + if (entity == 0) { + throw StateError('No live scene entity is bound to key $key.'); + } + return entity; + } + + ({double x, double y}) transformOf(int entity) => using((arena) { + final x = arena(); + final y = arena(); + _checkNative( + native.eshi_transform_get(_requirePointer(), entity, x, y), + 'eshi_transform_get', + ); + return (x: x.value, y: y.value); + }); + + ({double x, double y}) velocityOf(int entity) => using((arena) { + final x = arena(); + final y = arena(); + _checkNative( + native.eshi_velocity_get(_requirePointer(), entity, x, y), + 'eshi_velocity_get', + ); + return (x: x.value, y: y.value); + }); + + void setTransform(int entity, {required double x, required double y}) { + _checkNative( + native.eshi_transform_set(_requirePointer(), entity, x, y), + 'eshi_transform_set', + ); + } + + void setVelocity(int entity, {required double x, required double y}) { + _checkNative( + native.eshi_velocity_set(_requirePointer(), entity, x, y), + 'eshi_velocity_set', + ); + } + /// Releases native state. Repeating the call on the owner isolate is safe. void dispose() { _requireOwnerIsolate(); if (_disposed) { return; } + for (final asset in _assets) { + asset._worldDisposed(); + } + _assets.clear(); native.eshi_world_destroy(_pointer); + _materialBuffer?._release(); + _materialBuffer = null; _pointer = ffi.nullptr; _disposed = true; } diff --git a/bindings/dart/larimar/macos/diagnostics/texture_host_main.mm b/bindings/dart/larimar/macos/diagnostics/texture_host_main.mm new file mode 100644 index 0000000..8a05416 --- /dev/null +++ b/bindings/dart/larimar/macos/diagnostics/texture_host_main.mm @@ -0,0 +1,317 @@ +/** + * @file texture_host_main.mm + * @brief Leak and race harness for the macOS EshiView texture adapter. + * + * The widget lifecycle test in examples/larimar_flutter proves the adapter + * survives real create/resize/suspend/destroy cycles, but it runs inside a + * Flutter engine whose raster thread is timing-dependent: a lock that is merely + * *usually* held would pass it. And it runs under the App Sandbox, where a + * process cannot open its own task port, so `leaks` cannot examine it from + * within. This harness removes the engine instead of the concurrency. It drives + * the real LarimarPlugin through its real method channel entry points from the + * platform thread while a stand-in raster queue borrows surfaces through + * `copyPixelBuffer`, exactly as Flutter's compositor does. + * + * scripts/check_eshiview_host.sh builds it twice: once plain, run under + * `leaks --atExit`, and once with -fsanitize=thread. + * + * The stub registry mirrors Flutter's threading contract rather than + * simplifying it: registration and unregistration are requested on the platform + * thread and applied on the raster queue, `textureFrameAvailable:` copies the + * surface there, and `onTextureUnregistered:` is delivered there too. Its own + * state is confined to that queue so that any race the sanitizer reports + * belongs to the adapter under test and not to the harness. + * + * Exit codes: 0 clean (or no Metal device, reported as a skip), 66 on a + * sanitizer report, 1 on a lifecycle invariant failure. `leaks --atExit` + * discards the child's code, so the verdict is also printed as + * `[host] status=...`. + */ +#import +#import +#import +#import + +namespace { + +const int kDefaultCycles = 24; +const NSInteger kWidth = 640; +const NSInteger kHeight = 360; + +int IntegerFromEnvironment(const char* name, int fallback) { + const char* value = getenv(name); + if (!value || !*value) return fallback; + int parsed = atoi(value); + return parsed > 0 ? parsed : fallback; +} + +} // namespace + +/// Stands in for `FlutterTextureRegistry`, preserving its thread contract. +@interface RasterRegistry : NSObject +@property(nonatomic, readonly) dispatch_queue_t rasterQueue; +- (void)drain; +- (uint64_t)framesCopied; +@end + +@implementation RasterRegistry { + dispatch_queue_t _raster; + /// Raster-queue confined, like the engine's own texture map. + NSMutableDictionary*>* _textures; + uint64_t _framesCopied; + /// Platform-thread confined. + int64_t _nextTextureId; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _raster = dispatch_queue_create("dev.larimar.harness.raster", + DISPATCH_QUEUE_SERIAL); + _textures = [[NSMutableDictionary alloc] init]; + } + return self; +} + +- (dispatch_queue_t)rasterQueue { + return _raster; +} + +- (int64_t)registerTexture:(NSObject*)texture { + int64_t textureId = ++_nextTextureId; + dispatch_async(_raster, ^{ + self->_textures[@(textureId)] = texture; + }); + return textureId; +} + +- (void)textureFrameAvailable:(int64_t)textureId { + dispatch_async(_raster, ^{ + @autoreleasepool { + NSObject* texture = self->_textures[@(textureId)]; + if (!texture) return; + CVPixelBufferRef buffer = [texture copyPixelBuffer]; + if (!buffer) return; + // Read the surface the way a compositor would before handing it back. + // The copy above must have returned an owning reference, or this touches + // memory the platform thread is free to have released underneath us. + if (CVPixelBufferLockBaseAddress(buffer, kCVPixelBufferLock_ReadOnly) == + kCVReturnSuccess) { + const uint8_t* pixels = + static_cast(CVPixelBufferGetBaseAddress(buffer)); + if (pixels) { + volatile uint8_t sample = pixels[0]; + (void)sample; + self->_framesCopied += 1; + } + CVPixelBufferUnlockBaseAddress(buffer, kCVPixelBufferLock_ReadOnly); + } + CVPixelBufferRelease(buffer); + } + }); +} + +- (void)unregisterTexture:(int64_t)textureId { + dispatch_async(_raster, ^{ + @autoreleasepool { + NSObject* texture = self->_textures[@(textureId)]; + if (!texture) return; + [self->_textures removeObjectForKey:@(textureId)]; + if ([texture respondsToSelector:@selector(onTextureUnregistered:)]) { + [texture onTextureUnregistered:texture]; + } + } + }); +} + +- (void)drain { + dispatch_sync(_raster, ^{ + }); +} + +- (uint64_t)framesCopied { + __block uint64_t copied = 0; + dispatch_sync(_raster, ^{ + copied = self->_framesCopied; + }); + return copied; +} + +@end + +namespace { + +/// Invokes one method-channel call synchronously and returns its result. +id Invoke(LarimarPlugin* plugin, NSString* method, NSDictionary* arguments) { + __block id captured = nil; + FlutterMethodCall* call = [FlutterMethodCall methodCallWithMethodName:method + arguments:arguments]; + [plugin handleMethodCall:call + result:^(id result) { + captured = result; + }]; + return captured; +} + +bool IsError(id result, NSString** code) { + if (![result isKindOfClass:[FlutterError class]]) return false; + if (code) *code = ((FlutterError*)result).code; + return true; +} + +/// Writes into the shared surface the way the Metal backend does each frame, so +/// the raster thread's borrow overlaps a real producer rather than idle memory. +void PaintTexture(uint64_t handle, NSInteger width, NSInteger height, int cycle) { + id texture = (__bridge id)(void*)(uintptr_t)handle; + if (!texture) return; + const NSUInteger rowBytes = (NSUInteger)width * 4; + NSMutableData* row = [NSMutableData dataWithLength:rowBytes]; + memset(row.mutableBytes, (int)(cycle & 0xff), rowBytes); + [texture replaceRegion:MTLRegionMake2D(0, 0, (NSUInteger)width, 1) + mipmapLevel:0 + withBytes:row.bytes + bytesPerRow:rowBytes]; + (void)height; +} + +int RunOneCycle(LarimarPlugin* plugin, int cycle) { + id created = Invoke(plugin, @"create", @{@"width" : @(kWidth), @"height" : @(kHeight)}); + NSString* code = nil; + if (IsError(created, &code)) { + fprintf(stderr, "[host] create failed: %s\n", code.UTF8String); + return 1; + } + NSDictionary* surface = created; + NSNumber* textureId = surface[@"textureId"]; + + // Frames, a resize under load, and suspend/resume all while the raster queue + // is borrowing the surface. The resize is the interesting one: it swaps the + // pixel buffer out from under an in-flight copy. + for (int frame = 0; frame < 8; ++frame) { + PaintTexture([surface[@"metalTexture"] unsignedLongLongValue], + [surface[@"width"] integerValue], + [surface[@"height"] integerValue], cycle + frame); + Invoke(plugin, @"present", @{@"textureId" : textureId}); + } + + id resized = Invoke(plugin, @"resize", @{ + @"textureId" : textureId, + @"width" : @(kWidth + ((cycle % 4) + 1) * 8), + @"height" : @(kHeight + ((cycle % 4) + 1) * 8), + }); + if (IsError(resized, &code)) { + fprintf(stderr, "[host] resize failed: %s\n", code.UTF8String); + return 1; + } + surface = resized; + for (int frame = 0; frame < 8; ++frame) { + PaintTexture([surface[@"metalTexture"] unsignedLongLongValue], + [surface[@"width"] integerValue], + [surface[@"height"] integerValue], cycle + frame); + Invoke(plugin, @"present", @{@"textureId" : textureId}); + } + + // Application backgrounding arrives on the platform thread as both a channel + // call and an app-delegate notification; the adapter must tolerate either + // order while frames are still in flight. + NSNotification* notification = + [NSNotification notificationWithName:@"LarimarHarnessLifecycle" object:nil]; + Invoke(plugin, @"setSuspended", @{@"textureId" : textureId, @"suspended" : @YES}); + [plugin handleWillResignActive:notification]; + Invoke(plugin, @"present", @{@"textureId" : textureId}); + [plugin handleDidBecomeActive:notification]; + Invoke(plugin, @"setSuspended", @{@"textureId" : textureId, @"suspended" : @NO}); + Invoke(plugin, @"present", @{@"textureId" : textureId}); + + Invoke(plugin, @"dispose", @{@"textureId" : textureId}); + // Disposal races the borrow deliberately: no drain here. + return 0; +} + +int RunCycles(LarimarPlugin* plugin, int cycles) { + for (int cycle = 0; cycle < cycles; ++cycle) { + // One pool per cycle, because that is what a platform thread's run loop + // gives the adapter in a real application. Without it, the autoreleased + // arrays the app-lifecycle handlers walk would hold every surface of the + // whole run alive and the reclamation check below would prove nothing. + @autoreleasepool { + int status = RunOneCycle(plugin, cycle); + if (status != 0) return status; + } + } + return 0; +} + +int ReportLifecycle(LarimarPlugin* plugin, RasterRegistry* registry, int cycles) { + // The engine may still hold the last surface for a frame or two, so give the + // raster queue a bounded chance to let go before reading the live count. + NSDictionary* diagnostics = nil; + for (int attempt = 0; attempt < 50; ++attempt) { + @autoreleasepool { + [registry drain]; + diagnostics = Invoke(plugin, @"diagnostics", @{}); + } + if ([diagnostics[@"live"] unsignedLongLongValue] == 0) break; + [NSThread sleepForTimeInterval:0.02]; + } + + const uint64_t created = [diagnostics[@"created"] unsignedLongLongValue]; + const uint64_t disposed = [diagnostics[@"disposed"] unsignedLongLongValue]; + const uint64_t live = [diagnostics[@"live"] unsignedLongLongValue]; + const uint64_t registered = [diagnostics[@"registered"] unsignedLongLongValue]; + const uint64_t bytes = [diagnostics[@"liveSurfaceBytes"] unsignedLongLongValue]; + const uint64_t frames = registry.framesCopied; + + printf("[host] cycles=%d created=%llu disposed=%llu registered=%llu live=%llu " + "liveSurfaceBytes=%llu rasterCopies=%llu\n", + cycles, created, disposed, registered, live, bytes, frames); + + int failures = 0; + if (created != (uint64_t)cycles) { + fprintf(stderr, "[host] expected %d creations, saw %llu\n", cycles, created); + failures += 1; + } + if (created != disposed) { + fprintf(stderr, "[host] %llu surfaces created but %llu disposed\n", created, disposed); + failures += 1; + } + if (registered != 0 || live != 0 || bytes != 0) { + fprintf(stderr, "[host] surfaces outlived the loop: registered=%llu live=%llu bytes=%llu\n", + registered, live, bytes); + failures += 1; + } + if (frames == 0) { + fprintf(stderr, "[host] the raster queue never copied a surface; the loop proved nothing\n"); + failures += 1; + } + // `leaks --atExit` swallows the child's exit code, so the harness states its + // own verdict on a line the runner can check. + printf("[host] status=%s\n", failures == 0 ? "ok" : "failed"); + return failures == 0 ? 0 : 1; +} + +} // namespace + +int main(int argc, const char* argv[]) { + (void)argc; + (void)argv; + @autoreleasepool { + const int cycles = IntegerFromEnvironment("LARIMAR_HOST_CYCLES", kDefaultCycles); + + id device = MTLCreateSystemDefaultDevice(); + if (!device) { + // Virtualized runners have no Metal device. Say so plainly rather than + // turning an absent GPU into a red build that reads like a race. + printf("[host] no Metal device on this host; skipping the texture host harness\n"); + printf("[host] status=skipped-no-metal\n"); + return 0; + } + + RasterRegistry* registry = [[RasterRegistry alloc] init]; + LarimarPlugin* plugin = [[LarimarPlugin alloc] initWithRegistry:registry]; + + int status = RunCycles(plugin, cycles); + if (status != 0) return status; + return ReportLifecycle(plugin, registry, cycles); + } +} diff --git a/bindings/dart/larimar/macos/larimar.podspec b/bindings/dart/larimar/macos/larimar.podspec new file mode 100644 index 0000000..e70136d --- /dev/null +++ b/bindings/dart/larimar/macos/larimar.podspec @@ -0,0 +1,23 @@ +Pod::Spec.new do |s| + s.name = 'larimar' + s.version = '0.1.0-dev.2' + s.summary = 'Larimar macOS external-texture host.' + s.description = <<-DESC +IOSurface-backed Flutter textures for the Larimar game engine. + DESC + s.homepage = 'https://github.com/crux161/eshi' + s.license = { :type => 'MIT' } + s.author = 'Larimar contributors' + s.source = { :path => '.' } + s.source_files = 'larimar/Sources/larimar/**/*.{h,mm}' + s.public_header_files = 'larimar/Sources/larimar/include/**/*.h' + s.dependency 'FlutterMacOS' + s.platform = :osx, '10.15' + # Objective-C++ does not emit Swift-style framework autolink metadata, so + # explicitly link the Flutter engine framework supplied by Flutter tooling. + s.frameworks = 'FlutterMacOS', 'CoreVideo', 'Metal', 'IOSurface', 'CoreGraphics', 'ImageIO' + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'CLANG_CXX_LANGUAGE_STANDARD' => 'c++11', + } +end diff --git a/bindings/dart/larimar/macos/larimar/Package.swift b/bindings/dart/larimar/macos/larimar/Package.swift new file mode 100644 index 0000000..908e87b --- /dev/null +++ b/bindings/dart/larimar/macos/larimar/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "larimar", + platforms: [.macOS("12.0")], + products: [ + .library(name: "larimar", targets: ["larimar"]), + ], + dependencies: [ + .package(name: "FlutterFramework", path: "../FlutterFramework"), + ], + targets: [ + .target( + name: "larimar", + dependencies: [ + .product(name: "FlutterFramework", package: "FlutterFramework"), + ], + path: "Sources/larimar", + publicHeadersPath: "include", + linkerSettings: [ + .linkedFramework("CoreVideo"), + .linkedFramework("Metal"), + .linkedFramework("IOSurface"), + .linkedFramework("CoreGraphics"), + .linkedFramework("ImageIO"), + ] + ), + ], + cxxLanguageStandard: .cxx11 +) diff --git a/bindings/dart/larimar/macos/larimar/Sources/larimar/LarimarPlugin.mm b/bindings/dart/larimar/macos/larimar/Sources/larimar/LarimarPlugin.mm new file mode 100644 index 0000000..e5a7d3f --- /dev/null +++ b/bindings/dart/larimar/macos/larimar/Sources/larimar/LarimarPlugin.mm @@ -0,0 +1,506 @@ +#import + +#import +#import +#import +#import + +namespace { + +static NSString* const kChannelName = @"dev.larimar/texture"; +static const NSInteger kMaximumTextureDimension = 16384; + +FlutterError* LarimarError(NSString* code, NSString* message) { + return [FlutterError errorWithCode:code message:message details:nil]; +} + +} // namespace + +@interface LarimarTexture : NSObject + +@property(nonatomic, readonly) int64_t textureId; +@property(nonatomic, readonly) NSInteger width; +@property(nonatomic, readonly) NSInteger height; +@property(nonatomic, readonly) uint64_t metalTextureHandle; +@property(nonatomic, readonly) uint64_t pixelBufferHandle; +@property(nonatomic, readonly) uint64_t surfaceBytes; +@property(nonatomic, readonly) uint64_t copies; +@property(atomic, assign, getter=isSuspended) BOOL suspended; + +- (instancetype)initWithDevice:(id)device + cache:(CVMetalTextureCacheRef)cache + width:(NSInteger)width + height:(NSInteger)height + error:(FlutterError**)error; +- (BOOL)resizeToWidth:(NSInteger)width + height:(NSInteger)height + error:(FlutterError**)error; +- (void)setTextureId:(int64_t)textureId; + +@end + +@implementation LarimarTexture { + id _device; + CVMetalTextureCacheRef _cache; + CVPixelBufferRef _pixelBuffer; + CVMetalTextureRef _cvMetalTexture; + NSLock* _surfaceLock; + NSInteger _width; + NSInteger _height; + int64_t _textureId; + uint64_t _copies; +} + +- (instancetype)initWithDevice:(id)device + cache:(CVMetalTextureCacheRef)cache + width:(NSInteger)width + height:(NSInteger)height + error:(FlutterError**)error { + self = [super init]; + if (self) { + _device = device; + _cache = (CVMetalTextureCacheRef)CFRetain(cache); + _surfaceLock = [[NSLock alloc] init]; + if (![self resizeToWidth:width height:height error:error]) { + return nil; + } + } + return self; +} + +- (void)dealloc { + [_surfaceLock lock]; + if (_cvMetalTexture) CFRelease(_cvMetalTexture); + if (_pixelBuffer) CVPixelBufferRelease(_pixelBuffer); + [_surfaceLock unlock]; + if (_cache) { + // Apple's documented maintenance step: a texture cache may hold entries for + // buffers nothing else references. Measured against the diagnostics harness + // this release path is already clean without it, so this is not a fix for a + // leak we found — it keeps the discipline explicit instead of depending on + // CoreVideo continuing to drop the entry eagerly. + CVMetalTextureCacheFlush(_cache, 0); + CFRelease(_cache); + } +} + +- (NSInteger)width { + [_surfaceLock lock]; + NSInteger value = _width; + [_surfaceLock unlock]; + return value; +} + +- (NSInteger)height { + [_surfaceLock lock]; + NSInteger value = _height; + [_surfaceLock unlock]; + return value; +} + +- (int64_t)textureId { + return _textureId; +} + +- (void)setTextureId:(int64_t)textureId { + _textureId = textureId; +} + +- (uint64_t)metalTextureHandle { + [_surfaceLock lock]; + id texture = + _cvMetalTexture ? CVMetalTextureGetTexture(_cvMetalTexture) : nil; + uint64_t handle = (uint64_t)(uintptr_t)(__bridge void*)texture; + [_surfaceLock unlock]; + return handle; +} + +- (uint64_t)pixelBufferHandle { + [_surfaceLock lock]; + uint64_t handle = (uint64_t)(uintptr_t)_pixelBuffer; + [_surfaceLock unlock]; + return handle; +} + +- (uint64_t)surfaceBytes { + [_surfaceLock lock]; + uint64_t bytes = _pixelBuffer ? (uint64_t)CVPixelBufferGetDataSize(_pixelBuffer) : 0; + [_surfaceLock unlock]; + return bytes; +} + +- (BOOL)resizeToWidth:(NSInteger)width + height:(NSInteger)height + error:(FlutterError**)error { + NSDictionary* attributes = @{ + (id)kCVPixelBufferIOSurfacePropertiesKey : @{}, + (id)kCVPixelBufferMetalCompatibilityKey : @YES, + }; + CVPixelBufferRef pixelBuffer = nil; + CVReturn status = CVPixelBufferCreate(kCFAllocatorDefault, width, height, + kCVPixelFormatType_32BGRA, + (__bridge CFDictionaryRef)attributes, + &pixelBuffer); + if (status != kCVReturnSuccess || !pixelBuffer) { + if (error) { + *error = LarimarError(@"surface-allocation", + [NSString stringWithFormat: + @"CVPixelBufferCreate failed (%d).", status]); + } + return NO; + } + + NSDictionary* textureAttributes = @{ + (id)kCVMetalTextureUsage : @(MTLTextureUsageShaderRead | + MTLTextureUsageShaderWrite | + MTLTextureUsageRenderTarget), + }; + CVMetalTextureRef cvTexture = nil; + status = CVMetalTextureCacheCreateTextureFromImage( + kCFAllocatorDefault, _cache, pixelBuffer, + (__bridge CFDictionaryRef)textureAttributes, + MTLPixelFormatBGRA8Unorm, width, height, 0, &cvTexture); + if (status != kCVReturnSuccess || !cvTexture || + !CVMetalTextureGetTexture(cvTexture)) { + if (cvTexture) CFRelease(cvTexture); + CVPixelBufferRelease(pixelBuffer); + if (error) { + *error = LarimarError(@"metal-texture-allocation", + [NSString stringWithFormat: + @"CVMetalTexture creation failed (%d).", status]); + } + return NO; + } + + [_surfaceLock lock]; + CVMetalTextureRef oldTexture = _cvMetalTexture; + CVPixelBufferRef oldBuffer = _pixelBuffer; + _cvMetalTexture = cvTexture; + _pixelBuffer = pixelBuffer; + _width = width; + _height = height; + [_surfaceLock unlock]; + + // copyPixelBuffer returns an owning reference, so a raster-thread borrower + // can safely finish consuming the old surface after this swap. + if (oldTexture) CFRelease(oldTexture); + if (oldBuffer) CVPixelBufferRelease(oldBuffer); + CVMetalTextureCacheFlush(_cache, 0); + return YES; +} + +- (CVPixelBufferRef)copyPixelBuffer { + [_surfaceLock lock]; + CVPixelBufferRef buffer = + _pixelBuffer ? CVPixelBufferRetain(_pixelBuffer) : nil; + // Counted under the same lock the surface swap holds, because this runs on + // the raster thread. It is the only signal that distinguishes "frames are + // being produced" from "frames are being *composited*": the engine calls this + // when, and only when, it is drawing the texture layer. A view that presents + // frames while this stays at zero is on screen as a hole. + if (buffer) _copies += 1; + [_surfaceLock unlock]; + return buffer; +} + +- (uint64_t)copies { + [_surfaceLock lock]; + uint64_t value = _copies; + [_surfaceLock unlock]; + return value; +} + +/// Writes the surface itself to a PNG, bypassing Flutter's compositor entirely. +/// +/// This answers a question no widget test can: whether the pixels Larimar +/// submitted are in the surface Flutter was handed. A composited capture that +/// comes back empty is either an empty surface or a layer that was not drawn, +/// and those have completely different causes. +- (BOOL)writePNGTo:(NSString*)path error:(FlutterError**)error { + [_surfaceLock lock]; + CVPixelBufferRef buffer = _pixelBuffer ? CVPixelBufferRetain(_pixelBuffer) : nil; + [_surfaceLock unlock]; + if (!buffer) { + if (error) *error = LarimarError(@"no-surface", @"The view has no surface to capture."); + return NO; + } + + BOOL ok = NO; + if (CVPixelBufferLockBaseAddress(buffer, kCVPixelBufferLock_ReadOnly) == kCVReturnSuccess) { + const size_t width = CVPixelBufferGetWidth(buffer); + const size_t height = CVPixelBufferGetHeight(buffer); + const size_t stride = CVPixelBufferGetBytesPerRow(buffer); + void* base = CVPixelBufferGetBaseAddress(buffer); + CGColorSpaceRef space = CGColorSpaceCreateDeviceRGB(); + CGContextRef context = CGBitmapContextCreate( + base, width, height, 8, stride, space, + kCGImageAlphaNoneSkipFirst | kCGBitmapByteOrder32Little); + CGImageRef image = context ? CGBitmapContextCreateImage(context) : NULL; + if (image) { + NSURL* url = [NSURL fileURLWithPath:path]; + CGImageDestinationRef destination = + CGImageDestinationCreateWithURL((__bridge CFURLRef)url, + CFSTR("public.png"), 1, NULL); + if (destination) { + CGImageDestinationAddImage(destination, image, NULL); + ok = CGImageDestinationFinalize(destination); + CFRelease(destination); + } + CGImageRelease(image); + } + if (context) CGContextRelease(context); + CGColorSpaceRelease(space); + CVPixelBufferUnlockBaseAddress(buffer, kCVPixelBufferLock_ReadOnly); + } + CVPixelBufferRelease(buffer); + if (!ok && error) { + *error = LarimarError(@"capture-failed", @"Could not encode the surface as a PNG."); + } + return ok; +} + +- (void)onTextureUnregistered:(NSObject*)texture { + self.suspended = YES; +} + +@end + +@implementation LarimarPlugin { + id _registry; + id _device; + CVMetalTextureCacheRef _cache; + NSMutableDictionary* _textures; + // Weakly held, so a texture the engine is still borrowing on the raster + // thread stays visible to `diagnostics` after the plugin has released it. + // That distinction is the whole point: `registered` returning to zero only + // says the plugin let go, while `live` returning to zero says the surface + // and its IOSurface were actually reclaimed. + NSHashTable* _liveTextures; + uint64_t _created; + uint64_t _resized; + uint64_t _presented; + uint64_t _disposed; + uint64_t _suspensions; + /// Raster-thread borrows charged by surfaces the plugin has already released. + uint64_t _retiredCopies; +} + ++ (void)registerWithRegistrar:(NSObject*)registrar { + FlutterMethodChannel* channel = + [FlutterMethodChannel methodChannelWithName:kChannelName + binaryMessenger:registrar.messenger]; + LarimarPlugin* plugin = [[LarimarPlugin alloc] initWithRegistry:registrar.textures]; + [registrar addMethodCallDelegate:plugin channel:channel]; + [registrar addApplicationDelegate:plugin]; +} + +- (instancetype)initWithRegistry:(id)registry { + self = [super init]; + if (self) { + _registry = registry; + _device = MTLCreateSystemDefaultDevice(); + _textures = [[NSMutableDictionary alloc] init]; + _liveTextures = [NSHashTable weakObjectsHashTable]; + if (_device) { + CVMetalTextureCacheCreate(kCFAllocatorDefault, nil, _device, nil, &_cache); + } + } + return self; +} + +- (void)dealloc { + if (_cache) CFRelease(_cache); +} + +- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { + NSDictionary* arguments = + [call.arguments isKindOfClass:[NSDictionary class]] ? call.arguments : @{}; + + if ([call.method isEqualToString:@"create"]) { + [self createTexture:arguments result:result]; + } else if ([call.method isEqualToString:@"resize"]) { + [self resizeTexture:arguments result:result]; + } else if ([call.method isEqualToString:@"present"]) { + LarimarTexture* texture = [self textureFromArguments:arguments result:result]; + if (texture) { + if (!texture.isSuspended) { + _presented += 1; + [_registry textureFrameAvailable:texture.textureId]; + } + result(nil); + } + } else if ([call.method isEqualToString:@"setSuspended"]) { + LarimarTexture* texture = [self textureFromArguments:arguments result:result]; + if (texture) { + _suspensions += 1; + texture.suspended = [arguments[@"suspended"] boolValue]; + result(nil); + } + } else if ([call.method isEqualToString:@"diagnostics"]) { + result([self diagnostics]); + } else if ([call.method isEqualToString:@"captureSurface"]) { + LarimarTexture* texture = [self textureFromArguments:arguments result:result]; + if (texture) { + NSString* path = arguments[@"path"]; + if (![path isKindOfClass:[NSString class]]) { + result(LarimarError(@"invalid-path", @"A destination path is required.")); + return; + } + FlutterError* error = nil; + result([texture writePNGTo:path error:&error] ? (id)path : (id)error); + } + } else if ([call.method isEqualToString:@"dispose"]) { + id value = arguments[@"textureId"]; + if (![value isKindOfClass:[NSNumber class]]) { + result(LarimarError(@"invalid-texture", @"A numeric textureId is required.")); + return; + } + NSNumber* textureId = value; + LarimarTexture* texture = _textures[textureId]; + if (texture) { + _disposed += 1; + _retiredCopies += texture.copies; + texture.suspended = YES; + [_textures removeObjectForKey:textureId]; + [_registry unregisterTexture:texture.textureId]; + } + result(nil); + } else { + result(FlutterMethodNotImplemented); + } +} + +- (void)createTexture:(NSDictionary*)arguments result:(FlutterResult)result { + if (!_device || !_cache) { + result(LarimarError(@"metal-unavailable", @"No Metal device is available.")); + return; + } + NSInteger width = [arguments[@"width"] integerValue]; + NSInteger height = [arguments[@"height"] integerValue]; + if (![self dimensionsAreValidWidth:width height:height result:result]) return; + + FlutterError* error = nil; + LarimarTexture* texture = [[LarimarTexture alloc] initWithDevice:_device + cache:_cache + width:width + height:height + error:&error]; + if (!texture) { + result(error); + return; + } + int64_t textureId = [_registry registerTexture:texture]; + if (textureId == 0) { + result(LarimarError(@"texture-registration", + @"Flutter rejected the external texture.")); + return; + } + [texture setTextureId:textureId]; + _textures[@(textureId)] = texture; + [_liveTextures addObject:texture]; + _created += 1; + result([self descriptionForTexture:texture]); +} + +- (void)resizeTexture:(NSDictionary*)arguments result:(FlutterResult)result { + LarimarTexture* texture = [self textureFromArguments:arguments result:result]; + if (!texture) return; + NSInteger width = [arguments[@"width"] integerValue]; + NSInteger height = [arguments[@"height"] integerValue]; + if (![self dimensionsAreValidWidth:width height:height result:result]) return; + if (texture.width == width && texture.height == height) { + result([self descriptionForTexture:texture]); + return; + } + FlutterError* error = nil; + if (![texture resizeToWidth:width height:height error:&error]) { + result(error); + return; + } + _resized += 1; + result([self descriptionForTexture:texture]); +} + +- (LarimarTexture*)textureFromArguments:(NSDictionary*)arguments + result:(FlutterResult)result { + id value = arguments[@"textureId"]; + if (![value isKindOfClass:[NSNumber class]]) { + result(LarimarError(@"invalid-texture", @"A numeric textureId is required.")); + return nil; + } + NSNumber* textureId = value; + LarimarTexture* texture = _textures[textureId]; + if (!texture) { + result(LarimarError(@"unknown-texture", @"The EshiView texture is disposed.")); + } + return texture; +} + +- (BOOL)dimensionsAreValidWidth:(NSInteger)width + height:(NSInteger)height + result:(FlutterResult)result { + if (width < 1 || height < 1 || width > kMaximumTextureDimension || + height > kMaximumTextureDimension) { + result(LarimarError(@"invalid-size", + @"Texture dimensions must be between 1 and 16384.")); + return NO; + } + return YES; +} + +// The leak gate. Every counter is mutated on the platform thread only, which is +// also where Flutter requires texture registration and unregistration to +// happen, so no lock is needed here — see the thread-affinity rules in +// docs/larimar/NATIVE_CONTRACT.md. +- (NSDictionary*)diagnostics { + NSArray* live = _liveTextures.allObjects; + uint64_t bytes = 0; + uint64_t copies = _retiredCopies; + for (LarimarTexture* texture in live) { + bytes += texture.surfaceBytes; + copies += texture.copies; + } + return @{ + @"created" : @(_created), + @"resized" : @(_resized), + @"presented" : @(_presented), + @"disposed" : @(_disposed), + @"suspensions" : @(_suspensions), + @"registered" : @(_textures.count), + @"live" : @(live.count), + @"liveSurfaceBytes" : @(bytes), + @"copies" : @(copies), + @"metalAvailable" : @(_device != nil && _cache != nil), + }; +} + +- (NSDictionary*)descriptionForTexture:(LarimarTexture*)texture { + return @{ + @"textureId" : @(texture.textureId), + @"metalTexture" : @(texture.metalTextureHandle), + @"pixelBuffer" : @(texture.pixelBufferHandle), + @"width" : @(texture.width), + @"height" : @(texture.height), + }; +} + +- (void)handleWillResignActive:(NSNotification*)notification { + for (LarimarTexture* texture in _textures.allValues) texture.suspended = YES; +} + +- (void)handleDidBecomeActive:(NSNotification*)notification { + for (LarimarTexture* texture in _textures.allValues) texture.suspended = NO; +} + +- (void)handleWillTerminate:(NSNotification*)notification { + NSArray* textureIds = _textures.allKeys; + for (NSNumber* textureId in textureIds) { + LarimarTexture* texture = _textures[textureId]; + _disposed += 1; + texture.suspended = YES; + [_registry unregisterTexture:texture.textureId]; + } + [_textures removeAllObjects]; +} + +@end diff --git a/bindings/dart/larimar/macos/larimar/Sources/larimar/include/larimar/LarimarPlugin.h b/bindings/dart/larimar/macos/larimar/Sources/larimar/include/larimar/LarimarPlugin.h new file mode 100644 index 0000000..143b1e4 --- /dev/null +++ b/bindings/dart/larimar/macos/larimar/Sources/larimar/include/larimar/LarimarPlugin.h @@ -0,0 +1,9 @@ +#import + +@interface LarimarPlugin : NSObject + +/// Host seam kept public so diagnostics harnesses can drive the texture adapter +/// with a stub registry, without an engine, a window, or a Dart isolate. +- (instancetype)initWithRegistry:(id)registry; + +@end diff --git a/bindings/dart/larimar/pubspec.yaml b/bindings/dart/larimar/pubspec.yaml index f3d52c6..af7f144 100644 --- a/bindings/dart/larimar/pubspec.yaml +++ b/bindings/dart/larimar/pubspec.yaml @@ -1,20 +1,25 @@ name: larimar description: Dart and Flutter FFI bindings for the Larimar game engine core. -version: 0.1.0-dev.1 +version: 0.1.0-dev.2 publish_to: none environment: sdk: '>=3.10.0 <4.0.0' + flutter: '>=3.38.0' dependencies: code_assets: ^1.1.0 ffi: ^2.2.0 + flutter: + sdk: flutter hooks: ^2.0.0 logging: ^1.3.0 native_toolchain_c: ^0.19.0 dev_dependencies: ffigen: ^21.0.0 + flutter_test: + sdk: flutter lints: ^6.0.0 test: ^1.25.8 @@ -22,3 +27,11 @@ topics: - ffi - flutter - game-engine + +flutter: + plugin: + platforms: + macos: + pluginClass: LarimarPlugin + assets: + - assets/pong.gpu.cpp diff --git a/bindings/dart/larimar/test/eshiview_test.dart b/bindings/dart/larimar/test/eshiview_test.dart new file mode 100644 index 0000000..a7df75c --- /dev/null +++ b/bindings/dart/larimar/test/eshiview_test.dart @@ -0,0 +1,186 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:larimar/larimar.dart'; + +void main() { + const channel = MethodChannel('dev.larimar/texture'); + + test('decodes the host diagnostics the lifecycle gate reads', () async { + TestWidgetsFlutterBinding.ensureInitialized(); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'diagnostics'); + return { + 'created': 12, + 'resized': 12, + 'presented': 60, + 'disposed': 12, + 'suspensions': 24, + 'registered': 0, + 'live': 0, + 'liveSurfaceBytes': 0, + 'copies': 58, + 'metalAvailable': true, + }; + }); + final diagnostics = await const MacOSEshiViewHost().diagnostics(); + expect(diagnostics.created, 12); + expect(diagnostics.disposed, 12); + expect(diagnostics.live, 0); + expect(diagnostics.liveSurfaceBytes, 0); + expect(diagnostics.copies, 58); + expect(diagnostics.metalAvailable, isTrue); + expect(diagnostics.toString(), contains('presented: 60')); + + // A host that answers with nothing must not read as "nothing is leaking". + messenger.setMockMethodCallHandler(channel, (call) async => null); + await expectLater( + const MacOSEshiViewHost().diagnostics(), + throwsA(isA()), + ); + }); + testWidgets('serializes create, resize, lifecycle, frames, and disposal', ( + tester, + ) async { + final host = _FakeHost(); + final world = LarimarWorld(); + addTearDown(world.dispose); + + Future mount(Size size) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 2), + child: Center( + child: SizedBox( + width: size.width, + height: size.height, + child: EshiView(world: world, host: host), + ), + ), + ), + ); + await tester.pump(); + } + + await mount(const Size(160, 90)); + expect(host.created, const [Size(320, 180)]); + expect(find.byType(Texture), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 16)); + expect(host.renderCount, greaterThan(0)); + expect(host.presentCount, greaterThan(0)); + + await mount(const Size(200, 120)); + expect(host.resized, const [Size(400, 240)]); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + await tester.pump(); + expect(host.suspensions.last, isTrue); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + expect(host.suspensions.last, isFalse); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + expect(host.disposed, 1); + }); + + testWidgets('disposes a surface created after the widget is removed', ( + tester, + ) async { + final host = _FakeHost()..delayCreate = true; + final world = LarimarWorld(); + addTearDown(world.dispose); + + await tester.pumpWidget( + SizedBox( + width: 64, + height: 64, + child: EshiView(world: world, host: host), + ), + ); + await tester.pump(); + await tester.pumpWidget(const SizedBox()); + host.finishCreate(); + await tester.pump(); + await tester.pump(); + + expect(host.disposed, 1); + }); +} + +final class _FakeHost implements EshiViewHost { + final List created = []; + final List resized = []; + final List suspensions = []; + bool delayCreate = false; + int renderCount = 0; + int presentCount = 0; + int disposed = 0; + int _nextTexture = 1; + EshiViewSurface? _pendingSurface; + Completer? _pendingCreate; + + @override + Future create({required int width, required int height}) { + created.add(Size(width.toDouble(), height.toDouble())); + final surface = _surface(width, height); + if (!delayCreate) return Future.value(surface); + _pendingSurface = surface; + _pendingCreate = Completer(); + return _pendingCreate!.future; + } + + void finishCreate() => _pendingCreate!.complete(_pendingSurface!); + + @override + Future dispose(EshiViewSurface surface) async { + disposed += 1; + } + + @override + Future present(EshiViewSurface surface) async { + presentCount += 1; + } + + @override + void render(EshiViewSurface surface, LarimarWorld world, double time) { + renderCount += 1; + } + + @override + Future resize( + EshiViewSurface surface, { + required int width, + required int height, + }) async { + resized.add(Size(width.toDouble(), height.toDouble())); + return EshiViewSurface( + textureId: surface.textureId, + metalTexture: surface.metalTexture + 1, + pixelBuffer: surface.pixelBuffer + 1, + width: width, + height: height, + ); + } + + @override + Future setSuspended(EshiViewSurface surface, bool suspended) async { + suspensions.add(suspended); + } + + EshiViewSurface _surface(int width, int height) => EshiViewSurface( + textureId: _nextTexture++, + metalTexture: 100, + pixelBuffer: 200, + width: width, + height: height, + ); +} diff --git a/bindings/dart/larimar/test/native_smoke_test.dart b/bindings/dart/larimar/test/native_smoke_test.dart index 2f35911..e5d0c41 100644 --- a/bindings/dart/larimar/test/native_smoke_test.dart +++ b/bindings/dart/larimar/test/native_smoke_test.dart @@ -4,7 +4,10 @@ import 'package:test/test.dart'; void main() { test('loads and validates the native contract', () { final contract = LarimarContract.current; - expect(contract.abiVersion, 1 << 24); + // 1.1.0. The minor moved when the asset handles were added: additive, so a + // package built against 1.0 still loads, which is what the check in + // eshi_abi_check enforces. + expect(contract.abiVersion, (1 << 24) | (1 << 16)); expect(contract.commandProtocolVersion, 1); expect(contract.eventProtocolVersion, 1); }); diff --git a/build.zig b/build.zig index 56a5233..bcad95b 100644 --- a/build.zig +++ b/build.zig @@ -1,5 +1,9 @@ const std = @import("std"); +/// Kept in step with scripts/vendor_filament.sh, which owns the checksums. +const filament_version = "v1.75.0"; +const filament_default_path = "third_party/filament/" ++ filament_version; + const example_sources = [_][]const u8{ "examples/aurora.cpp", "examples/bubbles.cpp", @@ -70,11 +74,16 @@ pub fn build(b: *std.Build) void { "filament", "Build Larimar's Filament backend (default: false)", ) orelse false; + // The pinned distribution, fetched and checksummed by + // scripts/vendor_filament.sh. The default is the vendored path rather than + // whatever Filament checkout happens to exist on the machine: a renderer + // that silently builds against a different version than the one its + // materials were compiled by is a debugging afternoon nobody needs. const filament_path = b.option( []const u8, "filament-path", - "Installed Filament distribution (default: resources/filament/out/release/filament)", - ) orelse "resources/filament/out/release/filament"; + "Installed Filament distribution (default: " ++ filament_default_path ++ ")", + ) orelse filament_default_path; const filament_arch = b.option( []const u8, "filament-arch", @@ -113,21 +122,121 @@ pub fn build(b: *std.Build) void { "Path to the libomp prefix on macOS", ) orelse b.pathJoin(&.{ homebrew_prefix, "opt", "libomp" }); + ensurePkgConfig(b, homebrew_prefix); + const eshi_step = b.step("eshi", "Build and install only the main eshi executable"); const examples_step = b.step("examples", "Build and install all example executables"); + // Build-time tools. Neither links a backend or a host: matgen reads a + // shader through the same transpiler the runtime uses, and ppmdiff reads + // frames a host already dumped. + const matgen = addToolExecutable(b, .{ + .name = "eshi-matgen", + .sources = &.{ "core/tools/matgen.cpp", "core/src/render/transpile.cpp" }, + .target = target, + .optimize = optimize, + }); + const ppmdiff = addToolExecutable(b, .{ + .name = "eshi-ppmdiff", + .sources = &.{"core/tools/ppmdiff.cpp"}, + .target = target, + .optimize = optimize, + }); + const gemgen = addToolExecutable(b, .{ + .name = "eshi-gemgen", + .sources = &.{"core/tools/gemgen.cpp"}, + .target = target, + .optimize = optimize, + }); + const matgen_install = b.addInstallArtifact(matgen, .{}); + const ppmdiff_install = b.addInstallArtifact(ppmdiff, .{}); + const gemgen_install = b.addInstallArtifact(gemgen, .{}); + const tools_step = b.step("tools", "Build the material, asset, and frame-comparison tools"); + tools_step.dependOn(&matgen_install.step); + tools_step.dependOn(&ppmdiff_install.step); + tools_step.dependOn(&gemgen_install.step); + b.getInstallStep().dependOn(&matgen_install.step); + b.getInstallStep().dependOn(&ppmdiff_install.step); + b.getInstallStep().dependOn(&gemgen_install.step); + + // Larimar: the Phase 0 engine core plus its hosts. Built independently of // the shadertoy gallery above so neither path can break the other. + // Every material has exactly one definition: the shader source the Ink tier + // compiles and the GPU tiers transpile. matgen re-expresses it as a Filament + // material and matc packages that, so no .mat exists as a file anybody can + // edit — or forget to edit. + const MaterialPackage = struct { + fn build( + owner: *std.Build, + tool: *std.Build.Step.Compile, + matc_path: []const u8, + source: []const u8, + name: []const u8, + uniform_floats: []const u8, + ) std.Build.LazyPath { + const generate = owner.addRunArtifact(tool); + generate.addFileArg(owner.path(source)); + generate.addArgs(&.{ "--uniform-floats", uniform_floats, "-o" }); + const material = generate.addOutputFileArg(owner.fmt("{s}.mat", .{name})); + + const matc = owner.addSystemCommand(&.{ matc_path, "-p", "all", "-a", "all", "-o" }); + const output = matc.addOutputFileArg(owner.fmt("{s}.filamat", .{name})); + matc.addFileArg(material); + return output; + } + }; + + // The GPU tiers read their shader at runtime, so an installed binary needs + // the sources installed beside it. Without this, zig-out/bin/pong renders + // black frames at Paper or Brush from any directory but this one — the + // backend reports the missing file, the banner still names the tier, and + // the exit code is zero. + const shader_install_dir = b.getInstallPath(.prefix, "share/eshi/shaders"); + const shaders_step = b.step("shaders", "Install the shader sources and reference asset read at runtime"); + for (example_sources) |source| { + if (!std.mem.endsWith(u8, source, ".cpp")) continue; + const install = b.addInstallFile( + b.path(source), + b.fmt("share/eshi/shaders/{s}", .{std.fs.path.basename(source)}), + ); + shaders_step.dependOn(&install.step); + } + const install_pong_shader = b.addInstallFile( + b.path("examples/pong/pong.gpu.cpp"), + "share/eshi/shaders/pong.gpu.cpp", + ); + shaders_step.dependOn(&install_pong_shader.step); + + // The reference asset, generated rather than checked in for the same reason + // the materials are: the input is reviewable and the output reproducible. + // PLAN Step 8 replaces it with a Blender-authored gem at the same path. + const generate_logo = b.addRunArtifact(gemgen); + generate_logo.addArg("-o"); + const logo_glb = generate_logo.addOutputFileArg("larimar_logo.glb"); + const install_logo = b.addInstallFile(logo_glb, "share/eshi/larimar_logo.glb"); + shaders_step.dependOn(&install_logo.step); + b.getInstallStep().dependOn(shaders_step); + + const matc_path = b.pathJoin(&.{ filament_path, "bin", "matc" }); const pong_package_path: ?[]const u8 = if (use_filament) b.getInstallPath(.prefix, "share/eshi/pong.filamat") else null; - const pong_package: ?std.Build.LazyPath = if (use_filament) package: { - const matc = b.addSystemCommand(&.{b.pathJoin(&.{ filament_path, "bin", "matc" }), "-p", "all", "-a", "all", "-o"}); - const output = matc.addOutputFileArg("pong.filamat"); - matc.addFileArg(b.path("examples/pong/pong.mat")); - break :package output; - } else null; + const gallery_package_path: ?[]const u8 = if (use_filament) + b.getInstallPath(.prefix, "share/eshi/ripple.filamat") + else + null; + const pong_package: ?std.Build.LazyPath = if (use_filament) + MaterialPackage.build(b, matgen, matc_path, "examples/pong/pong.gpu.cpp", "pong", "64") + else + null; + // The gallery half of the same claim: ripple reaches Filament from the file + // Ink compiles, with nothing written twice on the way. + const gallery_package: ?std.Build.LazyPath = if (use_filament) + MaterialPackage.build(b, matgen, matc_path, "examples/ripple.cpp", "ripple", "0") + else + null; const larimar_install = addLarimarExecutable(b, .{ .name = "pong", @@ -137,6 +246,7 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, .sumi_include = sumi_include, + .shader_install_dir = shader_install_dir, .libomp_prefix = libomp_prefix, .use_openmp = use_openmp, .use_metal = use_metal, @@ -144,20 +254,26 @@ pub fn build(b: *std.Build) void { .filament_path = filament_path, .filament_arch = filament_arch, .pong_package_path = pong_package_path, + .gallery_package_path = gallery_package_path, .use_lto = use_lto, }); const larimar_step = b.step("larimar", "Build the Larimar core and the SDL host (pong)"); larimar_step.dependOn(&larimar_install.step); + // A binary without its shaders renders black frames at the GPU tiers. + larimar_step.dependOn(shaders_step); b.getInstallStep().dependOn(&larimar_install.step); if (pong_package) |package| { const install_package = b.addInstallFile(package, "share/eshi/pong.filamat"); + const install_gallery = b.addInstallFile(gallery_package.?, "share/eshi/ripple.filamat"); const install_filament_license = b.addInstallFile( pathFromOption(b, b.pathJoin(&.{ filament_path, "LICENSE" })), "share/licenses/filament/LICENSE", ); larimar_step.dependOn(&install_package.step); + larimar_step.dependOn(&install_gallery.step); larimar_step.dependOn(&install_filament_license.step); b.getInstallStep().dependOn(&install_package.step); + b.getInstallStep().dependOn(&install_gallery.step); b.getInstallStep().dependOn(&install_filament_license.step); } @@ -202,12 +318,29 @@ pub fn build(b: *std.Build) void { const sanitized_test_step = b.step("test-sanitize", "Run core tests under ASan and UBSan"); sanitized_test_step.dependOn(&sanitized_test_command.step); + const conformance_command = b.addSystemCommand(&.{"./scripts/check_tier_conformance.sh"}); + conformance_command.step.dependOn(&ppmdiff_install.step); + const conformance_step = b.step( + "conformance", + "Compare the render tiers frame by frame (requires a built pong)", + ); + conformance_step.dependOn(&conformance_command.step); + + // The gates above answer machine questions. This one produces the thing a + // person looks at, because "does it still look right" has no other answer. + const demo_command = b.addSystemCommand(&.{"./scripts/build_demo.sh"}); + demo_command.step.dependOn(&larimar_install.step); + demo_command.step.dependOn(shaders_step); + const demo_step = b.step("demo", "Render build/demo: the same scenes on every available tier"); + demo_step.dependOn(&demo_command.step); + const main_install = addEshiExecutable(b, .{ .name = "eshi", .shader_source = "shader.cpp", .target = target, .optimize = optimize, .sumi_include = sumi_include, + .shader_install_dir = shader_install_dir, .libomp_prefix = libomp_prefix, .use_metal = use_metal, .use_opengl = use_opengl, @@ -225,6 +358,7 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, .sumi_include = sumi_include, + .shader_install_dir = shader_install_dir, .libomp_prefix = libomp_prefix, .use_metal = use_metal, .use_opengl = use_opengl, @@ -245,6 +379,7 @@ const ExecutableOptions = struct { target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, sumi_include: []const u8, + shader_install_dir: []const u8, libomp_prefix: []const u8, use_metal: bool, use_opengl: bool, @@ -262,6 +397,9 @@ fn addEshiExecutable(b: *std.Build, options: ExecutableOptions) *std.Build.Step. module.addIncludePath(b.path(".")); module.addIncludePath(pathFromOption(b, options.sumi_include)); + // Where the GPU tiers look for a shader source when the working directory + // is not the repository. ESHI_SHADER_DIR overrides it at runtime. + module.addCMacro("ESHI_SHADER_INSTALL_DIR", b.fmt("\"{s}\"", .{options.shader_install_dir})); module.addCMacro("LINK_SHADER", "1"); const cpp_flags: []const []const u8 = if (options.use_openmp) @@ -357,6 +495,7 @@ const LarimarOptions = struct { target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, sumi_include: []const u8, + shader_install_dir: []const u8, libomp_prefix: []const u8, use_openmp: bool, use_metal: bool, @@ -364,6 +503,7 @@ const LarimarOptions = struct { filament_path: []const u8, filament_arch: []const u8, pong_package_path: ?[]const u8, + gallery_package_path: ?[]const u8, use_lto: bool, }; @@ -383,6 +523,9 @@ fn addLarimarExecutable(b: *std.Build, options: LarimarOptions) *std.Build.Step. module.addIncludePath(b.path(".")); module.addIncludePath(b.path("core/include")); module.addIncludePath(pathFromOption(b, options.sumi_include)); + // Where the GPU tiers look for a shader source when the working directory + // is not the repository. ESHI_SHADER_DIR overrides it at runtime. + module.addCMacro("ESHI_SHADER_INSTALL_DIR", b.fmt("\"{s}\"", .{options.shader_install_dir})); const cpp_flags: []const []const u8 = if (options.use_openmp) if (options.target.result.os.tag == .macos) @@ -434,6 +577,9 @@ fn addLarimarExecutable(b: *std.Build, options: LarimarOptions) *std.Build.Step. if (options.pong_package_path) |package_path| { module.addCMacro("ESHI_PONG_PACKAGE_PATH", b.fmt("\"{s}\"", .{package_path})); } + if (options.gallery_package_path) |package_path| { + module.addCMacro("ESHI_GALLERY_PACKAGE_PATH", b.fmt("\"{s}\"", .{package_path})); + } module.addSystemIncludePath(pathFromOption(b, b.pathJoin(&.{ options.filament_path, "include" }))); module.addLibraryPath(pathFromOption(b, b.pathJoin(&.{ options.filament_path, "lib", options.filament_arch }))); module.addCSourceFile(.{ @@ -442,11 +588,26 @@ fn addLarimarExecutable(b: *std.Build, options: LarimarOptions) *std.Build.Step. .language = .cpp, }); + // gltfio brings its own dependency set: the ubershader archive it + // vends materials from, the glTF parser, and the codecs a glTF may + // reference. Link order matters for static archives, so the consumers + // come before what they consume. const filament_libraries = [_][]const u8{ + "gltfio", + "gltfio_core", "filament", "backend", "filabridge", "filaflat", + "geometry", + "dracodec", + "ktxreader", + "image", + "meshoptimizer", + "stb", + "uberarchive", + "uberzlib", + "basis_transcoder", "bluegl", "bluevk", "smol-v", @@ -509,6 +670,113 @@ fn addLarimarExecutable(b: *std.Build, options: LarimarOptions) *std.Build.Step. return b.addInstallArtifact(exe, .{}); } +/// Makes `pkg-config` usable when the one on PATH cannot see system packages. +/// +/// On a machine with devkitpro installed, `pkg-config` resolves to devkitpro's, +/// whose built-in search path contains only devkitpro's own packages. Every +/// SDL-linked target then aborts with `pkg-config failed for library SDL2_ttf` +/// — a Zig panic and a stack trace, for what is really an unset environment +/// variable. Probing first means a machine whose pkg-config already works is +/// left exactly as it was. +fn ensurePkgConfig(b: *std.Build, homebrew_prefix: []const u8) void { + if (pkgConfigSees(b, "SDL2_ttf")) return; + + var search: []const u8 = b.graph.environ_map.get("PKG_CONFIG_PATH") orelse ""; + search = appendPkgConfigDir(b, search, b.pathJoin(&.{ homebrew_prefix, "lib", "pkgconfig" })); + search = appendPkgConfigDir(b, search, b.pathJoin(&.{ homebrew_prefix, "share", "pkgconfig" })); + // Every keg-only formula keeps its own .pc files under opt//lib. + search = appendPkgConfigTree(b, search, b.pathJoin(&.{ homebrew_prefix, "opt" }), &.{ "lib", "pkgconfig" }); + // And Homebrew keeps the macOS system-library .pc files per OS version, which + // is where SDL2_ttf -> freetype2 -> zlib finally resolves. + search = appendPkgConfigTree(b, search, b.pathJoin(&.{ homebrew_prefix, "Library", "Homebrew", "os", "mac", "pkgconfig" }), &.{}); + + if (search.len == 0) return; + b.graph.environ_map.put("PKG_CONFIG_PATH", search) catch return; + + if (!pkgConfigSees(b, "SDL2_ttf")) { + std.debug.print( + \\note: pkg-config cannot find SDL2_ttf, so SDL-linked targets will fail. + \\ `pkg-config` on PATH is {s}. Install the dependencies, or set + \\ PKG_CONFIG_PATH to a prefix that has them. + \\ + , .{b.graph.environ_map.get("PATH") orelse "unset"}); + } +} + +/// True when pkg-config, as the build will invoke it, knows this package. +/// +/// runAllowFail returns an error for any non-zero exit and only writes the code +/// on that path, so success is "it returned at all" — reading the code instead +/// reports every probe as a failure, including the one that works. +fn pkgConfigSees(b: *std.Build, package: []const u8) bool { + var code: u8 = 1; + const output = b.runAllowFail( + &.{ "pkg-config", "--exists", package }, + &code, + .ignore, + ) catch return false; + b.allocator.free(output); + return true; +} + +fn appendPkgConfigDir(b: *std.Build, search: []const u8, dir: []const u8) []const u8 { + std.Io.Dir.accessAbsolute(b.graph.io, dir, .{}) catch return search; + if (search.len == 0) return dir; + return b.fmt("{s}:{s}", .{ search, dir }); +} + +/// Appends `//` for every entry in `root`. +fn appendPkgConfigTree(b: *std.Build, search: []const u8, root: []const u8, suffix: []const []const u8) []const u8 { + var result = search; + var dir = std.Io.Dir.openDirAbsolute(b.graph.io, root, .{ .iterate = true }) catch return result; + defer dir.close(b.graph.io); + var it = dir.iterate(); + while (it.next(b.graph.io) catch null) |entry| { + if (entry.kind != .directory and entry.kind != .sym_link) continue; + var parts = std.ArrayList([]const u8).initCapacity(b.allocator, suffix.len + 2) catch return result; + parts.appendAssumeCapacity(root); + parts.appendAssumeCapacity(entry.name); + for (suffix) |part| parts.appendAssumeCapacity(part); + result = appendPkgConfigDir(b, result, b.pathJoin(parts.items)); + } + return result; +} + +const ToolOptions = struct { + name: []const u8, + sources: []const []const u8, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, +}; + +/// A host-side tool: C++ and libc++ only, no SDL, no FFmpeg, no backend. +/// +/// These run on a build machine, never on a device, so they take none of the +/// options the runtime executables carry. If one of them ever needs a system +/// package, it has stopped being a tool. +fn addToolExecutable(b: *std.Build, options: ToolOptions) *std.Build.Step.Compile { + const module = b.createModule(.{ + .target = options.target, + .optimize = options.optimize, + .link_libc = true, + .link_libcpp = true, + }); + module.addIncludePath(b.path("core/include")); + module.addCSourceFiles(.{ + .files = options.sources, + .flags = &.{ "-std=c++11", "-Wall", "-Wextra" }, + .language = .cpp, + }); + + const exe = b.addExecutable(.{ + .name = options.name, + .root_module = module, + .use_llvm = true, + }); + exe.lto = .none; + return exe; +} + fn pathFromOption(b: *std.Build, path: []const u8) std.Build.LazyPath { return if (std.fs.path.isAbsolute(path)) .{ .cwd_relative = path } diff --git a/core/include/eshi/eshi.h b/core/include/eshi/eshi.h index cc44fee..e7f6288 100644 --- a/core/include/eshi/eshi.h +++ b/core/include/eshi/eshi.h @@ -45,7 +45,7 @@ extern "C" { #define ESHI_ABI_VERSION_MINOR_OF(version) (((uint32_t)(version) >> 16) & 0xFFu) #define ESHI_ABI_VERSION_PATCH_OF(version) (((uint32_t)(version) >> 8) & 0xFFu) -#define ESHI_ABI_VERSION ESHI_ABI_VERSION_PACK(1, 0, 0) +#define ESHI_ABI_VERSION ESHI_ABI_VERSION_PACK(1, 1, 0) #define ESHI_COMMAND_PROTOCOL_VERSION 1u #define ESHI_EVENT_PROTOCOL_VERSION 1u @@ -587,6 +587,70 @@ int eshi_grade_available(EshiGrade grade); /** Highest grade available in this binary, or ESHI_GRADE_INK. */ EshiGrade eshi_grade_best(void); +/* =========================================================================== + * Assets + * + * A 3D asset is an opaque handle. The loader, the meshes, the materials and + * the renderable entities behind it belong to whichever backend the world + * selected, and none of those types appear here — the boundary rule in + * ARCHITECTURE.md §5 applies to Filament exactly as it does to SDL. + * + * Geometry is a capability, not a guarantee. A grade whose backend has no + * scene — Ink, Paper, direct Metal — returns ESHI_ERR_UNSUPPORTED rather than + * pretending to load something, so a host can ask instead of assuming. + * ==========================================================================*/ + +/** An asset the backend has loaded. Zero is "no asset". */ +typedef uint32_t EshiAsset; + +/** + * Loads a glTF or GLB file for this world's backend. + * + * Loading is separate from instancing because the expensive half — parsing, + * decoding buffers, uploading vertex data and textures — happens once per + * asset no matter how many copies of it a scene holds, and no matter how many + * views draw that scene. + * + * Returns ESHI_ERR_UNSUPPORTED when the grade has no 3D scene, + * ESHI_ERR_INVALID when the file is missing or is not a glTF the loader + * accepts, and ESHI_ERR_NOMEM when the upload fails. A failed load leaves + * `out_asset` untouched and the world renderable; it never half-loads. + */ +EshiResult eshi_asset_load(EshiWorld* w, const char* path, EshiAsset* out_asset); + +/** + * Places an instance of a loaded asset into the world's scene. + * + * Instances share the asset's uploaded geometry and materials; the cost of a + * second one is a transform and a set of renderable entities, not a second + * copy of the mesh. + * + * `x`, `y`, `z` position the instance, and `scale` is uniform. The convention + * is right-handed, Y up, metres, matching glTF's own. + */ +EshiResult eshi_asset_instance(EshiWorld* w, EshiAsset asset, + float x, float y, float z, float scale); + +/** + * Places an instance whose root rotates around glTF's Y-up axis in the native + * render system. The host declares the rate once; no per-frame FFI update is + * required. `radians_per_second` may be negative and zero is stationary. + */ +EshiResult eshi_asset_instance_animated(EshiWorld* w, EshiAsset asset, + float x, float y, float z, float scale, + float radians_per_second); + +/** + * Releases an asset and every instance of it. + * + * Assets are also released with the world, so a host that exits does not need + * to call this; a host that swaps scenes does. + */ +EshiResult eshi_asset_release(EshiWorld* w, EshiAsset asset); + +/** Number of assets this world currently holds. Zero on grades without 3D. */ +uint32_t eshi_asset_count(const EshiWorld* w); + /** * Supplies OpenGL entry points for the Paper backend. * diff --git a/core/src/eshi_internal.h b/core/src/eshi_internal.h index 117d3ad..53aac9c 100644 --- a/core/src/eshi_internal.h +++ b/core/src/eshi_internal.h @@ -54,8 +54,9 @@ void eshi__frame_params(EshiWorld* w, * table here and nothing else. * -------------------------------------------------------------------------*/ typedef struct EshiBackend EshiBackend; +typedef struct EshiBackendVTable EshiBackendVTable; -typedef struct EshiBackendVTable { +struct EshiBackendVTable { const char* name; /** Probes whether this backend can run right now. */ @@ -76,7 +77,48 @@ typedef struct EshiBackendVTable { uint8_t* pixels, int32_t stride, float time, EshiShaderFn cpu_shader, const void* uniforms, size_t uniform_size); -} EshiBackendVTable; + + /** Renders directly into a borrowed host Metal texture, when supported. */ + EshiResult (*render_texture)(EshiBackend* backend, + void* metal_texture, + void* pixel_buffer, + int32_t width, int32_t height, + float time, + const void* uniforms, size_t uniform_size); + + /* + * 3D scene operations, and the first place the vtable stops being uniform. + * + * A backend that has no scene leaves these NULL and the world answers + * ESHI_ERR_UNSUPPORTED — which is the honest answer for Ink, Paper, and + * direct Metal, all of which render a fullscreen material and nothing + * else. Making them optional keeps the capability ladder in the vtable + * rather than in a grade comparison somewhere else. + */ + + /** Parses and uploads a glTF/GLB. Writes a nonzero handle on success. */ + EshiResult (*asset_load)(EshiBackend* backend, const char* path, + uint32_t* out_asset); + + /** Adds one instance of a loaded asset to the backend's scene. */ + EshiResult (*asset_instance)(EshiBackend* backend, uint32_t asset, + float x, float y, float z, float scale, + float radians_per_second); + + /** Releases an asset and its instances. */ + EshiResult (*asset_release)(EshiBackend* backend, uint32_t asset); + + /** Assets currently loaded, for hosts and tests that ask. */ + uint32_t (*asset_count)(const EshiBackend* backend); +}; + +/** + * Borrows the world's selected backend. Host adapters use this only to route + * an opaque platform render target to the matching private backend entry + * point; neither value is part of the installed C ABI. + */ +EshiBackend* eshi__world_backend(EshiWorld* w, + const EshiBackendVTable** out_vtable); /* Implemented per backend; the unavailable ones compile to stubs. */ const EshiBackendVTable* eshi__backend_ink(void); @@ -84,6 +126,21 @@ const EshiBackendVTable* eshi__backend_gl(void); const EshiBackendVTable* eshi__backend_metal(void); const EshiBackendVTable* eshi__backend_filament(void); +/** + * Renders into a borrowed macOS surface. Direct Metal uses `metal_texture`; + * Filament uses `pixel_buffer` as an Apple CVPixelBuffer swapchain. + * + * This symbol is intentionally absent from eshi.h: it is consumed only by the + * macOS Flutter host. The caller retains the texture until the synchronous + * submission completes. + */ +EshiResult eshi__metal_render_texture(EshiWorld* w, + void* metal_texture, + void* pixel_buffer, + int32_t width, + int32_t height, + float time); + /** Maps a grade to its table, or NULL if the grade has no backend here. */ const EshiBackendVTable* eshi__backend_for_grade(EshiGrade grade); diff --git a/core/src/render/filament.cpp b/core/src/render/filament.cpp index b9e1168..847845c 100644 --- a/core/src/render/filament.cpp +++ b/core/src/render/filament.cpp @@ -2,28 +2,38 @@ * @file filament.cpp * @brief Kantei Grade 3 (Brush) — Google Filament backend. * - * Filament renders a device-domain fullscreen material into an offscreen - * texture, then reads RGBA8 back into the same host-owned framebuffer contract - * used by Ink, OpenGL, and direct Metal. This readback is intentionally a First - * Light bridge: later Flutter hosts can expose the texture directly and remove - * the copy without changing the game or ECS APIs. + * Headless hosts render into an offscreen texture and read RGBA8 back into the + * framebuffer contract shared with Ink, OpenGL, and direct Metal. EshiView + * takes the zero-readback path instead: its CVPixelBuffer is a native Filament + * swapchain, and Flutter composites that same IOSurface. */ #include #include #include #include #include +#include #include #include #include #include #include #include +#include #include +#include #include #include #include +#include +#include +#include +#include +#include +#include +#include #include +#include #include #include @@ -43,6 +53,30 @@ static_assert(sizeof(EshiEntity) == sizeof(utils::Entity), static_assert(ESHI_ENTITY_INDEX_BITS == utils::FILAMENT_GENERATION_SHIFT, "Eshi and Filament entity handles must use the same bit layout"); +/* + * Instances an asset may carry. gltfio decides an asset's instance count when + * it parses, so this is reserved up front and spent by eshi_asset_instance; + * asking for a ninth copy is a limit, reported as one. + */ +const size_t kMaxAssetInstances = 8; + +struct AssetPlacement { + float x; + float y; + float z; + float scale; + float radians_per_second; +}; + +/** One loaded glTF, its reserved instances, and how many are in the scene. */ +struct AssetSlot { + uint32_t id; + filament::gltfio::FilamentAsset* asset; + std::vector instances; + std::vector placements; + size_t used; +}; + struct FilamentBackend { int32_t width; int32_t height; @@ -54,6 +88,10 @@ struct FilamentBackend { filament::Camera* camera; filament::Texture* color; filament::RenderTarget* target; + filament::SwapChain* external_swapchain; + void* external_handle; + int32_t external_width; + int32_t external_height; filament::VertexBuffer* vertices; filament::IndexBuffer* indices; filament::Material* material; @@ -63,6 +101,21 @@ struct FilamentBackend { utils::Entity renderable; std::vector package; std::vector readback; + + /* + * The 3D half. It stays null until a host loads an asset, so a world that + * only draws a fullscreen material builds exactly the scene it built + * before — which matters, because the cross-tier conformance gate compares + * that scene against the CPU tier to within one LSB. + */ + filament::gltfio::MaterialProvider* materials; + filament::gltfio::AssetLoader* asset_loader; + filament::gltfio::ResourceLoader* resource_loader; + filament::gltfio::TextureProvider* stb_textures; + filament::gltfio::TextureProvider* ktx2_textures; + utils::Entity sunlight; + std::vector assets; + uint32_t next_asset_id; }; bool read_file(const char* path, std::vector* out) { @@ -105,6 +158,28 @@ void filament_destroy(EshiBackend* handle) { filament::Engine* engine = backend->engine; if (engine) { + /* Assets first: they hold entities the scene and the engine both know. */ + for (size_t i = 0; i < backend->assets.size(); ++i) { + if (backend->assets[i].asset) { + backend->scene->removeEntities(backend->assets[i].asset->getEntities(), + backend->assets[i].asset->getEntityCount()); + backend->asset_loader->destroyAsset(backend->assets[i].asset); + } + } + backend->assets.clear(); + if (backend->materials) backend->materials->destroyMaterials(); + delete backend->resource_loader; + delete backend->stb_textures; + delete backend->ktx2_textures; + if (backend->asset_loader) { + filament::gltfio::AssetLoader::destroy(&backend->asset_loader); + } + delete backend->materials; + if (backend->sunlight) { + backend->scene->remove(backend->sunlight); + engine->destroy(backend->sunlight); + utils::EntityManager::get().destroy(backend->sunlight); + } if (backend->scene && backend->renderable) { backend->scene->remove(backend->renderable); } @@ -122,6 +197,7 @@ void filament_destroy(EshiBackend* handle) { engine->destroy(backend->indices); engine->destroy(backend->target); engine->destroy(backend->color); + engine->destroy(backend->external_swapchain); engine->destroy(backend->view); engine->destroy(backend->scene); engine->destroy(backend->renderer); @@ -132,12 +208,12 @@ void filament_destroy(EshiBackend* handle) { EshiBackend* filament_create(int32_t width, int32_t height, const char* /*source_path*/, const char* package_path) { - if (!package_path) { - std::fprintf(stderr, "[eshi/filament] material has no package_path; compile its .mat " - "file with matc\n"); - return NULL; - } - + /* + * A missing package is no longer a failure: a world may hold 3D geometry + * and no fullscreen material at all. What is still a failure is a package + * that was named and could not be read, which is a build problem wearing a + * runtime disguise. + */ FilamentBackend* backend = new FilamentBackend(); backend->width = width; backend->height = height; @@ -148,14 +224,25 @@ EshiBackend* filament_create(int32_t width, int32_t height, const char* /*source backend->camera = NULL; backend->color = NULL; backend->target = NULL; + backend->external_swapchain = NULL; + backend->external_handle = NULL; + backend->external_width = 0; + backend->external_height = 0; backend->vertices = NULL; backend->indices = NULL; backend->material = NULL; backend->material_instance = NULL; backend->camera_entity = {}; backend->renderable = {}; - - if (!read_file(package_path, &backend->package)) { + backend->materials = NULL; + backend->asset_loader = NULL; + backend->resource_loader = NULL; + backend->stb_textures = NULL; + backend->ktx2_textures = NULL; + backend->sunlight = {}; + backend->next_asset_id = 1; + + if (package_path && !read_file(package_path, &backend->package)) { std::fprintf(stderr, "[eshi/filament] cannot read material package: %s\n", package_path); delete backend; return NULL; @@ -171,6 +258,17 @@ EshiBackend* filament_create(int32_t width, int32_t height, const char* /*source filament::Engine& engine = *backend->engine; backend->renderer = engine.createRenderer(); + /* + * Without this the render target keeps whatever was in it — which for a + * 3D scene is a magenta field and staircase blocks of stale tile memory + * around the geometry, because a fullscreen quad used to cover every + * pixel and hide the question. An explicit opaque clear makes the + * background deterministic, which the headless digests depend on. + */ + filament::Renderer::ClearOptions clear = {}; + clear.clearColor = {0.02f, 0.027f, 0.051f, 1.0f}; + clear.clear = true; + backend->renderer->setClearOptions(clear); backend->scene = engine.createScene(); backend->view = engine.createView(); @@ -197,64 +295,71 @@ EshiBackend* filament_create(int32_t width, int32_t height, const char* /*source .build(engine); backend->view->setRenderTarget(backend->target); - static const float kVertices[] = { - -1.0f, -1.0f, 1.0f, -1.0f, 1.0f, 1.0f, -1.0f, 1.0f, - }; - static const uint16_t kIndices[] = {0, 1, 2, 0, 2, 3}; - - backend->vertices = filament::VertexBuffer::Builder() - .vertexCount(4) - .bufferCount(1) - .attribute(filament::VertexAttribute::POSITION, 0, - filament::VertexBuffer::AttributeType::FLOAT2, 0, - 2 * sizeof(float)) - .build(engine); - backend->vertices->setBufferAt( - engine, 0, - filament::VertexBuffer::BufferDescriptor(kVertices, sizeof(kVertices), NULL)); - - backend->indices = filament::IndexBuffer::Builder() - .indexCount(6) - .bufferType(filament::IndexBuffer::IndexType::USHORT) - .build(engine); - backend->indices->setBuffer( - engine, filament::IndexBuffer::BufferDescriptor(kIndices, sizeof(kIndices), NULL)); - - backend->material = filament::Material::Builder() - .package(backend->package.data(), backend->package.size()) - .build(engine); - if (!backend->material) { - std::fprintf(stderr, "[eshi/filament] material package is invalid or lacks this API\n"); - filament_destroy(reinterpret_cast(backend)); - return NULL; + /* + * The fullscreen material is optional now, so everything that serves it + * — the quad, the package, the renderable — is built only when a host + * asked for one. A 3D-only world skips straight to its assets. + */ + if (package_path) { + static const float kVertices[] = { + -1.0f, -1.0f, 1.0f, -1.0f, 1.0f, 1.0f, -1.0f, 1.0f, + }; + static const uint16_t kIndices[] = {0, 1, 2, 0, 2, 3}; + + backend->vertices = filament::VertexBuffer::Builder() + .vertexCount(4) + .bufferCount(1) + .attribute(filament::VertexAttribute::POSITION, 0, + filament::VertexBuffer::AttributeType::FLOAT2, 0, + 2 * sizeof(float)) + .build(engine); + backend->vertices->setBufferAt( + engine, 0, + filament::VertexBuffer::BufferDescriptor(kVertices, sizeof(kVertices), NULL)); + + backend->indices = filament::IndexBuffer::Builder() + .indexCount(6) + .bufferType(filament::IndexBuffer::IndexType::USHORT) + .build(engine); + backend->indices->setBuffer( + engine, filament::IndexBuffer::BufferDescriptor(kIndices, sizeof(kIndices), NULL)); + + backend->material = filament::Material::Builder() + .package(backend->package.data(), backend->package.size()) + .build(engine); + if (!backend->material) { + std::fprintf(stderr, "[eshi/filament] material package is invalid or lacks this API\n"); + filament_destroy(reinterpret_cast(backend)); + return NULL; + } + backend->material_instance = backend->material->createInstance(); + backend->material_instance->setParameter( + "eshiResolution", filament::math::float2{(float)width, (float)height}); + + backend->renderable = utils::EntityManager::get().create(); + const filament::RenderableManager::Builder::Result result = + filament::RenderableManager::Builder(1) + .boundingBox({{-1.0f, -1.0f, -1.0f}, {1.0f, 1.0f, 1.0f}}) + .material(0, backend->material_instance) + .geometry(0, filament::RenderableManager::PrimitiveType::TRIANGLES, + backend->vertices, backend->indices, 0, 6) + .culling(false) + .receiveShadows(false) + .castShadows(false) + .build(engine, backend->renderable); + if (result != filament::RenderableManager::Builder::Success) { + std::fprintf(stderr, "[eshi/filament] could not build fullscreen renderable\n"); + utils::EntityManager::get().destroy(backend->renderable); + backend->renderable = {}; + filament_destroy(reinterpret_cast(backend)); + return NULL; + } + backend->scene->addEntity(backend->renderable); } - backend->material_instance = backend->material->createInstance(); - backend->material_instance->setParameter( - "eshiResolution", filament::math::float2{(float)width, (float)height}); - - backend->renderable = utils::EntityManager::get().create(); - const filament::RenderableManager::Builder::Result result = - filament::RenderableManager::Builder(1) - .boundingBox({{-1.0f, -1.0f, -1.0f}, {1.0f, 1.0f, 1.0f}}) - .material(0, backend->material_instance) - .geometry(0, filament::RenderableManager::PrimitiveType::TRIANGLES, - backend->vertices, backend->indices, 0, 6) - .culling(false) - .receiveShadows(false) - .castShadows(false) - .build(engine, backend->renderable); - if (result != filament::RenderableManager::Builder::Success) { - std::fprintf(stderr, "[eshi/filament] could not build fullscreen renderable\n"); - utils::EntityManager::get().destroy(backend->renderable); - backend->renderable = {}; - filament_destroy(reinterpret_cast(backend)); - return NULL; - } - backend->scene->addEntity(backend->renderable); backend->readback.resize((size_t)width * (size_t)height * 4); std::printf("[eshi/filament] backend=%d package=%s\n", (int)engine.getBackend(), - package_path); + package_path ? package_path : "(none, 3D scene only)"); return reinterpret_cast(backend); } catch (const std::exception& error) { std::fprintf(stderr, "[eshi/filament] initialization failed: %s\n", error.what()); @@ -265,6 +370,191 @@ EshiBackend* filament_create(int32_t width, int32_t height, const char* /*source return NULL; } +/* + * Builds the glTF loaders and the default lighting, once, on the first asset. + * + * These are the scene conventions the rest of Step 5 hangs off, so they are + * stated here rather than left to whatever a caller passes: glTF's own + * right-handed, Y-up metres; a 45° vertical field of view; a photographic + * exposure so physical light units mean something; and a single directional + * light at daylight intensity. Image-based lighting is not here yet, which is + * why a fully metallic material still renders dark — that is the next bullet, + * not an accident. + */ +EshiResult ensure_scene(FilamentBackend* backend) { + if (backend->asset_loader) return ESHI_OK; + + filament::Engine& engine = *backend->engine; + backend->materials = filament::gltfio::createUbershaderProvider( + &engine, UBERARCHIVE_DEFAULT_DATA, UBERARCHIVE_DEFAULT_SIZE); + if (!backend->materials) { + std::fprintf(stderr, "[eshi/filament] could not create the ubershader provider\n"); + return ESHI_ERR_NOMEM; + } + + filament::gltfio::AssetConfiguration config = {}; + config.engine = &engine; + config.materials = backend->materials; + backend->asset_loader = filament::gltfio::AssetLoader::create(config); + if (!backend->asset_loader) { + std::fprintf(stderr, "[eshi/filament] could not create the glTF asset loader\n"); + return ESHI_ERR_NOMEM; + } + + filament::gltfio::ResourceConfiguration resources = {}; + resources.engine = &engine; + resources.normalizeSkinningWeights = true; + backend->resource_loader = new filament::gltfio::ResourceLoader(resources); + + /* + * Without these a textured glTF still loads, still instantiates, and + * renders black — gltfio drops every image it has no decoder for and says + * so once on stderr, which is easy to miss and impossible to see in a + * digest. The greybox asset has no textures, so only a real model exposed + * it; registering the providers is what makes "loaded" mean "loaded". + */ + backend->stb_textures = filament::gltfio::createStbProvider(&engine); + backend->ktx2_textures = filament::gltfio::createKtx2Provider(&engine); + backend->resource_loader->addTextureProvider("image/png", backend->stb_textures); + backend->resource_loader->addTextureProvider("image/jpeg", backend->stb_textures); + backend->resource_loader->addTextureProvider("image/ktx2", backend->ktx2_textures); + + backend->camera->setProjection(45.0, (double)backend->width / (double)backend->height, + 0.1, 100.0, filament::Camera::Fov::VERTICAL); + backend->camera->lookAt({0.0f, 0.35f, 2.4f}, {0.0f, 0.0f, 0.0f}, {0.0f, 1.0f, 0.0f}); + backend->camera->setExposure(16.0f, 1.0f / 125.0f, 100.0f); + + backend->sunlight = utils::EntityManager::get().create(); + filament::LightManager::Builder(filament::LightManager::Type::DIRECTIONAL) + .color({1.0f, 0.98f, 0.95f}) + .intensity(90000.0f) + .direction({-0.4f, -0.8f, -0.45f}) + .castShadows(false) + .build(engine, backend->sunlight); + backend->scene->addEntity(backend->sunlight); + return ESHI_OK; +} + +AssetSlot* find_asset(FilamentBackend* backend, uint32_t id) { + for (size_t i = 0; i < backend->assets.size(); ++i) { + if (backend->assets[i].id == id) return &backend->assets[i]; + } + return NULL; +} + +EshiResult filament_asset_load(EshiBackend* handle, const char* path, uint32_t* out_asset) { + FilamentBackend* backend = reinterpret_cast(handle); + if (!backend || !path || !out_asset) return ESHI_ERR_INVALID; + + std::vector bytes; + if (!read_file(path, &bytes)) { + std::fprintf(stderr, "[eshi/filament] cannot read asset: %s\n", path); + return ESHI_ERR_INVALID; + } + + const EshiResult ready = ensure_scene(backend); + if (ready != ESHI_OK) return ready; + + AssetSlot slot; + slot.id = 0; + slot.asset = NULL; + slot.used = 0; + /* + * Instanced from the start. gltfio uploads the geometry and materials once + * and hands back handles that share them, which is the whole reason a + * second view or a second copy of the logo costs a transform instead of a + * second megabyte. + */ + slot.instances.assign(kMaxAssetInstances, NULL); + slot.placements.assign(kMaxAssetInstances, AssetPlacement{}); + slot.asset = backend->asset_loader->createInstancedAsset( + bytes.data(), (uint32_t)bytes.size(), slot.instances.data(), kMaxAssetInstances); + if (!slot.asset) { + std::fprintf(stderr, "[eshi/filament] not a glTF this loader accepts: %s\n", path); + return ESHI_ERR_INVALID; + } + if (!backend->resource_loader->loadResources(slot.asset)) { + std::fprintf(stderr, "[eshi/filament] asset resources failed to load: %s\n", path); + backend->asset_loader->destroyAsset(slot.asset); + return ESHI_ERR_INVALID; + } + /* The parsed glTF is no longer needed once buffers and textures are up. */ + slot.asset->releaseSourceData(); + + slot.id = backend->next_asset_id++; + backend->assets.push_back(slot); + *out_asset = slot.id; + return ESHI_OK; +} + +EshiResult filament_asset_instance(EshiBackend* handle, uint32_t asset, + float x, float y, float z, float scale, + float radians_per_second) { + FilamentBackend* backend = reinterpret_cast(handle); + if (!backend) return ESHI_ERR_INVALID; + AssetSlot* slot = find_asset(backend, asset); + if (!slot) return ESHI_ERR_INVALID; + if (slot->used >= slot->instances.size()) return ESHI_ERR_LIMIT; + + filament::gltfio::FilamentInstance* instance = slot->instances[slot->used]; + if (!instance) return ESHI_ERR_INVALID; + const size_t placement_index = slot->used; + slot->used += 1; + slot->placements[placement_index] = {x, y, z, scale, radians_per_second}; + + filament::TransformManager& transforms = backend->engine->getTransformManager(); + const filament::TransformManager::Instance root = + transforms.getInstance(instance->getRoot()); + transforms.setTransform(root, + filament::math::mat4f::translation( + filament::math::float3{x, y, z}) * + filament::math::mat4f::scaling(scale)); + + backend->scene->addEntities(instance->getEntities(), instance->getEntityCount()); + backend->scene->addEntity(instance->getRoot()); + return ESHI_OK; +} + +void animate_asset_instances(FilamentBackend* backend, float time) { + filament::TransformManager& transforms = backend->engine->getTransformManager(); + for (AssetSlot& slot : backend->assets) { + for (size_t i = 0; i < slot.used; ++i) { + filament::gltfio::FilamentInstance* instance = slot.instances[i]; + if (!instance) continue; + const AssetPlacement& placement = slot.placements[i]; + const filament::TransformManager::Instance root = + transforms.getInstance(instance->getRoot()); + transforms.setTransform( + root, + filament::math::mat4f::translation( + filament::math::float3{placement.x, placement.y, placement.z}) * + filament::math::mat4f::rotation( + time * placement.radians_per_second, + filament::math::float3{0.0f, 1.0f, 0.0f}) * + filament::math::mat4f::scaling(placement.scale)); + } + } +} + +EshiResult filament_asset_release(EshiBackend* handle, uint32_t asset) { + FilamentBackend* backend = reinterpret_cast(handle); + if (!backend) return ESHI_ERR_INVALID; + for (size_t i = 0; i < backend->assets.size(); ++i) { + if (backend->assets[i].id != asset) continue; + AssetSlot& slot = backend->assets[i]; + backend->scene->removeEntities(slot.asset->getEntities(), slot.asset->getEntityCount()); + backend->asset_loader->destroyAsset(slot.asset); + backend->assets.erase(backend->assets.begin() + (long)i); + return ESHI_OK; + } + return ESHI_ERR_INVALID; +} + +uint32_t filament_asset_count(const EshiBackend* handle) { + const FilamentBackend* backend = reinterpret_cast(handle); + return backend ? (uint32_t)backend->assets.size() : 0u; +} + EshiResult filament_render(EshiBackend* handle, uint8_t* pixels, int32_t stride, float time, EshiShaderFn /*cpu_shader*/, const void* uniforms, size_t uniform_size) { FilamentBackend* backend = reinterpret_cast(handle); @@ -279,12 +569,20 @@ EshiResult filament_render(EshiBackend* handle, uint8_t* pixels, int32_t stride, if (uniform_count > 0 && !uniforms) return ESHI_ERR_INVALID; - backend->material_instance->setParameter("eshiTime", time); - if (uniform_count > 0) { - backend->material_instance->setParameter( - "eshiUniforms", static_cast(uniforms), uniform_count); + /* + * A 3D-only world has no fullscreen material to feed. Its geometry is + * already in the scene, so the frame is a plain render of it. + */ + if (backend->material_instance) { + backend->material_instance->setParameter("eshiTime", time); + if (uniform_count > 0) { + backend->material_instance->setParameter( + "eshiUniforms", static_cast(uniforms), uniform_count); + } } + animate_asset_instances(backend, time); + backend->renderer->renderStandaloneView(backend->view); filament::backend::PixelBufferDescriptor readback( backend->readback.data(), backend->readback.size(), @@ -306,8 +604,101 @@ EshiResult filament_render(EshiBackend* handle, uint8_t* pixels, int32_t stride, return ESHI_OK; } +EshiResult filament_render_texture(EshiBackend* handle, + void* metal_texture, + void* pixel_buffer, + int32_t width, int32_t height, + float time, + const void* uniforms, size_t uniform_size) { +#if !defined(__APPLE__) + (void)handle; + (void)metal_texture; + (void)pixel_buffer; + (void)width; + (void)height; + (void)time; + (void)uniforms; + (void)uniform_size; + return ESHI_ERR_UNSUPPORTED; +#else + FilamentBackend* backend = reinterpret_cast(handle); + (void)metal_texture; + if (!backend || !pixel_buffer || width <= 0 || height <= 0) { + return ESHI_ERR_INVALID; + } + if ((uniform_size % sizeof(float)) != 0 || + uniform_size / sizeof(float) > kUniformFloatCapacity || + (uniform_size > 0 && !uniforms)) { + return ESHI_ERR_INVALID; + } + + filament::Engine& engine = *backend->engine; + if (backend->external_handle != pixel_buffer || + backend->external_width != width || backend->external_height != height) { + backend->view->setRenderTarget(backend->target); + engine.destroy(backend->external_swapchain); + backend->external_swapchain = engine.createSwapChain( + pixel_buffer, filament::SwapChain::CONFIG_APPLE_CVPIXELBUFFER); + if (!backend->external_swapchain) return ESHI_ERR_INVALID; + backend->external_handle = pixel_buffer; + backend->external_width = width; + backend->external_height = height; + } + + if (backend->material_instance) { + backend->material_instance->setParameter("eshiTime", time); + const size_t uniform_count = uniform_size / sizeof(float); + if (uniform_count > 0) { + backend->material_instance->setParameter( + "eshiUniforms", static_cast(uniforms), uniform_count); + } + } + animate_asset_instances(backend, time); + + filament::Renderer::ClearOptions transparent = {}; + transparent.clearColor = {0.0, 0.0, 0.0, 0.0}; + transparent.clear = true; + backend->renderer->setClearOptions(transparent); + backend->view->setBlendMode(filament::View::BlendMode::TRANSLUCENT); + backend->view->setRenderTarget(NULL); + backend->view->setViewport({0, 0, (uint32_t)width, (uint32_t)height}); + backend->camera->setProjection(45.0, (double)width / (double)height, + 0.1, 100.0, filament::Camera::Fov::VERTICAL); + const bool frame_started = + backend->renderer->beginFrame(backend->external_swapchain); + if (frame_started) { + backend->renderer->render(backend->view); + backend->renderer->endFrame(); + engine.flushAndWait(); + } + + /* Leave the headless framebuffer contract exactly as it was. */ + filament::Renderer::ClearOptions opaque = {}; + opaque.clearColor = {0.02, 0.027, 0.051, 1.0}; + opaque.clear = true; + backend->renderer->setClearOptions(opaque); + backend->view->setBlendMode(filament::View::BlendMode::OPAQUE); + backend->view->setRenderTarget(backend->target); + backend->view->setViewport( + {0, 0, (uint32_t)backend->width, (uint32_t)backend->height}); + backend->camera->setProjection( + 45.0, (double)backend->width / (double)backend->height, + 0.1, 100.0, filament::Camera::Fov::VERTICAL); + return frame_started ? ESHI_OK : ESHI_ERR_INVALID; +#endif +} + const EshiBackendVTable kFilamentVTable = { - "filament", filament_available, filament_create, filament_destroy, filament_render, + "filament", + filament_available, + filament_create, + filament_destroy, + filament_render, + filament_render_texture, + filament_asset_load, + filament_asset_instance, + filament_asset_release, + filament_asset_count, }; } /* namespace */ diff --git a/core/src/render/gl.cpp b/core/src/render/gl.cpp index f8a42ba..af7897d 100644 --- a/core/src/render/gl.cpp +++ b/core/src/render/gl.cpp @@ -357,6 +357,12 @@ const EshiBackendVTable kGlVTable = { gl_create, gl_destroy, gl_render, + NULL, + /* No 3D scene: assets are refused rather than half-supported. */ + NULL, + NULL, + NULL, + NULL, }; } /* namespace */ diff --git a/core/src/render/ink.cpp b/core/src/render/ink.cpp index 7261b3d..802c070 100644 --- a/core/src/render/ink.cpp +++ b/core/src/render/ink.cpp @@ -89,6 +89,12 @@ const EshiBackendVTable kInkVTable = { ink_create, ink_destroy, ink_render, + NULL, + /* No 3D scene: assets are refused rather than half-supported. */ + NULL, + NULL, + NULL, + NULL, }; } /* namespace */ diff --git a/core/src/render/metal.mm b/core/src/render/metal.mm index 929eeef..103c4d6 100644 --- a/core/src/render/metal.mm +++ b/core/src/render/metal.mm @@ -216,14 +216,83 @@ EshiResult metal_render(EshiBackend* handle, return ESHI_OK; } +EshiResult metal_render_texture(EshiBackend* handle, + void* metal_texture, + void* pixel_buffer, + int32_t width, int32_t height, + float time, + const void* uniforms, size_t uniform_size); + const EshiBackendVTable kMetalVTable = { "metal", metal_available, metal_create, metal_destroy, metal_render, + metal_render_texture, + /* No 3D scene: assets are refused rather than half-supported. */ + NULL, + NULL, + NULL, + NULL, }; } /* namespace */ extern "C" const EshiBackendVTable* eshi__backend_metal(void) { return &kMetalVTable; } + +namespace { + +EshiResult metal_render_texture(EshiBackend* handle, + void* metal_texture, + void* /*pixel_buffer*/, + int32_t width, int32_t height, + float time, + const void* uniforms, size_t uniform_size) { + MetalBackend* backend = reinterpret_cast(handle); + if (!backend || !metal_texture || width <= 0 || height <= 0) { + return ESHI_ERR_INVALID; + } + id output_texture = (__bridge id)metal_texture; + id device = (__bridge id)backend->device; + if (!output_texture || output_texture.device != device || + output_texture.width != (NSUInteger)width || + output_texture.height != (NSUInteger)height || + !(output_texture.usage & MTLTextureUsageShaderWrite)) { + return ESHI_ERR_INVALID; + } + if (uniform_size > backend->uniform_capacity) return ESHI_ERR_LIMIT; + + id queue = (__bridge id)backend->queue; + id pipeline = + (__bridge id)backend->pipeline; + id input_texture = (__bridge id)backend->input_texture; + + @autoreleasepool { + id command_buffer = [queue commandBuffer]; + id encoder = [command_buffer computeCommandEncoder]; + if (!command_buffer || !encoder) return ESHI_ERR_INVALID; + + [encoder setComputePipelineState:pipeline]; + [encoder setBytes:&time length:sizeof(float) atIndex:0]; + if (uniforms && uniform_size > 0) { + [encoder setBytes:uniforms length:uniform_size atIndex:1]; + } + [encoder setTexture:output_texture atIndex:0]; + [encoder setTexture:input_texture atIndex:1]; + [encoder dispatchThreads:MTLSizeMake(output_texture.width, + output_texture.height, 1) + threadsPerThreadgroup:MTLSizeMake(16, 16, 1)]; + [encoder endEncoding]; + [command_buffer commit]; + [command_buffer waitUntilCompleted]; + if (command_buffer.status == MTLCommandBufferStatusError) { + std::fprintf(stderr, "[eshi/metal] external texture render failed: %s\n", + [[command_buffer.error localizedDescription] UTF8String]); + return ESHI_ERR_INVALID; + } + } + return ESHI_OK; +} + +} /* namespace */ diff --git a/core/src/render/registry.cpp b/core/src/render/registry.cpp index 22ff06d..8fa76c5 100644 --- a/core/src/render/registry.cpp +++ b/core/src/render/registry.cpp @@ -26,7 +26,17 @@ EshiResult stub_render(EshiBackend*, uint8_t*, int32_t, float, } const EshiBackendVTable kStubVTable = { - "unavailable", stub_available, stub_create, stub_destroy, stub_render, + "unavailable", + stub_available, + stub_create, + stub_destroy, + stub_render, + NULL, + /* No 3D scene: assets are refused rather than half-supported. */ + NULL, + NULL, + NULL, + NULL, }; } /* namespace */ diff --git a/core/src/render/transpile.cpp b/core/src/render/transpile.cpp index 0271711..232a1be 100644 --- a/core/src/render/transpile.cpp +++ b/core/src/render/transpile.cpp @@ -9,6 +9,7 @@ #include "transpile.h" #include +#include #include #include #include @@ -39,6 +40,25 @@ std::string resolve_path(const std::string& path) { const std::string stripped = path.substr(3); if (file_exists(stripped)) return stripped; } + + /* + * Everything above resolves against the working directory, which is fine + * inside a source tree and wrong everywhere else: an installed binary run + * from anywhere but the repository root would fail to find its shader, and + * the GPU tiers would render black frames while the banner still announced + * their backend. The install prefix and an environment override are how a + * shipped binary finds the sources that ship beside it. + */ + const size_t slash = path.find_last_of("/\\"); + const std::string leaf = (slash == std::string::npos) ? path : path.substr(slash + 1); + if (const char* directory = std::getenv("ESHI_SHADER_DIR")) { + const std::string candidate = std::string(directory) + "/" + leaf; + if (file_exists(candidate)) return candidate; + } +#ifdef ESHI_SHADER_INSTALL_DIR + const std::string installed = std::string(ESHI_SHADER_INSTALL_DIR) + "/" + leaf; + if (file_exists(installed)) return installed; +#endif return ""; } @@ -196,6 +216,31 @@ std::string glsl_rules(std::string line) { return line; } +/** + * Filament binds a material's parameters through a generated `materialParams` + * block, so the subset's global array name has to be rewritten to reach it. + * Everything else a `.mat` fragment block needs is already GLSL. + */ +std::string mat_rules(std::string line) { + line = replace_all(line, "eshi_uniforms", "materialParams.eshiUniforms"); + + /* + * Filament's shader prelude defines PI and HALF_PI before the material's + * fragment block, and two shaders in the corpus own those names: lunar + * `#define`s PI and seascape declares `const float PI`. The first is a macro + * redefinition, the second expands to `const float 3.14159 = ...` and + * reports a syntax error a line later, which is a genuinely baffling + * diagnostic to receive about a file that compiles everywhere else. + * + * Renaming the shader's use rather than undefining Filament's is the safe + * direction: an #undef would also reach the engine code emitted after this + * block. Same treatment the GLSL target already gives the reserved noise + * builtins, for the same reason. + */ + static const std::regex prelude_names("\\b(PI|HALF_PI)\\b"); + return std::regex_replace(line, prelude_names, "eshi_$1"); +} + std::string msl_rules(std::string line, int uniform_floats) { /* MSL requires an explicit address space on every reference parameter. */ line = replace_all(line, "float&", "thread float&"); @@ -307,7 +352,13 @@ std::string read_and_convert(const std::string& resolved, Target target, int uni continue; } line = common_rules(line); - line = (target == kTargetGlsl) ? glsl_rules(line) : msl_rules(line, uniform_floats); + if (target == kTargetMsl) { + line = msl_rules(line, uniform_floats); + } else { + /* GLSL and .mat share a body dialect; only the wrapper differs. */ + line = glsl_rules(line); + if (target == kTargetMat) line = mat_rules(line); + } body += line; body += "\n"; } @@ -319,6 +370,22 @@ std::string read_and_convert(const std::string& resolved, Target target, int uni return body; } +/** `examples/pong/pong.gpu.cpp` -> `eshi_pong`. Metadata only; keep it stable. */ +std::string material_name(const std::string& resolved) { + size_t start = resolved.find_last_of("/\\"); + start = (start == std::string::npos) ? 0 : start + 1; + const size_t dot = resolved.find('.', start); + const std::string stem = resolved.substr(start, dot == std::string::npos ? dot : dot - start); + std::string name = "eshi_"; + for (size_t i = 0; i < stem.size(); ++i) { + const char c = stem[i]; + const bool allowed = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_'; + name += allowed ? c : '_'; + } + return name; +} + std::string uniform_binding(Target target, int uniform_floats) { if (uniform_floats <= 0) return ""; std::ostringstream out; @@ -345,7 +412,107 @@ bool build_program(const std::string& path, const std::string body = read_and_convert(resolved, target, uniform_floats); std::ostringstream out; - if (target == kTargetGlsl) { + if (target == kTargetMat) { + /* + * A material is not a shader with different syntax: Filament owns the + * vertex stage, the varyings, the uniform block, and the lighting, and + * a fragment block only fills in MaterialInputs. Anything the subset + * expresses that this domain has no place for must stop the build. + * iChannel0 is the first such thing — the material declares no sampler + * and the Filament backend binds no texture — and a silent drop would + * render a plausible, wrong picture. + */ + if (body.find("iChannel0") != std::string::npos) { + if (out_error) { + *out_error = path + + ": samples iChannel0, which the Filament material domain has no " + "binding for in RC0. Give the material a sampler parameter or keep " + "this shader on the Paper and Ink tiers."; + } + return false; + } + /* + * matc splits a .mat into blocks by counting braces, before any + * preprocessing. A shader whose braces only balance *after* the + * preprocessor runs — rainforest.cpp opens its march loop inside + * `#ifdef LOWQUALITY` and again inside the `#else` — therefore runs the + * fragment block past its own closing brace, and matc reports an + * unexpected character on a line that looks perfectly fine. Say what + * actually happened instead. + */ + if (count_char(body, '{') != count_char(body, '}')) { + if (out_error) { + *out_error = path + + ": braces balance only after preprocessing, which matc's block " + "parser does not run. Lift the #if out of the braces it opens, or " + "keep this shader on the Paper and Ink tiers."; + } + return false; + } + + const bool has_uniforms = uniform_floats > 0; + out << "// Generated from " << path << " by eshi-matgen. Do not edit:\n" + << "// the shader source is the single definition of this material.\n" + << "material {\n" + << " name : " << material_name(resolved) << ",\n" + << " parameters : [\n"; + if (has_uniforms) { + out << " {\n" + << " type : float[" << uniform_floats << "],\n" + << " name : eshiUniforms\n" + << " },\n"; + } + out << " {\n" + << " type : float2,\n" + << " name : eshiResolution\n" + << " },\n" + << " {\n" + << " type : float,\n" + << " name : eshiTime\n" + << " }\n" + << " ],\n" + << " variables : [\n" + << " eshiPosition\n" + << " ],\n" + << " vertexDomain : device,\n" + << " depthWrite : false,\n" + << " depthCulling : false,\n" + << " culling : none,\n" + << " shadingModel : unlit,\n" + << " variantFilter : [ skinning, shadowReceiver, vsm ]\n" + << "}\n\n" + /* + * The device domain gives the fragment stage clip-space position, + * which is the only thing the subset's fragCoord can be rebuilt + * from. Passing it through a declared variable rather than reading + * gl_FragCoord keeps the material portable across Filament's + * backends. + */ + << "vertex {\n" + << " void materialVertex(inout MaterialVertexInputs material) {\n" + << " material.eshiPosition.xy = getPosition().xy;\n" + << " }\n" + << "}\n\n" + << "fragment {\n" + << kMathPreamble + << kGlslMathPreamble + << body + << "\nvoid material(inout MaterialInputs material) {\n" + << " prepareMaterial(material);\n" + << " vec2 iResolution = materialParams.eshiResolution;\n" + << " float iTime = materialParams.eshiTime;\n" + /* + * Clip space is [-1, 1]; the subset's fragCoord is in pixels with + * the same origin the GL tier's gl_FragCoord uses, so the tiers see + * one coordinate system and stay comparable pixel for pixel. + */ + << " vec2 fragCoord = (variable_eshiPosition.xy * 0.5 + 0.5) * iResolution;\n" + << " vec4 fragColor = vec4(0.0);\n" + << " mainImage(fragColor, fragCoord, iResolution, iTime);\n" + << " material.baseColor = vec4(fragColor.rgb, 1.0);\n" + << "}\n" + << "}\n"; + } else if (target == kTargetGlsl) { out << "#version 330 core\n" << "out vec4 FragColor;\n" << "uniform vec2 iResolution;\n" diff --git a/core/src/render/transpile.h b/core/src/render/transpile.h index 2d0b3c5..8c0d321 100644 --- a/core/src/render/transpile.h +++ b/core/src/render/transpile.h @@ -23,7 +23,18 @@ namespace transpile { enum Target { kTargetGlsl, /**< GLSL 3.30 core, fragment stage. */ - kTargetMsl /**< Metal Shading Language, compute kernel. */ + kTargetMsl, /**< Metal Shading Language, compute kernel. */ + /** + * A Filament material definition for `matc`. + * + * The body rules are GLSL's — a `.mat` fragment block is GLSL ES 3.0 — but + * the wrapper is not a shader. Filament owns the vertex stage, the + * varyings, and the uniform layout, so the emitted material declares its + * parameters, reconstructs `fragCoord` from the device-domain position, and + * assigns the result to `MaterialInputs::baseColor` under the unlit model. + * See docs/larimar/SHADER_SUBSET.md for what that domain cannot express. + */ + kTargetMat }; /** @@ -35,7 +46,11 @@ enum Target { * @param uniform_floats Size of the game's uniform block in floats. Zero omits * the `eshi_uniforms` binding entirely. * @param out_error Set to a human-readable reason when this returns false. - * @return false if the source could not be read. + * @return false if the source could not be read, or — for + * kTargetMat — if it uses something the material domain + * cannot express. Refusing is deliberate: a silently + * degraded material renders a wrong picture instead of + * failing a build. */ bool build_program(const std::string& path, Target target, diff --git a/core/src/world.cpp b/core/src/world.cpp index cd6ee21..36f0ddc 100644 --- a/core/src/world.cpp +++ b/core/src/world.cpp @@ -739,20 +739,123 @@ extern "C" EshiResult eshi_material_set(EshiWorld* w, const EshiMaterial* materi w->backend = NULL; } + /* + * A macOS consumer build can carry both Brush implementations. Runtime + * C++ shader source belongs to direct Metal, while packaged materials and + * 3D-only worlds belong to Filament. Selecting here keeps the public grade + * stable and lets the Pong reference and the glTF hero coexist in one app. + */ + const EshiBackendVTable* selected = eshi__backend_for_grade(w->cfg.grade); + if (w->cfg.grade == ESHI_GRADE_BRUSH && material->source_path && + !material->package_path) { + const EshiBackendVTable* metal = eshi__backend_metal(); + if (metal && metal->available && metal->available()) selected = metal; + } + EshiBackend* backend = NULL; try { - backend = w->backend_vtable->create(w->cfg.width, w->cfg.height, - material->source_path, material->package_path); + backend = selected->create(w->cfg.width, w->cfg.height, + material->source_path, material->package_path); } catch (...) { return ESHI_ERR_NOMEM; } if (!backend) return ESHI_ERR_UNSUPPORTED; w->backend = backend; + w->backend_vtable = selected; w->material = *material; return ESHI_OK; } +/* =========================================================================== + * Assets + * + * Every function here is a thin router: validate, hand the call to the + * backend's optional asset entry points, and translate an absent entry point + * into ESHI_ERR_UNSUPPORTED. Nothing about glTF, meshes or materials lives at + * this level, which is what keeps Filament out of the public header. + * ==========================================================================*/ +namespace { + +/* + * A 3D scene does not need a fullscreen material, but the backend that draws + * it is created by eshi_material_set today. Build one on demand so a host can + * load geometry into a fresh world without first binding a material it has no + * use for. + */ +EshiResult ensure_backend(EshiWorld* w) { + if (w->backend) return ESHI_OK; + EshiBackend* backend = NULL; + try { + backend = w->backend_vtable->create(w->cfg.width, w->cfg.height, NULL, NULL); + } catch (...) { + return ESHI_ERR_NOMEM; + } + if (!backend) return ESHI_ERR_UNSUPPORTED; + w->backend = backend; + return ESHI_OK; +} + +} /* namespace */ + +extern "C" EshiResult eshi_asset_load(EshiWorld* w, const char* path, EshiAsset* out_asset) { + if (!w || !path || !out_asset) return ESHI_ERR_INVALID; + if (!w->backend_vtable || !w->backend_vtable->asset_load) return ESHI_ERR_UNSUPPORTED; + + const EshiResult ready = ensure_backend(w); + if (ready != ESHI_OK) return ready; + + uint32_t asset = 0; + EshiResult rc; + try { + rc = w->backend_vtable->asset_load(w->backend, path, &asset); + } catch (const std::bad_alloc&) { + return ESHI_ERR_NOMEM; + } catch (...) { + return ESHI_ERR_INVALID; + } + if (rc != ESHI_OK) return rc; + /* A backend that reports success must produce a usable handle. */ + if (asset == 0) return ESHI_ERR_INVALID; + *out_asset = asset; + return ESHI_OK; +} + +extern "C" EshiResult eshi_asset_instance(EshiWorld* w, EshiAsset asset, + float x, float y, float z, float scale) { + return eshi_asset_instance_animated(w, asset, x, y, z, scale, 0.0f); +} + +extern "C" EshiResult eshi_asset_instance_animated(EshiWorld* w, EshiAsset asset, + float x, float y, float z, float scale, + float radians_per_second) { + if (!w || asset == 0) return ESHI_ERR_INVALID; + if (!w->backend || !w->backend_vtable->asset_instance) return ESHI_ERR_UNSUPPORTED; + try { + return w->backend_vtable->asset_instance( + w->backend, asset, x, y, z, scale, radians_per_second); + } catch (const std::bad_alloc&) { + return ESHI_ERR_NOMEM; + } catch (...) { + return ESHI_ERR_INVALID; + } +} + +extern "C" EshiResult eshi_asset_release(EshiWorld* w, EshiAsset asset) { + if (!w || asset == 0) return ESHI_ERR_INVALID; + if (!w->backend || !w->backend_vtable->asset_release) return ESHI_ERR_UNSUPPORTED; + try { + return w->backend_vtable->asset_release(w->backend, asset); + } catch (...) { + return ESHI_ERR_INVALID; + } +} + +extern "C" uint32_t eshi_asset_count(const EshiWorld* w) { + if (!w || !w->backend || !w->backend_vtable->asset_count) return 0; + return w->backend_vtable->asset_count(w->backend); +} + extern "C" EshiResult eshi_render(EshiWorld* w, uint8_t* pixels, int32_t stride, float time) { if (!w || !pixels || stride <= 0) return ESHI_ERR_INVALID; if (!w->backend) return ESHI_ERR_INVALID; @@ -768,6 +871,27 @@ extern "C" EshiResult eshi_render(EshiWorld* w, uint8_t* pixels, int32_t stride, } } +extern "C" EshiResult eshi__metal_render_texture(EshiWorld* w, + void* metal_texture, + void* pixel_buffer, + int32_t width, + int32_t height, + float time) { + if (!w || !metal_texture || width <= 0 || height <= 0) return ESHI_ERR_INVALID; + if (!w->backend || !w->backend_vtable || !w->backend_vtable->render_texture) { + return ESHI_ERR_UNSUPPORTED; + } + try { + return w->backend_vtable->render_texture( + w->backend, metal_texture, pixel_buffer, width, height, time, + w->material.uniform_data, w->material.uniform_size); + } catch (const std::bad_alloc&) { + return ESHI_ERR_NOMEM; + } catch (...) { + return ESHI_ERR_INVALID; + } +} + /* =========================================================================== * Frame * ==========================================================================*/ @@ -825,6 +949,12 @@ extern "C" void eshi__frame_params(EshiWorld* w, if (out_height) *out_height = w->cfg.height; } +extern "C" EshiBackend* eshi__world_backend( + EshiWorld* w, const EshiBackendVTable** out_vtable) { + if (out_vtable) *out_vtable = w ? w->backend_vtable : NULL; + return w ? w->backend : NULL; +} + /* =========================================================================== * RNG * ==========================================================================*/ diff --git a/core/tests/test_core.cpp b/core/tests/test_core.cpp index 8611ec6..8302abf 100644 --- a/core/tests/test_core.cpp +++ b/core/tests/test_core.cpp @@ -117,6 +117,42 @@ void test_material_and_render() { eshi_world_destroy(w); } +void test_assets_on_a_backend_without_a_scene() { + std::printf("assets on a backend without a scene\n"); + EshiWorld* w = make_world(); + + /* + * Ink has no 3D scene and says so. The distinction matters: a host that + * asks for geometry on a CPU tier must get a refusal it can act on, not a + * success followed by an empty frame. + */ + EshiAsset asset = 12345; + check(eshi_asset_load(w, "examples/assets/nothing.glb", &asset) == ESHI_ERR_UNSUPPORTED, + "Ink reports that it has no 3D scene"); + check(asset == 12345, "a refused load leaves the caller's handle alone"); + check(eshi_asset_count(w) == 0, "a backend without assets counts none"); + + check(eshi_asset_load(NULL, "a.glb", &asset) == ESHI_ERR_INVALID, + "loading into no world is refused"); + check(eshi_asset_load(w, NULL, &asset) == ESHI_ERR_INVALID, + "loading no path is refused"); + check(eshi_asset_load(w, "a.glb", NULL) == ESHI_ERR_INVALID, + "loading without somewhere to put the handle is refused"); + check(eshi_asset_instance(w, 0, 0.0f, 0.0f, 0.0f, 1.0f) == ESHI_ERR_INVALID, + "instancing the null asset is refused"); + check(eshi_asset_instance_animated(w, 1, 0.0f, 0.0f, 0.0f, 1.0f, 0.3f) == + ESHI_ERR_UNSUPPORTED, + "an animated instance reports that Ink has no scene"); + check(eshi_asset_instance_animated(NULL, 1, 0.0f, 0.0f, 0.0f, 1.0f, 0.3f) == + ESHI_ERR_INVALID, + "an animated instance rejects a null world"); + check(eshi_asset_release(w, 0) == ESHI_ERR_INVALID, + "releasing the null asset is refused"); + check(eshi_asset_count(NULL) == 0, "counting no world is zero, not a crash"); + + eshi_world_destroy(w); +} + void test_shader_transpile_builtin_overloads() { std::printf("shader builtin overloads\n"); @@ -980,6 +1016,7 @@ int main() { test_scene_epoch_gate(); test_scene_spans_flushes(); test_event_buffer(); + test_assets_on_a_backend_without_a_scene(); std::printf("\n%d checks, %d failures\n", g_checks, g_failures); return g_failures == 0 ? 0 : 1; diff --git a/core/tools/gemgen.cpp b/core/tools/gemgen.cpp new file mode 100644 index 0000000..9bfef73 --- /dev/null +++ b/core/tools/gemgen.cpp @@ -0,0 +1,222 @@ +/** + * @file gemgen.cpp + * @brief Emit the reference greybox asset as a GLB. + * + * PLAN Step 8 calls for a faceted gem modelled in Blender. This is its + * greybox: a procedurally generated icosahedron with flat-shaded facets and a + * PBR material, written straight out as a GLB. It is here rather than checked + * in as a binary for the reason every other generated artifact in this + * repository is generated — the input is reviewable, the output is + * reproducible, and nobody has to trust a blob. A Blender-authored replacement + * drops into the same path without touching a line of engine code. + * + * The file it writes is a plain glTF 2.0 binary container: a JSON chunk + * describing one mesh with one material, then a binary chunk holding + * interleaved positions and normals plus the index buffer. + * + * usage: eshi-gemgen [-o larimar_logo.glb] + */ +#include +#include +#include +#include +#include +#include + +namespace { + +struct Vec3 { + float x, y, z; +}; + +Vec3 subtract(const Vec3& a, const Vec3& b) { return Vec3{a.x - b.x, a.y - b.y, a.z - b.z}; } + +Vec3 cross(const Vec3& a, const Vec3& b) { + return Vec3{a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x}; +} + +Vec3 normalize(const Vec3& v) { + const float length = std::sqrt(v.x * v.x + v.y * v.y + v.z * v.z); + if (length <= 0.0f) return Vec3{0.0f, 1.0f, 0.0f}; + return Vec3{v.x / length, v.y / length, v.z / length}; +} + +/** + * An icosahedron, stretched into something gem-shaped. + * + * Flat facets are the point: every triangle gets its own three vertices and one + * face normal, so the silhouette reads as cut stone under a single light rather + * than as a smooth ball. That also makes the asset a fair test of the loader — + * 60 vertices, 20 primitives, no shared indices to hide a stride bug. + */ +void build_gem(std::vector* positions, std::vector* normals, + std::vector* indices) { + const float t = (1.0f + std::sqrt(5.0f)) * 0.5f; + Vec3 base[12] = { + {-1, t, 0}, {1, t, 0}, {-1, -t, 0}, {1, -t, 0}, + {0, -1, t}, {0, 1, t}, {0, -1, -t}, {0, 1, -t}, + {t, 0, -1}, {t, 0, 1}, {-t, 0, -1}, {-t, 0, 1}, + }; + static const int faces[20][3] = { + {0, 11, 5}, {0, 5, 1}, {0, 1, 7}, {0, 7, 10}, {0, 10, 11}, + {1, 5, 9}, {5, 11, 4}, {11, 10, 2}, {10, 7, 6}, {7, 1, 8}, + {3, 9, 4}, {3, 4, 2}, {3, 2, 6}, {3, 6, 8}, {3, 8, 9}, + {4, 9, 5}, {2, 4, 11}, {6, 2, 10}, {8, 6, 7}, {9, 8, 1}, + }; + + /* Unit radius, then taller than wide: a stone, not a die. */ + for (int i = 0; i < 12; ++i) { + base[i] = normalize(base[i]); + base[i].x *= 0.62f; + base[i].y *= 0.80f; + base[i].z *= 0.62f; + } + + for (int face = 0; face < 20; ++face) { + const Vec3 a = base[faces[face][0]]; + const Vec3 b = base[faces[face][1]]; + const Vec3 c = base[faces[face][2]]; + const Vec3 normal = normalize(cross(subtract(b, a), subtract(c, a))); + const uint16_t first = (uint16_t)positions->size(); + positions->push_back(a); + positions->push_back(b); + positions->push_back(c); + normals->push_back(normal); + normals->push_back(normal); + normals->push_back(normal); + indices->push_back(first); + indices->push_back((uint16_t)(first + 1)); + indices->push_back((uint16_t)(first + 2)); + } +} + +void append(std::vector* buffer, const void* data, size_t bytes) { + const uint8_t* start = static_cast(data); + buffer->insert(buffer->end(), start, start + bytes); +} + +void pad_to_four(std::vector* buffer, uint8_t filler) { + while ((buffer->size() % 4) != 0) buffer->push_back(filler); +} + +void write_u32(std::vector* buffer, uint32_t value) { + /* glTF is little-endian on the wire regardless of the host. */ + const uint8_t bytes[4] = {(uint8_t)(value & 0xFF), (uint8_t)((value >> 8) & 0xFF), + (uint8_t)((value >> 16) & 0xFF), (uint8_t)((value >> 24) & 0xFF)}; + append(buffer, bytes, sizeof(bytes)); +} + +std::string format_float(float value) { + char text[32]; + std::snprintf(text, sizeof(text), "%.6g", (double)value); + return text; +} + +} /* namespace */ + +int main(int argc, char** argv) { + std::string output = "larimar_logo.glb"; + for (int i = 1; i < argc; ++i) { + const std::string argument = argv[i]; + if ((argument == "-o" || argument == "--output") && i + 1 < argc) { + output = argv[++i]; + } else { + std::fprintf(stderr, "usage: %s [-o out.glb]\n", argv[0]); + return 2; + } + } + + std::vector positions; + std::vector normals; + std::vector indices; + build_gem(&positions, &normals, &indices); + + /* Bounds are required on the POSITION accessor, and gltfio checks them. */ + Vec3 minimum = positions[0]; + Vec3 maximum = positions[0]; + for (size_t i = 1; i < positions.size(); ++i) { + minimum.x = positions[i].x < minimum.x ? positions[i].x : minimum.x; + minimum.y = positions[i].y < minimum.y ? positions[i].y : minimum.y; + minimum.z = positions[i].z < minimum.z ? positions[i].z : minimum.z; + maximum.x = positions[i].x > maximum.x ? positions[i].x : maximum.x; + maximum.y = positions[i].y > maximum.y ? positions[i].y : maximum.y; + maximum.z = positions[i].z > maximum.z ? positions[i].z : maximum.z; + } + + std::vector binary; + const uint32_t positions_offset = 0; + append(&binary, positions.data(), positions.size() * sizeof(Vec3)); + const uint32_t normals_offset = (uint32_t)binary.size(); + append(&binary, normals.data(), normals.size() * sizeof(Vec3)); + const uint32_t indices_offset = (uint32_t)binary.size(); + append(&binary, indices.data(), indices.size() * sizeof(uint16_t)); + pad_to_four(&binary, 0); + + std::string json; + json += "{\"asset\":{\"version\":\"2.0\",\"generator\":\"eshi-gemgen\"},"; + json += "\"scene\":0,\"scenes\":[{\"nodes\":[0]}],"; + json += "\"nodes\":[{\"mesh\":0,\"name\":\"larimar_logo\"}],"; + json += "\"meshes\":[{\"name\":\"gem\",\"primitives\":[{\"attributes\":{\"POSITION\":0," + "\"NORMAL\":1},\"indices\":2,\"material\":0}]}],"; + /* + * A pale blue dielectric with a smooth-ish surface: enough roughness to + * catch the single directional light the backend installs, and no metallic + * component, because metal without image-based lighting renders black and + * that would look like a bug rather than a missing feature. + */ + json += "\"materials\":[{\"name\":\"larimar\",\"pbrMetallicRoughness\":{" + "\"baseColorFactor\":[0.46,0.79,0.86,1.0]," + "\"metallicFactor\":0.0,\"roughnessFactor\":0.35}}],"; + + json += "\"accessors\":["; + json += "{\"bufferView\":0,\"componentType\":5126,\"count\":" + + std::to_string(positions.size()) + ",\"type\":\"VEC3\",\"min\":[" + + format_float(minimum.x) + "," + format_float(minimum.y) + "," + + format_float(minimum.z) + "],\"max\":[" + format_float(maximum.x) + "," + + format_float(maximum.y) + "," + format_float(maximum.z) + "]},"; + json += "{\"bufferView\":1,\"componentType\":5126,\"count\":" + + std::to_string(normals.size()) + ",\"type\":\"VEC3\"},"; + json += "{\"bufferView\":2,\"componentType\":5123,\"count\":" + + std::to_string(indices.size()) + ",\"type\":\"SCALAR\"}],"; + + json += "\"bufferViews\":["; + json += "{\"buffer\":0,\"byteOffset\":" + std::to_string(positions_offset) + + ",\"byteLength\":" + std::to_string(positions.size() * sizeof(Vec3)) + + ",\"target\":34962},"; + json += "{\"buffer\":0,\"byteOffset\":" + std::to_string(normals_offset) + + ",\"byteLength\":" + std::to_string(normals.size() * sizeof(Vec3)) + + ",\"target\":34962},"; + json += "{\"buffer\":0,\"byteOffset\":" + std::to_string(indices_offset) + + ",\"byteLength\":" + std::to_string(indices.size() * sizeof(uint16_t)) + + ",\"target\":34963}],"; + json += "\"buffers\":[{\"byteLength\":" + std::to_string(binary.size()) + "}]}"; + + std::vector json_chunk; + append(&json_chunk, json.data(), json.size()); + pad_to_four(&json_chunk, ' '); /* JSON pads with spaces, BIN with zeroes. */ + + std::vector glb; + write_u32(&glb, 0x46546C67u); /* "glTF" */ + write_u32(&glb, 2u); + write_u32(&glb, (uint32_t)(12 + 8 + json_chunk.size() + 8 + binary.size())); + write_u32(&glb, (uint32_t)json_chunk.size()); + write_u32(&glb, 0x4E4F534Au); /* "JSON" */ + append(&glb, json_chunk.data(), json_chunk.size()); + write_u32(&glb, (uint32_t)binary.size()); + write_u32(&glb, 0x004E4942u); /* "BIN\0" */ + append(&glb, binary.data(), binary.size()); + + std::ofstream stream(output.c_str(), std::ios::binary | std::ios::trunc); + if (!stream) { + std::fprintf(stderr, "eshi-gemgen: cannot write %s\n", output.c_str()); + return 1; + } + stream.write(reinterpret_cast(glb.data()), (std::streamsize)glb.size()); + if (!stream) { + std::fprintf(stderr, "eshi-gemgen: failed while writing %s\n", output.c_str()); + return 1; + } + std::printf("eshi-gemgen: %s (%zu triangles, %zu bytes)\n", output.c_str(), + indices.size() / 3, glb.size()); + return 0; +} diff --git a/core/tools/matgen.cpp b/core/tools/matgen.cpp new file mode 100644 index 0000000..7fc9984 --- /dev/null +++ b/core/tools/matgen.cpp @@ -0,0 +1,89 @@ +/** + * @file matgen.cpp + * @brief Emit a Filament material definition from a shader source. + * + * Build-time only. It exists so that `examples/pong/pong.mat` does not: that + * file was a hand transliteration of `examples/pong/pong.gpu.cpp` — same + * distance functions, same constants, same score loop — and two copies of one + * material agree right up until somebody edits one of them. + * + * This links the same transpiler the runtime uses for GLSL and MSL, so a + * material and a shader cannot drift apart in their reading of the subset + * either. `matc` turns the output into the `.filamat` the Brush tier loads. + * + * usage: eshi-matgen [-o out.mat] [--uniform-floats N] + */ +#include +#include +#include +#include +#include + +#include "../src/render/transpile.h" + +namespace { + +/* + * The Filament backend's uniform block capacity. Declaring the full block + * rather than a per-game count keeps the material independent of how many + * floats a particular game happens to use; the runtime writes a prefix of it. + */ +const int kDefaultUniformFloats = 64; + +int usage(const char* program) { + std::fprintf(stderr, + "usage: %s [-o out.mat] [--uniform-floats N]\n", + program); + return 2; +} + +} /* namespace */ + +int main(int argc, char** argv) { + std::string source_path; + std::string output_path; + int uniform_floats = kDefaultUniformFloats; + + for (int i = 1; i < argc; ++i) { + const std::string argument = argv[i]; + if (argument == "-o" || argument == "--output") { + if (++i >= argc) return usage(argv[0]); + output_path = argv[i]; + } else if (argument == "--uniform-floats") { + if (++i >= argc) return usage(argv[0]); + uniform_floats = std::atoi(argv[i]); + if (uniform_floats < 0) return usage(argv[0]); + } else if (!argument.empty() && argument[0] == '-') { + return usage(argv[0]); + } else if (source_path.empty()) { + source_path = argument; + } else { + return usage(argv[0]); + } + } + if (source_path.empty()) return usage(argv[0]); + + std::string material, error; + if (!eshi::transpile::build_program(source_path, eshi::transpile::kTargetMat, + uniform_floats, &material, &error)) { + std::fprintf(stderr, "eshi-matgen: %s\n", error.c_str()); + return 1; + } + + if (output_path.empty()) { + std::fwrite(material.data(), 1, material.size(), stdout); + return 0; + } + + std::ofstream out(output_path.c_str(), std::ios::binary | std::ios::trunc); + if (!out) { + std::fprintf(stderr, "eshi-matgen: cannot write %s\n", output_path.c_str()); + return 1; + } + out.write(material.data(), (std::streamsize)material.size()); + if (!out) { + std::fprintf(stderr, "eshi-matgen: failed while writing %s\n", output_path.c_str()); + return 1; + } + return 0; +} diff --git a/core/tools/ppmdiff.cpp b/core/tools/ppmdiff.cpp new file mode 100644 index 0000000..2fc5ecd --- /dev/null +++ b/core/tools/ppmdiff.cpp @@ -0,0 +1,165 @@ +/** + * @file ppmdiff.cpp + * @brief Compare two directories of raw P6 frames and report the worst channel. + * + * The cross-tier conformance claim — Ink, Paper, and Brush agree to within one + * least-significant bit — was measured by hand and written into a table in + * docs/larimar/ARCHITECTURE.md. A number nobody can re-derive is a number that + * quietly stops being true, so this makes it a command. + * + * It deliberately reports the *maximum* absolute channel difference rather than + * a mean or a perceptual score. Averages hide exactly the failure this exists to + * catch: one wrong pixel in a frame is a broken transpile rule, and a mean over + * 320x180 buries it below the rounding noise that is expected everywhere else. + * + * usage: ppmdiff [max-allowed-diff] + */ +#include +#include +#include +#include +#include + +#include + +namespace { + +struct Image { + int width; + int height; + std::vector pixels; /* RGB, tightly packed. */ +}; + +/** Reads binary P6 with the exact header the SDL host's --dump writes. */ +bool read_ppm(const std::string& path, Image* out, std::string* error) { + std::FILE* file = std::fopen(path.c_str(), "rb"); + if (!file) { + *error = "cannot open " + path; + return false; + } + int width = 0, height = 0, maximum = 0; + if (std::fscanf(file, "P6\n%d %d\n%d\n", &width, &height, &maximum) != 3 || + width <= 0 || height <= 0 || maximum != 255) { + std::fclose(file); + *error = "not a 255-level binary PPM: " + path; + return false; + } + out->width = width; + out->height = height; + out->pixels.assign((size_t)width * (size_t)height * 3, 0); + const size_t read = std::fread(&out->pixels[0], 1, out->pixels.size(), file); + std::fclose(file); + if (read != out->pixels.size()) { + *error = "truncated frame: " + path; + return false; + } + return true; +} + +std::vector frame_names(const std::string& directory, std::string* error) { + std::vector names; + DIR* dir = opendir(directory.c_str()); + if (!dir) { + *error = "cannot open directory " + directory; + return names; + } + while (struct dirent* entry = readdir(dir)) { + const std::string name = entry->d_name; + if (name.size() > 4 && name.compare(name.size() - 4, 4, ".ppm") == 0) { + names.push_back(name); + } + } + closedir(dir); + /* The host names frames %06d, so lexical order is frame order. */ + for (size_t i = 1; i < names.size(); ++i) { + const std::string key = names[i]; + size_t j = i; + while (j > 0 && names[j - 1] > key) { + names[j] = names[j - 1]; + --j; + } + names[j] = key; + } + return names; +} + +} /* namespace */ + +int main(int argc, char** argv) { + if (argc < 3) { + std::fprintf(stderr, "usage: %s [max-allowed-diff]\n", argv[0]); + return 2; + } + const std::string left_dir = argv[1]; + const std::string right_dir = argv[2]; + const int allowed = argc > 3 ? std::atoi(argv[3]) : 1; + + std::string error; + const std::vector left_names = frame_names(left_dir, &error); + if (!error.empty()) { + std::fprintf(stderr, "ppmdiff: %s\n", error.c_str()); + return 2; + } + const std::vector right_names = frame_names(right_dir, &error); + if (!error.empty()) { + std::fprintf(stderr, "ppmdiff: %s\n", error.c_str()); + return 2; + } + if (left_names.empty()) { + std::fprintf(stderr, "ppmdiff: %s holds no frames\n", left_dir.c_str()); + return 2; + } + if (left_names.size() != right_names.size()) { + std::fprintf(stderr, "ppmdiff: %s has %zu frames, %s has %zu\n", left_dir.c_str(), + left_names.size(), right_dir.c_str(), right_names.size()); + return 2; + } + + int worst = 0; + std::string worst_frame; + int worst_x = 0, worst_y = 0; + double total = 0.0; + double samples = 0.0; + + for (size_t i = 0; i < left_names.size(); ++i) { + Image left, right; + if (!read_ppm(left_dir + "/" + left_names[i], &left, &error) || + !read_ppm(right_dir + "/" + right_names[i], &right, &error)) { + std::fprintf(stderr, "ppmdiff: %s\n", error.c_str()); + return 2; + } + if (left.width != right.width || left.height != right.height) { + std::fprintf(stderr, "ppmdiff: %s is %dx%d, %s is %dx%d\n", left_names[i].c_str(), + left.width, left.height, right_names[i].c_str(), right.width, + right.height); + return 2; + } + for (size_t p = 0; p < left.pixels.size(); ++p) { + const int difference = std::abs((int)left.pixels[p] - (int)right.pixels[p]); + total += difference; + samples += 1.0; + if (difference > worst) { + worst = difference; + worst_frame = left_names[i]; + worst_x = (int)((p / 3) % (size_t)left.width); + worst_y = (int)((p / 3) / (size_t)left.width); + } + } + } + + std::printf("frames=%zu mean=%.4f/255 max=%d/255", left_names.size(), + samples > 0.0 ? total / samples : 0.0, worst); + if (worst > 0) { + std::printf(" worst=%s@%d,%d", worst_frame.c_str(), worst_x, worst_y); + } + std::printf("\n"); + + if (worst > allowed) { + std::fprintf(stderr, + "ppmdiff: tiers disagree by %d, above the %d allowed. One LSB is float " + "evaluation differing; more than that is a transpile or binding bug.\n", + worst, allowed); + return 1; + } + return 0; +} diff --git a/docs/larimar/ARCHITECTURE.md b/docs/larimar/ARCHITECTURE.md index fe96f44..133c527 100644 --- a/docs/larimar/ARCHITECTURE.md +++ b/docs/larimar/ARCHITECTURE.md @@ -337,12 +337,15 @@ to Dart as a `Float32List`/`Int32List` via `asTypedList`, let Dart write packed commands into it, and cross the boundary **once** per frame with `eshi_flush(count)`. Same trick in reverse for the event queue. Bulk in, bulk out. -**6.8 — Don't build Filament from source in-tree.** It is a large CMake project -with its own toolchain expectations and would dominate the build. Consume the -official per-platform prebuilt release archives via a fetch script following the -existing `scripts/vendor.sh` pattern. License is Apache-2.0, compatible with -eshi's MIT — but the NOTICE obligations are real and need a `third_party/` -attribution file. +**6.8 — Don't build Filament from source in-tree.** *(Landed; +`scripts/vendor_filament.sh`.)* It is a large CMake project with its own +toolchain expectations and would dominate the build. Consume the official +per-platform prebuilt release archives via a fetch script following the existing +`scripts/vendor.sh` pattern. License is Apache-2.0, compatible with eshi's MIT — +but the NOTICE obligations are real and need a `third_party/` attribution file. +The script pins v1.75.0 and verifies its SHA-256 before extracting; the checksum +is what makes the pin mean anything, and the negative control is a different +version's archive being refused rather than quietly installed. **6.9 — Terminology.** `matc` is the *compiler*; the input is a `.mat` file, the output is a `.filamat` package. "Output Filament's `matc` syntax" should read @@ -469,6 +472,14 @@ bit-exact agreement is not achievable and was never the goal. Note that the equality check that answers *"is this tier deterministic?"*, not *"do two tiers agree?"* Those need different tools, and conflating them is easy. +That table is now `scripts/check_tier_conformance.sh`, which re-derives it on +demand and runs in CI. It also covers the Filament tier, which the numbers above +predate: PLAN Step 6a made Filament materials a *generated* representation of +the same source rather than a hand-written second copy, so Brush-through-Filament +is inside the oracle instead of beside it. See +[`SHADER_SUBSET.md`](SHADER_SUBSET.md) for what the material domain cannot +express, and why two of the twenty programs stop at Paper and Ink. + **The oracle immediately earned its keep.** The first cross-tier run showed Paper diverging from Ink and growing worse over time. The cause: `glReadPixels` returns rows bottom-up, and the readback was copying them straight through, so @@ -492,9 +503,11 @@ point's signature — a helper function cannot see it. - `core/src/render/filament.cpp` implements the existing backend vtable with a headless offscreen Filament view. Brush selects it in Filament-enabled builds. -- `examples/pong/pong.mat` is compiled by `matc` during `zig build larimar +- The material is compiled by `matc` during `zig build larimar -Dfilament=true`; the backend uploads the same opaque uniform float block used - by the lightweight GPU tiers. + by the lightweight GPU tiers. As of PLAN Step 6a the `.mat` handed to `matc` + is generated from `pong.gpu.cpp` by `eshi-matgen` rather than written by hand, + so First Light's material is the same file the other tiers read. - The First Light readback preserves the framebuffer contract and pixel-matches Ink. A Flutter hardware-texture host can later remove that copy without changing the ECS or game API. @@ -503,7 +516,7 @@ point's signature — a helper function cannot see it. ### Phase 2 — SDL3 + host hardening (§6.10) -### Phase 3 — Dart — **native half landed** +### Phase 3 — Dart — **package and macOS texture host landed** The two subsystems §6.6 and §6.7 describe are built, tested, and proven against Pong. They were built *before* Dart deliberately: both are testable in C++ with @@ -520,8 +533,8 @@ gets a vote on its shape. | Bulk event drain (§6.7) | `core/src/scene.cpp` | Done — same framing in reverse; overflow reported, not truncated | | C++ encoder/decoder | `core/include/eshi/scene.hpp` | Done — header-only, and the executable spec of the format | | Pong on the reconciler | `examples/pong/pong.cpp` | Done — the scene is a description, not a sequence of `eshi_entity_create()` calls | -| `ffigen` bindings from `eshi.h` | — | Not started | -| Flutter embedder + external texture, macOS first (§6.11) | — | Not started | +| `ffigen` bindings from `eshi.h` | `bindings/dart/larimar` | Done — generated ABI bindings, owned world/buffers, golden codecs, drift gate | +| Flutter embedder + external texture, macOS first (§6.11) | `bindings/dart/larimar/macos` | Done — IOSurface/CVPixelBuffer/Metal texture, direct Brush submission, and a lifecycle loop gated under `leaks`, ThreadSanitizer, and a composited frame capture | Four things came out of building it that were not obvious from §6.6. @@ -556,6 +569,22 @@ reconciler and the game argue over the ball every reload. The rule that fell out Dart layer will have to follow too, and it is easier to state now than to retrofit once widgets are writing scene descriptions. +**The texture host needed a harness the engine could not provide.** The +integration test drives a real `EshiView` through real create/resize/background/ +destroy cycles, and it cannot tell a held lock from a lucky one: Flutter's raster +thread borrows the surface on its own schedule, so a missing lock is a timing +question the test happens to win. `check_eshiview_host.sh` removes the engine +instead of the concurrency — the real plugin, its real method-channel entry +points, a stand-in registry that keeps Flutter's thread contract, and +ThreadSanitizer watching both sides. Deleting the surface lock turns it red at +the resize; the engine-level test stays green. The same harness under +`leaks --atExit` answers the leak question the sandboxed application cannot ask +about itself. Its first run reported every surface still alive after disposal, +which was the harness missing the per-cycle autorelease pool a run loop would +have given it — worth stating because it is the failure mode of any host-free +harness: the thing under test is only as honest as the environment you rebuild +around it. + One deliberate call worth flagging for review: an opcode the core does not implement stops the flush with `ESHI_ERR_UNSUPPORTED` rather than being skipped. The length field makes skipping *possible*, and skipping is the usual choice for @@ -617,7 +646,10 @@ it is the tier below Filament's floor, and §7 is ordered accordingly. **Settled:** the Rust monorepo is context, not a dependency. Nothing in `resources/gyosho` is linked, vendored, or ported wholesale. S2L's ideas move -into the C++ side; its implementation does not. +into the C++ side; its implementation does not. Refined by PLAN Step 6b: `sumic` +may be *invoked* as a source generator on a dev machine or in CI, emitting the +C++ subset that is checked in. That is §4's build-time-only row, not a +dependency — a consumer build never sees Rust, and the runtime never links it. Still open, before Phase 1: @@ -630,7 +662,8 @@ Still open, before Phase 1: 3. Filament `FeatureLevel` ↔ Kantei `Grade` — verify the mapping in §2. 4. `.mat` expressive limits vs. the gallery corpus — verify before designing the `.mat` emitter (§3). -5. **Specify the C++ shader subset.** It already exists implicitly: it is +5. **Specify the C++ shader subset.** *(Answered by PLAN Step 6a; see + [`SHADER_SUBSET.md`](SHADER_SUBSET.md).)* It already exists implicitly: it is whatever survives the `replaceAll` passes in `renderer_gl.h` and `renderer_metal.mm`, and 20 programs already conform to it. Writing it down is the cheapest possible version of "define S2L", and it has to happen before diff --git a/docs/larimar/HERO_ASSET.md b/docs/larimar/HERO_ASSET.md new file mode 100644 index 0000000..7758763 --- /dev/null +++ b/docs/larimar/HERO_ASSET.md @@ -0,0 +1,132 @@ +# Larimar Hero Asset + +> Status: presentation prototype landed in +> `examples/larimar_flutter/lib/hero.dart`; the caustic surface material and +> general ECS `AngularVelocity` component remain Step 8 work. This is the detail +> record for that step; [`PLAN.md`](../../PLAN.md) holds the final gate and +> [`ARCHITECTURE.md`](ARCHITECTURE.md) holds the decisions it rests on. + +## 1. Outcome + +The hero asset is a spinning, refractive Larimar stone rendered inside an +`EshiView` with ordinary Flutter UI above it. It is the release's one image, and +it is chosen because it cannot be faked: producing it requires every layer of the +engine to work at the same time. + +1. Faceted glTF geometry loaded natively through `gltfio`. +2. A generative caustic material authored once, compiled to `.filamat`. +3. Rotation integrated by the C++ ECS, with no per-frame FFI traffic. +4. Composition over Flutter UI through a transparent external texture. + +It answers presentation quality directly, which is what a competing framework's +landing page asserts and a benchmark table cannot. + +## 2. Evidence shaping the plan + +- Fluorite leads with a spinning refractive asset because it demonstrates engine + viability in one glance. The response has to be an equivalent artifact, not an + argument. +- `gltfio` owns Filament's glTF parsing and instantiation. Bypassing it for one + asset would fragment the resource pipeline the rest of RC0 depends on. +- Crossing the FFI boundary per frame to spin a transform would spend exactly the + budget the shared command buffer exists to save. The intent belongs to Dart; + the integration belongs to the core. +- The gallery's Voronoi program already models cellular caustics on the CPU and + the GPU, within 1 LSB. That is the starting point for the stone's material, and + it is already conformance-tested. + +## 3. Scope + +### Required + +- `larimar_logo.glb`, a low-poly faceted gem — also Step 5's reference asset, so + the release carries one asset rather than a test one and a pretty one. +- A caustic material mapping Voronoi noise to `baseColor`, `clearCoat`, and + transmission. +- An `AngularVelocity` component and its system in the C++ core. +- Declarative Dart support for spawning the asset and declaring its spin. +- Transparent clear color through `EshiView` and the Filament view. + +### Deferred + +- Touch picking on the logo. Step 7 proves picking on a tagged trigger; the hero + asset does not need to repeat it. +- Bloom, glare, and other post-processing beyond the material itself. +- Gold-tier or NPU work for this asset. + +## 4. Ordered execution + +Each stage ends in something checkable. Stage 1 lands with Step 5 rather than +here, because the reference asset and the hero asset are the same file. + +### Stage 1 — Geometry (with Step 5) + +- Model the faceted gem in Blender; export `larimar_logo.glb`. +- Load it through `gltfio` and expose instantiation by name over the command + protocol. + +**Check:** the greybox renders in `EshiView` when Dart asks for it — which is +Step 5's gate, met by the hero asset instead of a throwaway cube. + +### Stage 2 — The caustic material + +- Port the gallery's Voronoi logic into the material source, against the subset + Step 6a specifies. +- Map the noise to `baseColor`, `clearCoat`, and transmission under a lit + shading model. + +**Check:** the static asset shows the cyan-and-white refractive material. + +This is the first *surface* material in the project. Step 6a gates fullscreen +unlit materials, where a `mainImage` maps cleanly onto a device-domain quad; a +lit surface material fills in a different part of `MaterialInputs` and may need +subset rules the gallery never exercised. Anything the subset cannot express +must fail at compile time with a diagnostic, not degrade into a flat surface. + +### Stage 3 — Rotation in the core + +- Define an `AngularVelocity` POD component. +- Integrate it against the fixed timestep in a pure system, writing the rotation + through the transform manager. +- Extend the command protocol with the component declaration. + +**Check:** a C++ unit test advances an entity 60 ticks and asserts the resulting +transform, with no host involved. + +### Stage 4 — Composition + +- Declare the entity through the retained scene API with its mesh, material, and + angular velocity. +- Configure a transparent clear color on the view and the Filament swapchain. +- Layer the `EshiView` beneath Flutter text and controls. + +**Check:** the logo spins under the UI; one FFI call declares it and none occur +per frame while it turns. + +## 5. Scorecard + +| Area | Evidence | Current state | +|---|---|---| +| glTF pipeline | `resources/larimar-model.glb` loaded and instantiated by Dart | Prototype complete | +| Custom material | Caustic material compiled and bound | Unstarted | +| Native motion | One-time `spinY` declaration; general `AngularVelocity` ECS | Prototype complete; ECS remains | +| Composition | Transparent Filament `EshiView` under Flutter UI | Prototype complete | + +The prototype also loops `resources/larimar-official.mp3` in the Flutter host. +Its 4.54-second spectral rise, shimmer, and decay keyframe the background color +story. This does not introduce an engine audio API; audio remains deferred RC +scope, as the architecture record requires. + +## 6. Decision rules and risks + +- **Simulation stays native.** A Flutter `AnimationController` spinning the logo + would look identical and prove nothing. Dart declares that it spins at a rate; + the core owns advancing the matrix. If that distinction is ever invisible in + the profile, the demo has stopped demonstrating the claim. +- **Opaque fallback is acceptable.** If transparent external textures cost + measurable composition time on macOS, match the view's clear color to the + Flutter background instead and say so. A 60fps opaque hero beats a transparent + one that stutters. +- **Parity is not optional.** The material must also emit its CPU entry point, so + the logo remains renderable headless on Ink even when nobody looks at it that + way. That path is what keeps the material inside the conformance oracle. diff --git a/docs/larimar/NATIVE_CONTRACT.md b/docs/larimar/NATIVE_CONTRACT.md index a0af096..0e53957 100644 --- a/docs/larimar/NATIVE_CONTRACT.md +++ b/docs/larimar/NATIVE_CONTRACT.md @@ -69,9 +69,56 @@ synchronously from `eshi_tick()` on the calling/world thread. The result-string and grade-probe functions do not use a world. The OpenGL proc loader is process-global: install it before creating Paper-grade worlds and do -not replace it while such a world is in use. The future Flutter host must marshal -view resize, render, and disposal to its render thread; it must not call through -a stale Dart isolate after disposal. +not replace it while such a world is in use. + +A macOS consumer may carry both Brush implementations. A source-backed +fullscreen material selects direct Metal when it is bound; a package-backed or +3D-only world selects Filament. This keeps the grade stable while allowing the +Pong reference and glTF scenes to coexist in one application binary. + +On macOS, `EshiView` treats the creating Dart isolate as the render-submission +thread. Its ticker updates the world and calls the private Metal target entry +point synchronously on that isolate. Texture registration, resize, +frame-available, suspend/resume, and disposal are serialized through the +platform channel, whose handler runs on Flutter's platform thread. Flutter's +raster thread never calls the core; it only calls `copyPixelBuffer` under the +adapter's surface lock. + +Each view owns its Flutter texture ID, IOSurface-backed `CVPixelBuffer`, and +borrowed `id`. Resize atomically swaps that pair. Direct Metal +borrows the texture; Filament temporarily retains the pixel buffer as an Apple +CVPixelBuffer swapchain, which is the supported zero-readback path for a BGRA +surface. The raster thread +receives an owning pixel-buffer reference, so it may finish consuming the old +surface after a resize. A synchronous Metal submission must finish before the +host announces the frame, and no world or borrowed texture handle may be used +after view/world disposal. Independent views may share a world only when their +frame submissions are serialized on that world's owner isolate. + +## Assets + +`EshiAsset` is an opaque handle. Loading parses and uploads once; instancing +places copies that share that upload, so a second instance costs a transform +rather than a second copy of the mesh. Both belong to the world's backend and +are released with it, so a host that exits need not release them by hand. + +Geometry is a capability. A backend without a 3D scene — Ink, Paper, direct +Metal — leaves the asset entry points unimplemented and every asset call +returns `ESHI_ERR_UNSUPPORTED`. A missing or unparsable file returns +`ESHI_ERR_INVALID` after naming the file on stderr, and asking for more +instances than the asset reserved returns `ESHI_ERR_LIMIT`. A failed load +leaves the caller's handle untouched and the world renderable. + +Scene conventions are glTF's own: right-handed, Y up, metres. The backend +installs a 45° vertical field of view, a photographic exposure, and one +directional light at daylight intensity. Image-based lighting is not in yet, +which is why a fully metallic material renders dark. + +`eshi_asset_instance_animated` adds a root-space Y rotation rate to an +instance. The backend evaluates it while submitting the frame, so Dart declares +the spin once rather than crossing FFI with a matrix every frame. This is the +hero prototype's narrow animation seam; the retained-scene `AngularVelocity` +component in Step 8 remains the general simulation API. ## Errors and capacity diff --git a/docs/larimar/SHADER_SUBSET.md b/docs/larimar/SHADER_SUBSET.md new file mode 100644 index 0000000..f6bb9bf --- /dev/null +++ b/docs/larimar/SHADER_SUBSET.md @@ -0,0 +1,102 @@ +# The Larimar shader subset + +> Status: descriptive, not aspirational. Everything here is what +> `core/src/render/transpile.cpp` already does and what the 20 programs in +> `examples/` already obey. It answers open question 5 in +> [`ARCHITECTURE.md`](ARCHITECTURE.md) §9 and is the contract PLAN Step 6b's S2L +> frontend must lower into. + +A Larimar material is written once, as C++. That single file is: + +- **compiled** by the C++ compiler for the Ink tier, where it runs on the CPU; +- **transpiled** at runtime to GLSL 3.30 for Paper and to MSL for Brush/Metal; +- **emitted** at build time as a Filament `.mat` for Brush/Filament, which + `matc` compiles into a `.filamat`. + +The subset is the intersection of what those four consumers accept. It is +enforced by a line-oriented textual converter, not a parser, and this document +says so plainly: the rules below are what that converter can see. + +## 1. The entry point + +```cpp +SHADER_CTX void mainImage(vec4 &fragColor, vec2 fragCoord, + vec2 iResolution, float iTime); +``` + +`fragCoord` is in pixels, origin bottom-left, matching `gl_FragCoord`. Every +target reconstructs that convention rather than exposing its own: the Metal +kernel flips the thread's y, and the Filament material rebuilds it from the +device-domain clip position. One coordinate system is what makes the tiers +comparable pixel for pixel. + +`fragColor` is written, never read. Other reference parameters are `inout` and +may be read before assignment — `mario.cpp`'s sprite helpers composite onto what +is already there — so the converter never promotes them to `out`. + +## 2. Types and calls + +- `float`, `int`, `bool`, `vec2/3/4`, `ivec*`, `bvec*`, `mat2/3/4`. +- The `glsl::`/`sumi::` namespace qualifiers are stripped; write them or don't. +- Swizzles may be written as calls — `p.xy()` — which the converter rewrites to + members. That spelling exists because the CPU side implements them as methods. +- The libc float intrinsics (`sinf`, `powf`, `fabsf`, …) map to the shading + languages' unsuffixed names through a generated preamble. +- Float literals keep their `f` suffix. All four targets accept it. + +## 3. What each target adds + +| Rule | Ink | GLSL | MSL | `.mat` | +|---|---|---|---|---| +| `#include`, `#pragma`, `using namespace`, `extern` | kept | stripped | stripped | stripped | +| C-style casts `(float)x` | ok | rewritten to `float(x)` | ok | rewritten | +| Direct init `vec2 c(0,1);` | ok | rewritten to `= vec2(0,1)` | ok | rewritten | +| `(void)x;` | ok | removed | ok | removed | +| `noise1`–`noise4` | ok | renamed `eshi_noise*` (reserved) | ok | renamed | +| `PI`, `HALF_PI` | ok | ok | ok | renamed `eshi_PI` (Filament defines them) | +| `eshi_uniforms[i]` | typed struct | global array | kernel argument | `materialParams.eshiUniforms` | +| `iChannel0` | sampled | `sampler2D` | `texture2d` | **not available** | + +An `extern` declaration takes its whole body with it — `mainSound` is host code +and has no business in a shader. + +Uniforms are read only inside `mainImage`: MSL binds the block as a kernel +argument threaded through that signature, so a helper function cannot see it. + +## 4. What the `.mat` target cannot express + +Filament owns the vertex stage, the varyings, the uniform layout, and the +lighting; a fragment block fills in `MaterialInputs` under a declared shading +model. Larimar's fullscreen materials map onto `shadingModel : unlit` with +`vertexDomain : device`, which is a clean fit — 18 of the 20 corpus programs +compile through it unchanged. Two do not, and both refuse at generation time +with a diagnostic naming the cause rather than emitting something plausible: + +**Texture sampling.** `warp.cpp` reads `iChannel0`. The generated material +declares no sampler and the Filament backend binds no texture, so there is +nothing to sample. Adding a sampler parameter is possible and deliberately not +in RC0; the shader remains available on Paper and Ink. + +**Braces that only balance after preprocessing.** `rainforest.cpp` opens its +march loop inside `#ifdef LOWQUALITY` and again inside the `#else`. Every +compiler in the chain handles that correctly, but `matc` splits a `.mat` into +blocks by counting braces *before* preprocessing, so the fragment block runs +past its own closing brace and matc reports an unexpected character on a line +that looks fine. The rule for materials is therefore stricter than for shaders: +a conditional may not open a brace it does not close. + +`scripts/check_materials.sh` holds that list. A shader may only fail to emit if +it is named there, and a shader that starts emitting must be removed from it — +the set cannot grow or shrink without someone editing the file. + +## 5. What the subset is not + +It is not a language, and `transpile.cpp` is not a compiler. There is no type +checker, no scope analysis, and no error for a construct that happens to survive +the substitutions and mean something different afterwards. What keeps that +honest is the conformance oracle rather than the converter: +`scripts/check_tier_conformance.sh` renders the same source on every available +tier and requires the frames to agree to within one least-significant bit. + +PLAN Step 6b brings S2L in as a frontend that emits this subset. The subset is +what carries forward; the frontend is a nicer way to write it. diff --git a/docs/larimar/media/eshiview-pong-01.png b/docs/larimar/media/eshiview-pong-01.png new file mode 100644 index 0000000..e897f8a Binary files /dev/null and b/docs/larimar/media/eshiview-pong-01.png differ diff --git a/docs/larimar/media/eshiview-pong-02.png b/docs/larimar/media/eshiview-pong-02.png new file mode 100644 index 0000000..62179a1 Binary files /dev/null and b/docs/larimar/media/eshiview-pong-02.png differ diff --git a/examples/larimar_flutter/README.md b/examples/larimar_flutter/README.md index 6eaa29a..53c4045 100644 --- a/examples/larimar_flutter/README.md +++ b/examples/larimar_flutter/README.md @@ -1,12 +1,33 @@ # Larimar Flutter reference -The reference desktop shell for Larimar RC0. It currently loads the native -code asset, validates ABI/protocol v1, flushes one retained scene, and owns the -native world for the Flutter widget lifecycle. Plan Step 4 replaces the status -panel with the first macOS `EshiView` external texture. +The reference desktop shell for Larimar RC0. On macOS it renders Pong through an +`EshiView` external texture — an IOSurface-backed Metal target the Brush backend +writes into directly — with ordinary Flutter UI composited above it. On Linux it +loads the native code asset, validates ABI/protocol v1, flushes one retained +scene, and owns the native world for the Flutter widget lifecycle. + +The brand hero is the first real glTF scene to use the same view. Filament loads +`resources/larimar-model.glb`, the native renderer owns its slow Y-axis spin, +and Flutter layers the HarmonyOS Sans wordmark and a 4.54-second, soundtrack- +shaped color transition around its transparent texture. Audio remains host UI, +not an engine subsystem. + +```sh +flutter run -d macos -t lib/hero.dart +``` ```sh flutter run -d macos # or flutter run -d linux ``` + +`integration_test/eshiview_lifecycle_test.dart` is the Step 4 gate: a +create/resize/background/foreground/destroy loop that asserts the host reclaimed +every surface, and a visual test that captures the composited frames. Run it +through the harness, which supplies the cycle count and snapshots the process +from outside the App Sandbox: + +```sh +../../scripts/check_eshiview_lifecycle.sh +``` diff --git a/examples/larimar_flutter/analysis_options.yaml b/examples/larimar_flutter/analysis_options.yaml index 0d29021..bf8d421 100644 --- a/examples/larimar_flutter/analysis_options.yaml +++ b/examples/larimar_flutter/analysis_options.yaml @@ -7,6 +7,15 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml linter: diff --git a/examples/larimar_flutter/assets/fonts/HarmonyOS_Sans_Light.ttf b/examples/larimar_flutter/assets/fonts/HarmonyOS_Sans_Light.ttf new file mode 120000 index 0000000..ce0dbc6 --- /dev/null +++ b/examples/larimar_flutter/assets/fonts/HarmonyOS_Sans_Light.ttf @@ -0,0 +1 @@ +../../../../resources/HarmonyOS Sans/HarmonyOS_Sans/HarmonyOS_Sans_Light.ttf \ No newline at end of file diff --git a/examples/larimar_flutter/assets/fonts/HarmonyOS_Sans_Medium.ttf b/examples/larimar_flutter/assets/fonts/HarmonyOS_Sans_Medium.ttf new file mode 120000 index 0000000..e86840b --- /dev/null +++ b/examples/larimar_flutter/assets/fonts/HarmonyOS_Sans_Medium.ttf @@ -0,0 +1 @@ +../../../../resources/HarmonyOS Sans/HarmonyOS_Sans/HarmonyOS_Sans_Medium.ttf \ No newline at end of file diff --git a/examples/larimar_flutter/assets/fonts/HarmonyOS_Sans_Regular.ttf b/examples/larimar_flutter/assets/fonts/HarmonyOS_Sans_Regular.ttf new file mode 120000 index 0000000..e0c6e04 --- /dev/null +++ b/examples/larimar_flutter/assets/fonts/HarmonyOS_Sans_Regular.ttf @@ -0,0 +1 @@ +../../../../resources/HarmonyOS Sans/HarmonyOS_Sans/HarmonyOS_Sans_Regular.ttf \ No newline at end of file diff --git a/examples/larimar_flutter/assets/larimar-model.glb b/examples/larimar_flutter/assets/larimar-model.glb new file mode 120000 index 0000000..772c5ba --- /dev/null +++ b/examples/larimar_flutter/assets/larimar-model.glb @@ -0,0 +1 @@ +../../../resources/larimar-model.glb \ No newline at end of file diff --git a/examples/larimar_flutter/assets/larimar-official.mp3 b/examples/larimar_flutter/assets/larimar-official.mp3 new file mode 120000 index 0000000..842d92c --- /dev/null +++ b/examples/larimar_flutter/assets/larimar-official.mp3 @@ -0,0 +1 @@ +../../../resources/larimar-official.mp3 \ No newline at end of file diff --git a/examples/larimar_flutter/integration_test/eshiview_lifecycle_test.dart b/examples/larimar_flutter/integration_test/eshiview_lifecycle_test.dart new file mode 100644 index 0000000..d0136cf --- /dev/null +++ b/examples/larimar_flutter/integration_test/eshiview_lifecycle_test.dart @@ -0,0 +1,377 @@ +// The Step 4 gate: the macOS EshiView lifecycle under leak diagnostics, and a +// visual smoke test that keeps the evidence. +// +// Run it through scripts/check_eshiview_lifecycle.sh rather than directly. The +// script supplies the cycle count, snapshots `leaks` and `vmmap` from outside +// the App Sandbox — a sandboxed process cannot examine itself — and copies the +// captured frames out of the app container. +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:larimar/larimar.dart'; +import 'package:larimar_flutter/main.dart'; + +/// Create/resize/background/foreground/destroy rounds run by the leak gate. +const _cycles = int.fromEnvironment( + 'LARIMAR_LIFECYCLE_CYCLES', + defaultValue: 5, +); + +/// True when an external harness is listening for snapshot requests. +const _leakHarness = bool.fromEnvironment('LARIMAR_LEAK_HARNESS'); + +const _host = MacOSEshiViewHost(); + +/// Deliberately 4:3, not the arena's 16:9. +/// +/// The material's horizontal extent *is* the view's aspect ratio, so a game +/// that assumes 16:9 puts its paddles outside any narrower window — which is +/// what the reference application did, in the default window, on the first +/// machine that ran it. Testing at the arena's own shape would never show it. +const _viewSize = Size(640, 480); + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('reclaims every surface across $_cycles lifecycle cycles', ( + tester, + ) async { + // The App Sandbox rewrites the temp directory into the app container, and + // the harness outside cannot guess that path. Announce it before anything + // else so snapshot requests and captures can be found. + debugPrint('larimar-tmp: ${Directory.systemTemp.path}'); + if (!_brushIsAvailable()) return; + + final start = await _host.diagnostics(); + expect(start.metalAvailable, isTrue); + expect(start.registered, 0); + + for (var cycle = 0; cycle < _cycles; cycle += 1) { + final key = ValueKey(cycle); + await tester.pumpWidget(LarimarApp(key: key, testViewSize: _viewSize)); + await _pumpUntilTexture(tester); + expect(find.byKey(const ValueKey('larimar-error')), findsNothing); + + await tester.pumpWidget( + LarimarApp( + key: key, + testViewSize: Size( + _viewSize.width + (cycle % 4 + 1) * 8, + _viewSize.height + (cycle % 4 + 1) * 8, + ), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byType(Texture), findsOneWidget); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + await tester.pump(const Duration(milliseconds: 50)); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byType(Texture), findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byType(Texture), findsNothing); + + final settled = await _settle(tester); + expect( + settled.registered, + 0, + reason: 'cycle $cycle left a surface registered with the host', + ); + expect( + settled.live, + 0, + reason: 'cycle $cycle left a surface alive after disposal', + ); + + // One cycle is enough to charge every one-time allocation — the Metal + // device, the shader pipeline, the texture cache — so the baseline is + // taken here rather than before the loop. Everything after it must be + // reclaimed, and that is exactly what the harness compares. + if (cycle == 0) await _requestSnapshot('baseline'); + } + + final end = await _settle(tester); + debugPrint('larimar-diagnostics: $end'); + expect(end.created, _cycles); + expect(end.disposed, end.created); + expect(end.resized, greaterThanOrEqualTo(_cycles)); + expect(end.presented, greaterThan(0)); + expect(end.suspensions, greaterThanOrEqualTo(_cycles * 2)); + expect(end.registered, 0); + expect(end.live, 0); + expect(end.liveSurfaceBytes, 0); + + await _requestSnapshot('final'); + }); + + testWidgets('draws animated Pong into the surface Flutter composites', ( + tester, + ) async { + if (!_brushIsAvailable()) return; + + await tester.pumpWidget(const LarimarApp(testViewSize: _viewSize)); + await _pumpUntilTexture(tester); + expect(find.text('Flutter UI over animated Pong'), findsOneWidget); + expect(find.byKey(const ValueKey('larimar-error')), findsNothing); + + // Two separate claims, and they need two separate instruments. + // + // The first is that Larimar draws the frame: read back the surface itself, + // which is the memory the engine was handed. The second is that the engine + // composites it, which only its borrow count can answer. + // + // What is deliberately *not* used here is RepaintBoundary.toImage over the + // app: it rasterizes the layer tree offscreen and does not reliably include + // an external texture layer. Measured on this engine, it returned the + // Flutter UI over an empty view while the surface held a complete frame and + // the engine was borrowing it 60 times a second. A capture that can come + // back blank when everything works is not evidence of anything. + final drawn = await _pumpUntilComposited(tester, 4); + expect( + drawn.copies, + greaterThan(0), + reason: 'the engine never borrowed the surface; the view is a hole', + ); + + final surface = _textureSurface(tester); + final first = await _captureSurface(surface, 'eshiview-pong-first.png'); + await _pumpUntilComposited(tester, 20); + final second = await _captureSurface(surface, 'eshiview-pong-second.png'); + + // Pong's ball and paddles are the brightest things in the frame, so peak + // luminance separates a drawn frame from a cleared one. + expect( + first.peakLuminance(), + greaterThan(0.5), + reason: 'the surface is dark everywhere; no frame reached it', + ); + expect( + first.changedFraction(second), + greaterThan(0.002), + reason: 'two captures 20 frames apart are identical; the scene is frozen', + ); + + // The paddles live at the arena's edges, so they are the part of the scene + // that leaves the view when the game and the window disagree about shape. + expect( + first.peakLuminanceInBand(0, 0.15), + greaterThan(0.3), + reason: 'nothing bright near the left edge; the left paddle is off view', + ); + expect( + first.peakLuminanceInBand(0.85, 1), + greaterThan(0.3), + reason: + 'nothing bright near the right edge; the right paddle is off view', + ); + }); + + testWidgets('ends the match and starts another', (tester) async { + if (!_brushIsAvailable()) return; + + // A match to one point, because what is under test is that a match *ends*. + // The demo scored past the nine dots its material can draw and kept going, + // so the win condition is the subject here, not the length of a rally. + await tester.pumpWidget( + const LarimarApp(testViewSize: _viewSize, testWinningScore: 1), + ); + await _pumpUntilTexture(tester); + + final winner = find.byKey(const ValueKey('larimar-winner')); + await _pumpUntil(tester, () => winner.evaluate().isNotEmpty); + expect( + winner, + findsOneWidget, + reason: 'a point was scored but the match never ended', + ); + expect(tester.widget(winner).data, contains('wins')); + + // And it clears again, rather than leaving the announcement up forever. + await _pumpUntil(tester, () => winner.evaluate().isEmpty); + expect( + winner, + findsNothing, + reason: 'the announcement never cleared; the next match cannot start', + ); + }); +} + +/// Pumps frames until [condition] holds, for up to ten seconds of play. +Future _pumpUntil(WidgetTester tester, bool Function() condition) async { + for (var attempt = 0; attempt < 600 && !condition(); attempt += 1) { + await tester.pump(const Duration(milliseconds: 16)); + } +} + +/// A Brush-grade world needs a Metal device. Virtualized runners have none, and +/// that is a skip, not a failure — the same rule the Metal CI job follows. +bool _brushIsAvailable() { + try { + LarimarWorld( + config: const LarimarWorldConfig(grade: LarimarGrade.brush), + ).dispose(); + return true; + } on LarimarNativeException catch (error) { + if (error.result == LarimarResult.unsupported) { + debugPrint('larimar-skip: no Metal device on this host'); + return false; + } + rethrow; + } +} + +Future _pumpUntilTexture(WidgetTester tester) async { + for (var attempt = 0; attempt < 60; attempt += 1) { + await tester.pump(const Duration(milliseconds: 50)); + if (find.byType(Texture).evaluate().isNotEmpty) return; + } + fail('the EshiView texture never appeared'); +} + +/// Pumps until the engine has composited [frames] more frames of the view. +/// +/// One `tester.pump()` is one frame here, so a single long pump advances the +/// scene by one step no matter how long it waits. Counting the host's borrows +/// is both the honest measure of progress and the one that cannot pass while +/// nothing is being drawn. +Future _pumpUntilComposited( + WidgetTester tester, + int frames, +) async { + var diagnostics = await _host.diagnostics(); + final target = diagnostics.copies + frames; + for ( + var attempt = 0; + attempt < 240 && diagnostics.copies < target; + attempt += 1 + ) { + await tester.pump(const Duration(milliseconds: 16)); + diagnostics = await _host.diagnostics(); + } + if (diagnostics.copies < target) { + fail( + 'the engine composited ${diagnostics.copies} frames, expected $target; ' + 'the view is not reaching the screen', + ); + } + return diagnostics; +} + +/// Reads the host's accounting once the raster thread has let go of everything +/// the platform thread already released. +Future _settle(WidgetTester tester) async { + var diagnostics = await _host.diagnostics(); + for (var attempt = 0; attempt < 40 && diagnostics.live > 0; attempt += 1) { + await tester.pump(const Duration(milliseconds: 50)); + diagnostics = await _host.diagnostics(); + } + return diagnostics; +} + +/// Asks the external harness for a `leaks`/`vmmap` snapshot and waits for it. +Future _requestSnapshot(String phase) async { + if (!_leakHarness) return; + final request = File( + '${Directory.systemTemp.path}/larimar-lifecycle.request', + ); + final ack = File('${Directory.systemTemp.path}/larimar-lifecycle.ack'); + if (ack.existsSync()) ack.deleteSync(); + request.writeAsStringSync(phase, flush: true); + final deadline = DateTime.now().add(const Duration(seconds: 180)); + while (DateTime.now().isBefore(deadline)) { + if (ack.existsSync() && ack.readAsStringSync().trim() == phase) return; + await Future.delayed(const Duration(milliseconds: 100)); + } + fail('the leak harness never acknowledged the "$phase" snapshot'); +} + +/// The surface behind the mounted view, addressed by the id Flutter is using. +EshiViewSurface _textureSurface(WidgetTester tester) => EshiViewSurface( + textureId: tester.widget(find.byType(Texture)).textureId, + metalTexture: 0, + pixelBuffer: 0, + width: 0, + height: 0, +); + +Future<_Frame> _captureSurface(EshiViewSurface surface, String name) async { + final file = File('${Directory.systemTemp.path}/$name'); + await _host.captureSurface(surface, file.path); + // The harness copies anything announced this way out of the app container. + debugPrint('larimar-artifact: ${file.path}'); + + final image = await decodeImageFromList(await file.readAsBytes()); + try { + final raw = await image.toByteData(); + return _Frame( + width: image.width, + height: image.height, + pixels: raw!.buffer.asUint8List(), + ); + } finally { + image.dispose(); + } +} + +/// One read-back of the view's surface: what Larimar drew, before compositing. +final class _Frame { + const _Frame({ + required this.width, + required this.height, + required this.pixels, + }); + + final int width; + final int height; + final Uint8List pixels; + + double peakLuminance() { + var peak = 0.0; + for (var offset = 0; offset + 2 < pixels.length; offset += 4) { + final luminance = + (pixels[offset] + pixels[offset + 1] + pixels[offset + 2]) / + (3 * 255); + if (luminance > peak) peak = luminance; + } + return peak; + } + + /// Peak luminance in a vertical slice, as a fraction of the surface width. + double peakLuminanceInBand(double start, double end) { + final from = (start * width).round().clamp(0, width); + final to = (end * width).round().clamp(0, width); + var peak = 0.0; + for (var y = 0; y < height; y += 1) { + for (var x = from; x < to; x += 1) { + final offset = (y * width + x) * 4; + if (offset + 2 >= pixels.length) continue; + final luminance = + (pixels[offset] + pixels[offset + 1] + pixels[offset + 2]) / + (3 * 255); + if (luminance > peak) peak = luminance; + } + } + return peak; + } + + double changedFraction(_Frame other) { + if (other.pixels.length != pixels.length) return 1; + var counted = 0; + var changed = 0; + for (var offset = 0; offset + 2 < pixels.length; offset += 4) { + counted += 1; + final delta = + (pixels[offset] - other.pixels[offset]).abs() + + (pixels[offset + 1] - other.pixels[offset + 1]).abs() + + (pixels[offset + 2] - other.pixels[offset + 2]).abs(); + if (delta > 24) changed += 1; + } + return counted == 0 ? 0 : changed / counted; + } +} diff --git a/examples/larimar_flutter/lib/hero.dart b/examples/larimar_flutter/lib/hero.dart new file mode 100644 index 0000000..6e25243 --- /dev/null +++ b/examples/larimar_flutter/lib/hero.dart @@ -0,0 +1,441 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:larimar/larimar.dart'; + +const _modelAsset = 'assets/larimar-model.glb'; +const _audioAsset = 'assets/larimar-official.mp3'; +const _soundDuration = Duration(microseconds: 4544313); + +void main() => runApp(const LarimarBrandApp()); + +class LarimarBrandApp extends StatelessWidget { + const LarimarBrandApp({super.key, this.testMode = false}); + + final bool testMode; + + @override + Widget build(BuildContext context) => MaterialApp( + debugShowCheckedModeBanner: false, + title: 'larimar', + theme: ThemeData( + brightness: Brightness.dark, + fontFamily: 'HarmonyOS Sans', + scaffoldBackgroundColor: const Color(0xff03070b), + colorScheme: const ColorScheme.dark( + primary: Color(0xff71e6e4), + surface: Color(0xff07131a), + ), + ), + home: _LarimarBrandHome(testMode: testMode), + ); +} + +class _LarimarBrandHome extends StatefulWidget { + const _LarimarBrandHome({required this.testMode}); + + final bool testMode; + + @override + State<_LarimarBrandHome> createState() => _LarimarBrandHomeState(); +} + +class _LarimarBrandHomeState extends State<_LarimarBrandHome> + with SingleTickerProviderStateMixin { + late final AnimationController _sequence; + final AudioPlayer _audio = AudioPlayer(playerId: 'larimar-brand'); + _LarimarHeroSession? _session; + Directory? _soundtrackDirectory; + Object? _error; + bool _muted = false; + + @override + void initState() { + super.initState(); + _sequence = AnimationController(vsync: this, duration: _soundDuration); + if (widget.testMode) { + _sequence.value = 0.48; + } else { + unawaited(_initialize()); + } + } + + Future _initialize() async { + try { + final session = await _LarimarHeroSession.create(); + if (!mounted) { + session.dispose(); + return; + } + setState(() => _session = session); + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + + final soundtrack = await _stageSoundtrack(); + await _audio.setReleaseMode(ReleaseMode.loop); + await _audio.setVolume(1); + await _audio.play(DeviceFileSource(soundtrack.path)); + _sequence.repeat(period: _soundDuration); + } on Object catch (error) { + if (mounted) setState(() => _error = error); + } + } + + Future _stageSoundtrack() async { + final data = await rootBundle.load(_audioAsset); + final directory = await Directory.systemTemp.createTemp('larimar-audio-'); + final file = File('${directory.path}/larimar-official.mp3'); + await file.writeAsBytes( + data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes), + flush: true, + ); + _soundtrackDirectory = directory; + return file; + } + + Future _toggleMute() async { + final muted = !_muted; + setState(() => _muted = muted); + await _audio.setVolume(muted ? 0 : 1); + } + + @override + void dispose() { + _sequence.dispose(); + unawaited(_audio.dispose()); + final soundtrackDirectory = _soundtrackDirectory; + if (soundtrackDirectory != null && soundtrackDirectory.existsSync()) { + soundtrackDirectory.deleteSync(recursive: true); + } + _session?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + body: AnimatedBuilder( + animation: _sequence, + builder: (context, _) => _LarimarBackdrop( + phase: _sequence.value, + child: SafeArea( + child: Stack( + fit: StackFit.expand, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 54, + vertical: 40, + ), + child: LayoutBuilder( + builder: (context, constraints) => + _brandLockup(vertical: constraints.maxWidth < 600), + ), + ), + Positioned( + right: 24, + bottom: 22, + child: _SoundButton(muted: _muted, onPressed: _toggleMute), + ), + if (_error case final error?) + Positioned( + left: 24, + right: 24, + bottom: 24, + child: _ErrorBanner(error: error), + ), + ], + ), + ), + ), + ), + ); + + Widget _brandLockup({required bool vertical}) { + final session = _session; + final model = Semantics( + label: 'Larimar logo rotating gently', + image: true, + child: SizedBox.square( + dimension: vertical ? 300 : 360, + child: session == null + ? Center( + child: SizedBox.square( + dimension: 22, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: Colors.white.withValues(alpha: 0.52), + ), + ), + ) + : EshiView( + world: session.world, + onFrame: session.frame, + onError: (error) { + if (mounted) setState(() => _error = error); + }, + placeholder: const SizedBox.expand(), + ), + ), + ); + const wordmark = _Wordmark(); + + if (vertical) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + model, + Transform.translate(offset: const Offset(0, -26), child: wordmark), + ], + ); + } + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible(child: model), + const SizedBox(width: 8), + wordmark, + ], + ); + } +} + +final class _LarimarHeroSession { + _LarimarHeroSession._({required this.world, required this.asset}); + + final LarimarWorld world; + final LarimarAsset asset; + + static Future<_LarimarHeroSession> create() async { + final world = LarimarWorld( + config: const LarimarWorldConfig( + width: 720, + height: 720, + grade: LarimarGrade.brush, + ), + ); + try { + final asset = await world.loadAssetBundle(_modelAsset); + asset.instantiate(scale: 0.86, spinY: 0.32); + return _LarimarHeroSession._(world: world, asset: asset); + } on Object { + world.dispose(); + rethrow; + } + } + + void frame(LarimarWorld world, Duration elapsed, Duration delta) { + final seconds = delta.inMicroseconds / Duration.microsecondsPerSecond; + world.tick(seconds.clamp(0, 0.1)); + } + + void dispose() { + asset.dispose(); + world.dispose(); + } +} + +class _Wordmark extends StatelessWidget { + const _Wordmark(); + + @override + Widget build(BuildContext context) => Semantics( + header: true, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'larimar', + key: const ValueKey('larimar-wordmark'), + style: const TextStyle( + color: Color(0xfff5fbfb), + fontSize: 92, + height: 0.94, + fontWeight: FontWeight.w300, + letterSpacing: -4.8, + ), + ), + const SizedBox(height: 18), + Text( + 'A GAME ENGINE IN MOTION', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.54), + fontSize: 11, + height: 1, + fontWeight: FontWeight.w500, + letterSpacing: 3.35, + ), + ), + ], + ), + ); +} + +class _LarimarBackdrop extends StatelessWidget { + const _LarimarBackdrop({required this.phase, required this.child}); + + final double phase; + final Widget child; + + @override + Widget build(BuildContext context) { + final story = _GradientStory.at(phase); + final drift = math.sin(phase * math.pi * 2); + return DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment(-0.95 + drift * 0.08, -0.9), + end: Alignment(0.82, 0.9 - drift * 0.06), + colors: story.base, + stops: const [0, 0.37, 0.72, 1], + ), + ), + child: Stack( + fit: StackFit.expand, + children: [ + DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + center: Alignment(-0.82 + phase * 1.58, -0.46 + drift * 0.16), + radius: 0.62 + story.energy * 0.28, + colors: [ + story.glow.withValues(alpha: 0.28 * story.energy), + story.glow.withValues(alpha: 0), + ], + ), + ), + ), + DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + center: Alignment(0.72 - phase * 0.62, 0.78), + radius: 0.82, + colors: [ + story.echo.withValues(alpha: 0.2 * story.energy), + story.echo.withValues(alpha: 0), + ], + ), + ), + ), + child, + ], + ), + ); + } +} + +class _GradientStory { + const _GradientStory({ + required this.base, + required this.glow, + required this.echo, + required this.energy, + }); + + final List base; + final Color glow; + final Color echo; + final double energy; + + static const _quiet = [ + Color(0xff02060a), + Color(0xff07141b), + Color(0xff071019), + Color(0xff020508), + ]; + static const _rise = [ + Color(0xff031018), + Color(0xff063641), + Color(0xff12303f), + Color(0xff050712), + ]; + static const _shimmer = [ + Color(0xff04151d), + Color(0xff0b5360), + Color(0xff292453), + Color(0xff090713), + ]; + + factory _GradientStory.at(double phase) { + if (phase < 0.47) { + final t = Curves.easeInOutCubic.transform(phase / 0.47); + return _GradientStory( + base: _lerpColors(_quiet, _rise, t), + glow: Color.lerp(const Color(0xff1e6d78), const Color(0xff86f7ee), t)!, + echo: const Color(0xff5c67c8), + energy: 0.25 + t * 0.75, + ); + } + if (phase < 0.74) { + final t = Curves.easeOutCubic.transform((phase - 0.47) / 0.27); + return _GradientStory( + base: _lerpColors(_rise, _shimmer, t), + glow: Color.lerp(const Color(0xff86f7ee), const Color(0xffacdfff), t)!, + echo: Color.lerp(const Color(0xff5967d7), const Color(0xff9d72de), t)!, + energy: 1, + ); + } + final t = Curves.easeInCubic.transform((phase - 0.74) / 0.26); + return _GradientStory( + base: _lerpColors(_shimmer, _quiet, t), + glow: Color.lerp(const Color(0xffacdfff), const Color(0xff1e6d78), t)!, + echo: Color.lerp(const Color(0xff9d72de), const Color(0xff293057), t)!, + energy: 1 - t * 0.75, + ); + } + + static List _lerpColors(List a, List b, double t) => + List.generate( + a.length, + (index) => Color.lerp(a[index], b[index], t)!, + ); +} + +class _SoundButton extends StatelessWidget { + const _SoundButton({required this.muted, required this.onPressed}); + + final bool muted; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) => Semantics( + button: true, + label: muted ? 'Unmute soundtrack' : 'Mute soundtrack', + child: IconButton( + onPressed: onPressed, + tooltip: muted ? 'Unmute' : 'Mute', + color: Colors.white.withValues(alpha: 0.62), + icon: Icon(muted ? Icons.volume_off_outlined : Icons.volume_up_outlined), + ), + ); +} + +class _ErrorBanner extends StatelessWidget { + const _ErrorBanner({required this.error}); + + final Object error; + + @override + Widget build(BuildContext context) => Align( + alignment: Alignment.bottomCenter, + child: DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xe61b1016), + border: Border.all(color: const Color(0x55ff8da1)), + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Text( + error.toString(), + key: const ValueKey('larimar-hero-error'), + style: const TextStyle(fontSize: 12, color: Color(0xffffc6cf)), + ), + ), + ), + ); +} diff --git a/examples/larimar_flutter/lib/main.dart b/examples/larimar_flutter/lib/main.dart index fcf43e8..8c9261c 100644 --- a/examples/larimar_flutter/lib/main.dart +++ b/examples/larimar_flutter/lib/main.dart @@ -1,15 +1,33 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:larimar/larimar.dart'; void main() => runApp(const LarimarApp()); class LarimarApp extends StatelessWidget { - const LarimarApp({super.key, this.testStatus}); + const LarimarApp({ + super.key, + this.testStatus, + this.testViewSize, + this.testWinningScore, + }); /// Lets widget tests exercise Flutter composition without creating native /// state. The production application always leaves this null. final String? testStatus; + /// Constrains the native view without resizing the host window in integration + /// tests. Production always leaves this null. + final Size? testViewSize; + + /// Shortens a match so a test can watch one finish. Production leaves it null + /// and plays to [_PongSession.winningScore]. + final int? testWinningScore; + @override Widget build(BuildContext context) => MaterialApp( title: 'Larimar First Light', @@ -19,93 +37,516 @@ class LarimarApp extends StatelessWidget { brightness: Brightness.dark, ), ), - home: LarimarHome(testStatus: testStatus), + home: LarimarHome( + testStatus: testStatus, + testViewSize: testViewSize, + testWinningScore: testWinningScore, + ), ); } class LarimarHome extends StatefulWidget { - const LarimarHome({super.key, this.testStatus}); + const LarimarHome({ + super.key, + this.testStatus, + this.testViewSize, + this.testWinningScore, + }); final String? testStatus; + final Size? testViewSize; + final int? testWinningScore; @override State createState() => _LarimarHomeState(); } class _LarimarHomeState extends State { - LarimarWorld? _world; - late String _status; + _PongSession? _pong; + Object? _error; + EshiViewDiagnostics? _diagnostics; + Timer? _diagnosticsTimer; + final FocusNode _keyboard = FocusNode(debugLabel: 'larimar-pong'); @override void initState() { super.initState(); - if (widget.testStatus case final status?) { - _status = status; - return; + if (widget.testStatus == null) { + _initialize(); + // On screen, "it renders" and "it is composited" are different claims, + // and only the second one is what a person is looking at. Poll the host + // so the window says which is true: frames presented, and frames the + // engine actually borrowed to draw. + _diagnosticsTimer = Timer.periodic( + const Duration(milliseconds: 500), + (_) => _refreshDiagnostics(), + ); + } + } + + Future _refreshDiagnostics() async { + try { + final diagnostics = await const MacOSEshiViewHost().diagnostics(); + if (mounted) setState(() => _diagnostics = diagnostics); + } on Object { + // A host that cannot answer is not worth turning into an error banner + // over the running game; the counters simply stop updating. + } + } + + Future _initialize() async { + try { + final session = await _PongSession.create( + target: widget.testWinningScore ?? _PongSession.winningScore, + ); + if (!mounted) { + session.dispose(); + return; + } + setState(() => _pong = session); + } on Object catch (error) { + if (mounted) setState(() => _error = error); } - final world = _world = LarimarWorld(); - world.scene() - ..node(1) - ..transform(x: 0, y: 0) - ..finish(); - _status = _describe(world); } @override void dispose() { - _world?.dispose(); + _keyboard.dispose(); + _diagnosticsTimer?.cancel(); + _pong?.dispose(); super.dispose(); } @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('Larimar First Light')), - body: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560), - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Icon(Icons.blur_on, size: 72), - const SizedBox(height: 24), - Text( - 'Native core online', - style: Theme.of(context).textTheme.headlineMedium, - textAlign: TextAlign.center, + Widget build(BuildContext context) { + if (widget.testStatus case final status?) { + return _testShell(context, status); + } + final pong = _pong; + final gameView = pong == null + ? const ColoredBox(color: Color(0xff05070d)) + // The material's horizontal extent is the view's aspect ratio, so the + // game has to be told the shape of the window it is being drawn into or + // its paddles end up outside it. + : LayoutBuilder( + builder: (context, constraints) { + if (constraints.hasBoundedWidth && constraints.maxHeight > 0) { + pong.aspect = constraints.maxWidth / constraints.maxHeight; + } + return EshiView( + world: pong.world, + onFrame: pong.frame, + onError: (error) { + if (mounted) setState(() => _error = error); + }, + placeholder: const ColoredBox(color: Color(0xff05070d)), + ); + }, + ); + return Scaffold( + body: Focus( + focusNode: _keyboard, + autofocus: true, + onKeyEvent: _handleKey, + child: Stack( + fit: StackFit.expand, + children: [ + if (widget.testViewSize case final size?) ...[ + const ColoredBox(color: Color(0xff05070d)), + Center( + child: SizedBox( + width: size.width, + height: size.height, + child: gameView, + ), ), - const SizedBox(height: 12), - Text(_status, textAlign: TextAlign.center), - const SizedBox(height: 24), - const Text( - 'The EshiView external texture lands in Plan Step 4. This ' - 'reference shell already proves ABI loading, retained-scene ' - 'flushes, lifecycle ownership, and Flutter composition.', - textAlign: TextAlign.center, + ] else + gameView, + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.center, + colors: [Color(0xaa05070d), Colors.transparent], + ), ), - const SizedBox(height: 24), - FilledButton.icon( - onPressed: _world == null - ? null - : () { - setState(() { - _world!.tick(); - _status = _describe(_world!); - }); - }, - icon: const Icon(Icons.skip_next), - label: const Text('Advance one simulation tick'), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Larimar First Light', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 6), + Text( + pong == null + ? 'Preparing the Metal texture host…' + : '${pong.world.backendName} · external texture · ' + '${pong.world.entityCount} entities', + ), + if (_diagnostics case final diagnostics?) + Text( + 'presented ${diagnostics.presented} · ' + 'composited ${diagnostics.copies}', + key: const ValueKey('larimar-diagnostics'), + style: Theme.of(context).textTheme.bodySmall, + ), + if (_error case final error?) ...[ + const SizedBox(height: 12), + DecoratedBox( + key: const ValueKey('larimar-error'), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Text(error.toString()), + ), + ), + ], + const Spacer(), + Row( + children: [ + Text( + 'W / S move cyan · ↑ / ↓ move magenta · ' + 'first to ${_PongSession.winningScore} wins', + style: Theme.of(context).textTheme.bodySmall, + ), + const Spacer(), + const Chip( + avatar: Icon(Icons.layers_outlined, size: 18), + label: Text('Flutter UI over animated Pong'), + ), + ], + ), + ], + ), ), - ], - ), + ), + if (pong != null) + ValueListenableBuilder( + valueListenable: pong.winner, + builder: (context, winner, _) => winner == null + ? const SizedBox.shrink() + : Center( + child: Text( + '$winner wins', + key: const ValueKey('larimar-winner'), + style: Theme.of(context).textTheme.displaySmall, + ), + ), + ), + if (pong == null && _error == null) + const Center(child: CircularProgressIndicator()), + ], ), ), + ); + } + + /// Held keys set a direction; the game moves the paddle on its own clock. + /// + /// The result matters as much as the effect: an unhandled key walks up the + /// responder chain to AppKit, which beeps at it. Every key this game uses has + /// to be claimed, or playing it sounds like an error. + KeyEventResult _handleKey(FocusNode node, KeyEvent event) { + final pong = _pong; + if (pong == null) return KeyEventResult.ignored; + + final key = event.logicalKey; + final ({_Paddle paddle, double direction})? command = switch (key) { + LogicalKeyboardKey.keyW => (paddle: _Paddle.left, direction: 1), + LogicalKeyboardKey.keyS => (paddle: _Paddle.left, direction: -1), + LogicalKeyboardKey.arrowUp => (paddle: _Paddle.right, direction: 1), + LogicalKeyboardKey.arrowDown => (paddle: _Paddle.right, direction: -1), + _ => null, + }; + if (command == null) return KeyEventResult.ignored; + + pong.steer(command.paddle, event is KeyUpEvent ? 0 : command.direction); + return KeyEventResult.handled; + } + + Widget _testShell(BuildContext context, String status) => Scaffold( + appBar: AppBar(title: const Text('Larimar First Light')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Native core online'), + Text(status), + FilledButton( + onPressed: null, + child: const Text('Advance one simulation tick'), + ), + ], + ), ), ); +} - String _describe(LarimarWorld world) => - '${world.backendName} · ${world.entityCount} entity · ' - 'frame ${world.frameIndex}'; +enum _Paddle { left, right } + +/// One player's paddle: an idle sweep until a key claims it, then a position. +final class _Side { + double input = 0; + bool manual = false; + double _y = 0; + + double advance(double seconds, double limit, double idle) { + _y = manual + ? (_y + input * _PongSession._paddleSpeed * seconds).clamp( + -limit, + limit, + ) + : idle; + return _y; + } +} + +final class _PongSession { + _PongSession._({ + required this.world, + required this.material, + required this.shaderFile, + required this.leftPaddle, + required this.rightPaddle, + required this.ball, + required int target, + }) : _target = target; + + /// The arena's half-width at 16:9, and the width the walls were built for. + static const _arenaX = 1.77; + static const _arenaY = 1.0; + static const _paddleHalfHeight = 0.2; + static const _ballRadius = 0.03; + static const _paddleSpeed = 2.2; + + final LarimarWorld world; + final LarimarMaterialBuffer material; + final File shaderFile; + final int leftPaddle; + final int rightPaddle; + final int ball; + + /// The score that ends this match; production plays to [winningScore]. + final int _target; + double _hit = 0; + int _scoreLeft = 0; + int _scoreRight = 0; + + /// Half-width of the visible arena, in the shader's coordinates. + /// + /// The material works in `uv = (fragCoord * 2 - iResolution) / iResolution.y`, + /// so the horizontal extent is the view's aspect ratio and *only* the vertical + /// one is fixed. Pinning the paddles to a 16:9 constant put them off screen in + /// any window narrower than that — which is every default window, since + /// Flutter opens at 4:3. The view tells the session its aspect instead. + double _halfWidth = _arenaX; + + /// The score that ends a match, and the number of dots the material draws. + static const winningScore = 9; + + final _Side _left = _Side(); + final _Side _right = _Side(); + + /// The winner of the last completed match, while it is being announced. + final ValueNotifier winner = ValueNotifier(null); + double _celebration = 0; + + set aspect(double value) { + if (value.isFinite && value > 0.5) _halfWidth = value; + } + + /// A held key becomes a direction; the first one hands that paddle over to + /// the player for good, so it does not snap back to its idle sweep. + void steer(_Paddle paddle, double direction) { + final side = paddle == _Paddle.left ? _left : _right; + side.input = direction; + if (direction != 0) side.manual = true; + } + + static Future<_PongSession> create({int target = winningScore}) async { + final source = await rootBundle.loadString( + 'packages/larimar/assets/pong.gpu.cpp', + ); + final file = File( + '${Directory.systemTemp.path}/larimar-pong-$pid-${DateTime.now().microsecondsSinceEpoch}.gpu.cpp', + ); + await file.writeAsString(source, flush: true); + + LarimarWorld? world; + try { + world = LarimarWorld( + config: const LarimarWorldConfig( + width: 960, + height: 540, + grade: LarimarGrade.brush, + ), + ); + world.scene(minimumWordCapacity: 128) + ..node(1) + ..transform(x: -_arenaX + 0.1, y: 0) + ..collider( + halfWidth: 0.02, + halfHeight: _paddleHalfHeight, + layer: 2, + mask: 1, + flags: LarimarColliderFlags.staticBody, + ) + ..node(2) + ..transform(x: _arenaX - 0.1, y: 0) + ..collider( + halfWidth: 0.02, + halfHeight: _paddleHalfHeight, + layer: 2, + mask: 1, + flags: LarimarColliderFlags.staticBody, + ) + ..node(3) + ..transform(x: 0, y: 0) + ..velocity(x: 1.1, y: 0.27) + ..collider( + halfWidth: _ballRadius, + halfHeight: _ballRadius, + layer: 1, + mask: 2 | 4, + ) + ..restitution(1) + ..node(4) + ..transform(x: 0, y: _arenaY + 0.1) + ..collider( + halfWidth: _arenaX + 1, + halfHeight: 0.1, + layer: 4, + mask: 1, + flags: LarimarColliderFlags.staticBody, + ) + ..node(5) + ..transform(x: 0, y: -_arenaY - 0.1) + ..collider( + halfWidth: _arenaX + 1, + halfHeight: 0.1, + layer: 4, + mask: 1, + flags: LarimarColliderFlags.staticBody, + ) + ..finish(); + + final material = world.bindShaderMaterial( + sourcePath: file.path, + uniformFloatCount: 9, + ); + final session = _PongSession._( + target: target, + world: world, + material: material, + shaderFile: file, + leftPaddle: world.sceneEntity(1), + rightPaddle: world.sceneEntity(2), + ball: world.sceneEntity(3), + ); + session._updateUniforms(); + return session; + } on Object { + world?.dispose(); + if (await file.exists()) await file.delete(); + rethrow; + } + } + + void frame(LarimarWorld world, Duration elapsed, Duration delta) { + final seconds = (delta.inMicroseconds / Duration.microsecondsPerSecond) + .clamp(0.0, 0.1); + final time = elapsed.inMicroseconds / Duration.microsecondsPerSecond; + + // Each paddle plays itself until somebody takes it over, and then stays + // where they left it. + final limit = _arenaY - _paddleHalfHeight; + world.setTransform( + leftPaddle, + x: -_halfWidth + 0.1, + y: _left.advance(seconds, limit, math.sin(time * 1.2) * 0.55), + ); + world.setTransform( + rightPaddle, + x: _halfWidth - 0.1, + y: _right.advance(seconds, limit, math.sin(time * 0.9 + 1.8) * 0.55), + ); + world.tick(seconds); + + if (_celebration > 0) { + // The match is over; hold the ball at centre until the announcement ends. + _celebration -= seconds; + if (_celebration <= 0) { + _scoreLeft = 0; + _scoreRight = 0; + winner.value = null; + _serve(1); + } + } else { + final position = world.transformOf(ball); + if (position.x > _halfWidth + 0.2) { + _scoreLeft += 1; + _serve(-1); + } else if (position.x < -_halfWidth - 0.2) { + _scoreRight += 1; + _serve(1); + } + // A game with no end is a demo that scores past the nine dots the + // material can draw and then keeps going, which is what this did. + if (_scoreLeft >= _target || _scoreRight >= _target) { + winner.value = _scoreLeft > _scoreRight ? 'Cyan' : 'Magenta'; + _celebration = 2.5; + world.setTransform(ball, x: 0, y: 0); + world.setVelocity(ball, x: 0, y: 0); + } + } + final collisions = world.events().drain().events; + if (collisions.any( + (event) => + event is LarimarCollisionEvent && + (event.a == leftPaddle || + event.b == leftPaddle || + event.a == rightPaddle || + event.b == rightPaddle), + )) { + _hit = 1; + } + _hit *= math.pow(0.05, seconds).toDouble(); + _updateUniforms(); + } + + void _serve(double direction) { + world.setTransform(ball, x: 0, y: 0); + world.setVelocity(ball, x: direction * 1.1, y: 0.31); + } + + void _updateUniforms() { + material.requireActive(); + final left = world.transformOf(leftPaddle); + final right = world.transformOf(rightPaddle); + final ballPosition = world.transformOf(ball); + material.values + ..[0] = left.x + ..[1] = left.y + ..[2] = right.x + ..[3] = right.y + ..[4] = ballPosition.x + ..[5] = ballPosition.y + ..[6] = _hit + ..[7] = _scoreLeft.toDouble() + ..[8] = _scoreRight.toDouble(); + } + + void dispose() { + winner.dispose(); + world.dispose(); + if (shaderFile.existsSync()) shaderFile.deleteSync(); + } } diff --git a/examples/larimar_flutter/linux/flutter/generated_plugin_registrant.cc b/examples/larimar_flutter/linux/flutter/generated_plugin_registrant.cc index e71a16d..1830e5c 100644 --- a/examples/larimar_flutter/linux/flutter/generated_plugin_registrant.cc +++ b/examples/larimar_flutter/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); + audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); } diff --git a/examples/larimar_flutter/linux/flutter/generated_plugins.cmake b/examples/larimar_flutter/linux/flutter/generated_plugins.cmake index 2e1de87..f4bf1dc 100644 --- a/examples/larimar_flutter/linux/flutter/generated_plugins.cmake +++ b/examples/larimar_flutter/linux/flutter/generated_plugins.cmake @@ -3,9 +3,11 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/examples/larimar_flutter/macos/Flutter/Flutter-Debug.xcconfig b/examples/larimar_flutter/macos/Flutter/Flutter-Debug.xcconfig index c2efd0b..4b81f9b 100644 --- a/examples/larimar_flutter/macos/Flutter/Flutter-Debug.xcconfig +++ b/examples/larimar_flutter/macos/Flutter/Flutter-Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/examples/larimar_flutter/macos/Flutter/Flutter-Release.xcconfig b/examples/larimar_flutter/macos/Flutter/Flutter-Release.xcconfig index c2efd0b..5caa9d1 100644 --- a/examples/larimar_flutter/macos/Flutter/Flutter-Release.xcconfig +++ b/examples/larimar_flutter/macos/Flutter/Flutter-Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/examples/larimar_flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/examples/larimar_flutter/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..c867bdd 100644 --- a/examples/larimar_flutter/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/examples/larimar_flutter/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,10 @@ import FlutterMacOS import Foundation +import audioplayers_darwin +import larimar func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) + LarimarPlugin.register(with: registry.registrar(forPlugin: "LarimarPlugin")) } diff --git a/examples/larimar_flutter/macos/Podfile b/examples/larimar_flutter/macos/Podfile new file mode 100644 index 0000000..167132a --- /dev/null +++ b/examples/larimar_flutter/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '12.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/examples/larimar_flutter/macos/Podfile.lock b/examples/larimar_flutter/macos/Podfile.lock new file mode 100644 index 0000000..f25f33f --- /dev/null +++ b/examples/larimar_flutter/macos/Podfile.lock @@ -0,0 +1,29 @@ +PODS: + - audioplayers_darwin (0.0.1): + - Flutter + - FlutterMacOS + - FlutterMacOS (1.0.0) + - larimar (0.1.0-dev.2): + - FlutterMacOS + +DEPENDENCIES: + - audioplayers_darwin (from `Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin`) + - FlutterMacOS (from `Flutter/ephemeral`) + - larimar (from `Flutter/ephemeral/.symlinks/plugins/larimar/macos`) + +EXTERNAL SOURCES: + audioplayers_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin + FlutterMacOS: + :path: Flutter/ephemeral + larimar: + :path: Flutter/ephemeral/.symlinks/plugins/larimar/macos + +SPEC CHECKSUMS: + audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 + FlutterMacOS: c232990155153907050900a2e175c7773903ba4e + larimar: 29dda0a372787042d5fb8bc4b08c5e9a99c0699c + +PODFILE CHECKSUM: 1e95c36afbfd1cb6423ceca4de7a8e1b256fb6ac + +COCOAPODS: 1.17.0 diff --git a/examples/larimar_flutter/macos/Runner.xcodeproj/project.pbxproj b/examples/larimar_flutter/macos/Runner.xcodeproj/project.pbxproj index b88755d..7729565 100644 --- a/examples/larimar_flutter/macos/Runner.xcodeproj/project.pbxproj +++ b/examples/larimar_flutter/macos/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 456E96F8992A8E8AC7A97EB8 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 87CAECD6D26197A85421D23B /* Pods_RunnerTests.framework */; }; + C82C225774F0326F1D697861 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8A14C318A7D7660FE29AC186 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,11 +62,14 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 003F361F9C5CFB913AC326D5 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 0FD2E03A11E773D0A21175D7 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 242B6FC2DED80E5942A26D45 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* larimar_flutter.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "larimar_flutter.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* larimar_flutter.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = larimar_flutter.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -76,8 +81,13 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 692A7E895FAF2D990CF4CAD4 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 87CAECD6D26197A85421D23B /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 8A14C318A7D7660FE29AC186 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + BA7AAAB5681C90EDCA07883E /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + E3A77294C6B6E1F83E9C2B69 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 456E96F8992A8E8AC7A97EB8 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,6 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C82C225774F0326F1D697861 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -125,6 +137,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + E22072037FE78F2162B7C902 /* Pods */, ); sourceTree = ""; }; @@ -175,10 +188,26 @@ D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 8A14C318A7D7660FE29AC186 /* Pods_Runner.framework */, + 87CAECD6D26197A85421D23B /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; }; + E22072037FE78F2162B7C902 /* Pods */ = { + isa = PBXGroup; + children = ( + 003F361F9C5CFB913AC326D5 /* Pods-Runner.debug.xcconfig */, + 0FD2E03A11E773D0A21175D7 /* Pods-Runner.release.xcconfig */, + 242B6FC2DED80E5942A26D45 /* Pods-Runner.profile.xcconfig */, + E3A77294C6B6E1F83E9C2B69 /* Pods-RunnerTests.debug.xcconfig */, + 692A7E895FAF2D990CF4CAD4 /* Pods-RunnerTests.release.xcconfig */, + BA7AAAB5681C90EDCA07883E /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -186,6 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 96F7CFC41EE83E885E547121 /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + DA3638B5BE7752DB6B35BA20 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + F5F0880128320C29FA89F518 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -329,6 +361,67 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 96F7CFC41EE83E885E547121 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + DA3638B5BE7752DB6B35BA20 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + F5F0880128320C29FA89F518 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -380,6 +473,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = E3A77294C6B6E1F83E9C2B69 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -394,6 +488,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 692A7E895FAF2D990CF4CAD4 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -408,6 +503,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = BA7AAAB5681C90EDCA07883E /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -461,7 +557,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -543,7 +639,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -593,7 +689,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/examples/larimar_flutter/macos/Runner.xcworkspace/contents.xcworkspacedata b/examples/larimar_flutter/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/examples/larimar_flutter/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/examples/larimar_flutter/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/examples/larimar_flutter/pubspec.lock b/examples/larimar_flutter/pubspec.lock index 3d332d7..59cbeec 100644 --- a/examples/larimar_flutter/pubspec.lock +++ b/examples/larimar_flutter/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -9,6 +17,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "2ba4bb2944baacbdd5372ff8254a8e7feb8c10d7739545e392f5605a8f618745" + url: "https://pub.dev" + source: hosted + version: "6.8.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: f5ff5b15620fbab8cb0849e9636c48e2b96c3f0f71723bbbe2ad3c761b205f05 + url: "https://pub.dev" + source: hosted + version: "5.3.0" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "1ca553add991384ecf421b9569da850f3ab2472ffb83f6970b0416365abc51be" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: "15178b726b7cdee5364d0463c8d445630c4e0fb7d26612b73c767e7d25de9417" + url: "https://pub.dev" + source: hosted + version: "4.3.0" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "765f6f0e6dca55cb471c9483fc77700564b3484d19198aca4ebb5147c6c85acb" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: ae1e0103c865a03e273f6d13d97b93f5595eac09915729cd5e37ef96e2857319 + url: "https://pub.dev" + source: hosted + version: "5.3.0" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: a70ae82bba2dfcb6eb03dd4815d737a2d46d33ea5a96a03f535cfcaac490e413 + url: "https://pub.dev" + source: hosted + version: "4.4.1" boolean_selector: dependency: transitive description: @@ -21,10 +85,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -81,11 +145,24 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -99,6 +176,16 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -115,13 +202,58 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" larimar: dependency: "direct main" description: path: "../../bindings/dart/larimar" relative: true source: path - version: "0.1.0-dev.1" + version: "0.1.0-dev.2" leak_tracker: dependency: transitive description: @@ -166,26 +298,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.20" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.19.0" native_toolchain_c: dependency: transitive description: @@ -194,6 +326,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" path: dependency: transitive description: @@ -202,6 +350,78 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" pub_semver: dependency: transitive description: @@ -255,6 +475,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931" + url: "https://pub.dev" + source: hosted + version: "3.4.1+2" term_glyph: dependency: transitive description: @@ -267,10 +503,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.12" typed_data: dependency: transitive description: @@ -279,14 +515,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" vector_math: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: @@ -295,6 +539,30 @@ packages: url: "https://pub.dev" source: hosted version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" yaml: dependency: transitive description: @@ -304,5 +572,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.8 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/examples/larimar_flutter/pubspec.yaml b/examples/larimar_flutter/pubspec.yaml index 0b98137..8f2bd61 100644 --- a/examples/larimar_flutter/pubspec.yaml +++ b/examples/larimar_flutter/pubspec.yaml @@ -28,6 +28,7 @@ environment: # the latest version available on pub.dev. To see which dependencies have newer # versions available, run `flutter pub outdated`. dependencies: + audioplayers: ^6.5.1 flutter: sdk: flutter @@ -37,6 +38,8 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is @@ -50,12 +53,30 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: + # Keep the reference app on CocoaPods so it builds on the pinned Flutter + # release as well as current Flutter. The plugin still publishes SwiftPM + # metadata for applications that opt into that integration. + config: + enable-swift-package-manager: false # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true + assets: + - assets/larimar-model.glb + - assets/larimar-official.mp3 + + fonts: + - family: HarmonyOS Sans + fonts: + - asset: assets/fonts/HarmonyOS_Sans_Regular.ttf + - asset: assets/fonts/HarmonyOS_Sans_Light.ttf + weight: 300 + - asset: assets/fonts/HarmonyOS_Sans_Medium.ttf + weight: 500 + # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg diff --git a/examples/larimar_flutter/test/hero_widget_test.dart b/examples/larimar_flutter/test/hero_widget_test.dart new file mode 100644 index 0000000..e533d0f --- /dev/null +++ b/examples/larimar_flutter/test/hero_widget_test.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:larimar_flutter/hero.dart'; + +void main() { + testWidgets('composes the HarmonyOS Sans Larimar lockup', (tester) async { + await tester.pumpWidget(const LarimarBrandApp(testMode: true)); + + expect(find.text('larimar'), findsOneWidget); + expect(find.text('A GAME ENGINE IN MOTION'), findsOneWidget); + expect(find.byTooltip('Mute'), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); +} diff --git a/examples/pong/pong.cpp b/examples/pong/pong.cpp index 48ef2a9..06fed12 100644 --- a/examples/pong/pong.cpp +++ b/examples/pong/pong.cpp @@ -254,7 +254,7 @@ void reload(EshiWorld* w, Game* g) { } } -void build(EshiWorld* w, Game* g) { +EshiResult build(EshiWorld* w, Game* g) { g->score_l = 0; g->score_r = 0; g->uniforms.hit_timer = 0.0f; @@ -292,7 +292,14 @@ void build(EshiWorld* w, Game* g) { #endif material.uniform_data = &g->uniforms; material.uniform_size = sizeof(g->uniforms); - eshi_material_set(w, &material); + + /* + * Returned rather than swallowed. A GPU tier that cannot find or compile + * this material leaves the world with no backend, and every subsequent + * frame is black — which previously reached the screen as a running game + * with an empty arena, and reached a script as exit code zero. + */ + return eshi_material_set(w, &material); } /* --------------------------------------------------------------------------- diff --git a/examples/pong/pong.h b/examples/pong/pong.h index 489bf1b..53a2c04 100644 --- a/examples/pong/pong.h +++ b/examples/pong/pong.h @@ -61,8 +61,13 @@ struct Game { Uniforms uniforms; }; -/** Submits the scene description, registers systems, binds the material. */ -void build(EshiWorld* w, Game* g); +/** + * Submits the scene description, registers systems, binds the material. + * + * Returns the material result: a host that ignores it will run a world with no + * backend and present black frames. + */ +EshiResult build(EshiWorld* w, Game* g); /** * Re-submits the scene description at a fresh epoch. diff --git a/examples/pong/pong.mat b/examples/pong/pong.mat deleted file mode 100644 index c65207e..0000000 --- a/examples/pong/pong.mat +++ /dev/null @@ -1,94 +0,0 @@ -material { - name : EshiGlowPong, - parameters : [ - { - type : float[64], - name : eshiUniforms - }, - { - type : float2, - name : eshiResolution - }, - { - type : float, - name : eshiTime - } - ], - variables : [ - eshiPosition - ], - vertexDomain : device, - depthWrite : false, - depthCulling : false, - culling : none, - shadingModel : unlit, - variantFilter : [ skinning, shadowReceiver, vsm ] -} - -vertex { - void materialVertex(inout MaterialVertexInputs material) { - material.eshiPosition.xy = getPosition().xy; - } -} - -fragment { - float sdBox(vec2 p, vec2 b) { - vec2 d = abs(p) - b; - return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0); - } - - float glow(float d, float intensity) { - return intensity / (d * d + 0.0001); - } - - void material(inout MaterialInputs material) { - prepareMaterial(material); - - const float paddleHalfW = 0.02; - const float paddleHalfH = 0.2; - const float ballRadius = 0.03; - const float arenaY = 1.0; - - vec2 paddleL = vec2(materialParams.eshiUniforms[0], - materialParams.eshiUniforms[1]); - vec2 paddleR = vec2(materialParams.eshiUniforms[2], - materialParams.eshiUniforms[3]); - vec2 ball = vec2(materialParams.eshiUniforms[4], - materialParams.eshiUniforms[5]); - float hitTimer = materialParams.eshiUniforms[6]; - float scoreL = materialParams.eshiUniforms[7]; - float scoreR = materialParams.eshiUniforms[8]; - - vec2 uv = variable_eshiPosition.xy; - uv.x *= materialParams.eshiResolution.x / materialParams.eshiResolution.y; - - float dLeft = sdBox(uv - paddleL, vec2(paddleHalfW, paddleHalfH)); - float dRight = sdBox(uv - paddleR, vec2(paddleHalfW, paddleHalfH)); - float dBall = length(uv - ball) - ballRadius; - - vec3 col = vec3(0.0); - col += vec3(0.2, 0.8, 1.0) * glow(dLeft, 0.002); - col += vec3(1.0, 0.2, 0.5) * glow(dRight, 0.002); - col += vec3(1.0) * glow(dBall, 0.005); - col += vec3(1.0) * hitTimer * 0.35 * exp(-length(uv - ball) * 2.0); - - if (abs(uv.x) < 0.004 && - fract(uv.y * 6.0 + materialParams.eshiTime * 0.1) < 0.5) { - col += vec3(0.18); - } - - for (int i = 0; i < 9; ++i) { - float fx = 0.08 * float(i + 1); - if (scoreL > float(i)) { - col += vec3(0.2, 0.8, 1.0) * - glow(length(uv - vec2(-fx, arenaY - 0.08)) - 0.012, 0.0004); - } - if (scoreR > float(i)) { - col += vec3(1.0, 0.2, 0.5) * - glow(length(uv - vec2(fx, arenaY - 0.08)) - 0.012, 0.0004); - } - } - - material.baseColor = vec4(col, 1.0); - } -} diff --git a/hosts/sdl/host_sdl.cpp b/hosts/sdl/host_sdl.cpp index 67abee6..5dbf6d7 100644 --- a/hosts/sdl/host_sdl.cpp +++ b/hosts/sdl/host_sdl.cpp @@ -131,6 +131,13 @@ void print_usage(const char* argv0) { " --reload N Re-submit the scene description every N frames, standing\n" " in for a Dart hot reload. Retained mode means this must\n" " not change the digest.\n" + " --asset FILE Load a glTF/GLB and render it instead of a 2D scene.\n" + " Brush grade with the Filament backend only; other tiers\n" + " report that they have no 3D scene rather than drawing\n" + " nothing.\n" + " --asset-instances N Copies of the asset to place. Zero loads it and\n" + " places none, which is how a test tells a drawn frame from\n" + " a cleared one. Default 1.\n" " --gallery Run the ripple gallery shader instead of pong. Required\n" " for the GPU tiers, which cannot execute pong's typed\n" " uniform struct.\n", @@ -147,6 +154,8 @@ int main(int argc, char** argv) { bool live = false; bool hash_only = false; bool gallery = false; + const char* asset_path = NULL; + int asset_instances = 1; std::string dump_dir; EncoderBackend encoder_backend = EncoderBackend::Auto; EshiGrade grade = ESHI_GRADE_INK; @@ -188,6 +197,10 @@ int main(int argc, char** argv) { } } else if (arg == "--gallery") { gallery = true; + } else if (arg == "--asset" && i + 1 < argc) { + asset_path = argv[++i]; + } else if (arg == "--asset-instances" && i + 1 < argc) { + asset_instances = std::atoi(argv[++i]); } else if (arg == "--grade" && i + 1 < argc) { bool ok = false; grade = parse_grade(argv[++i], &ok); @@ -254,21 +267,57 @@ int main(int argc, char** argv) { eshi_grade_string(eshi_world_grade(world)), eshi_world_backend_name(world), width, height, (unsigned long long)seed, - gallery ? "ripple" : "pong"); + asset_path ? asset_path : (gallery ? "ripple" : "pong")); pong::Game game; std::memset(&game, 0, sizeof(game)); - if (gallery) { + if (asset_path) { /* - * The same source file in both roles: compiled in for Ink, handed to - * the GPU tiers as a path they transpile at runtime. Comparing the two - * digests is the cross-tier conformance check. + * The 3D path: no fullscreen material at all. The world builds its + * backend when the asset arrives, and a tier without a scene says so + * instead of presenting an empty frame. + */ + EshiAsset asset = 0; + const EshiResult rc_asset = eshi_asset_load(world, asset_path, &asset); + if (rc_asset != ESHI_OK) { + std::fprintf(stderr, + "eshi_asset_load(%s) failed: %s\n" + " 3D assets need --grade brush in a build with -Dfilament=true.\n", + asset_path, eshi_result_string(rc_asset)); + eshi_world_destroy(world); + return 1; + } + for (int instance = 0; instance < asset_instances; ++instance) { + /* Spread copies along X so a second one is visibly a second one. */ + const float offset = (float)instance * 1.1f - (float)(asset_instances - 1) * 0.55f; + const EshiResult rc_instance = + eshi_asset_instance(world, asset, offset, 0.0f, 0.0f, 1.0f); + if (rc_instance != ESHI_OK) { + std::fprintf(stderr, "eshi_asset_instance failed: %s\n", + eshi_result_string(rc_instance)); + eshi_world_destroy(world); + return 1; + } + } + std::printf("[eshi/host] asset=%s instances=%d assets=%u\n", asset_path, + asset_instances, eshi_asset_count(world)); + } else if (gallery) { + /* + * The same source file in every role: compiled in for Ink, handed to + * the lightweight GPU tiers as a path they transpile at runtime, and + * emitted as a Filament material by eshi-matgen at build time. + * Comparing the resulting frames is the cross-tier conformance check — + * see scripts/check_tier_conformance.sh. */ EshiMaterial material; material.cpu_shader = eshi::shader_no_uniforms(); material.source_path = "examples/ripple.cpp"; +#ifdef ESHI_GALLERY_PACKAGE_PATH + material.package_path = ESHI_GALLERY_PACKAGE_PATH; +#else material.package_path = NULL; +#endif material.uniform_data = NULL; material.uniform_size = 0; @@ -280,7 +329,22 @@ int main(int argc, char** argv) { return 1; } } else { - pong::build(world, &game); + const EshiResult rc_material = pong::build(world, &game); + if (rc_material != ESHI_OK) { + /* + * The grade is named because that is the actionable part: the same + * binary at --grade ink would run. The most common cause by far is + * an installed binary that cannot find its shader source, so point + * at the override rather than making the reader guess. + */ + std::fprintf(stderr, + "pong: the %s tier could not bind its material: %s\n" + " The GPU tiers read examples/pong/pong.gpu.cpp at runtime; set\n" + " ESHI_SHADER_DIR to the directory holding it, or run --grade ink.\n", + eshi_world_backend_name(world), eshi_result_string(rc_material)); + eshi_world_destroy(world); + return 1; + } } int rc = 0; diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans/HarmonyOS_Sans_Black.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans/HarmonyOS_Sans_Black.ttf new file mode 100755 index 0000000..855943b Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans/HarmonyOS_Sans_Black.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans/HarmonyOS_Sans_Bold.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans/HarmonyOS_Sans_Bold.ttf new file mode 100755 index 0000000..8ea60c1 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans/HarmonyOS_Sans_Bold.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans/HarmonyOS_Sans_Light.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans/HarmonyOS_Sans_Light.ttf new file mode 100755 index 0000000..9b67785 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans/HarmonyOS_Sans_Light.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans/HarmonyOS_Sans_Medium.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans/HarmonyOS_Sans_Medium.ttf new file mode 100755 index 0000000..9bcfaa1 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans/HarmonyOS_Sans_Medium.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans/HarmonyOS_Sans_Regular.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans/HarmonyOS_Sans_Regular.ttf new file mode 100755 index 0000000..d7eccac Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans/HarmonyOS_Sans_Regular.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans/HarmonyOS_Sans_Thin.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans/HarmonyOS_Sans_Thin.ttf new file mode 100755 index 0000000..9b95ebd Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans/HarmonyOS_Sans_Thin.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans/LICENSE.txt b/resources/HarmonyOS Sans/HarmonyOS_Sans/LICENSE.txt new file mode 100644 index 0000000..211a701 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans/LICENSE.txt differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed/HarmonyOS_Sans_Condensed_Black.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed/HarmonyOS_Sans_Condensed_Black.ttf new file mode 100755 index 0000000..8557a9d Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed/HarmonyOS_Sans_Condensed_Black.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed/HarmonyOS_Sans_Condensed_Bold.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed/HarmonyOS_Sans_Condensed_Bold.ttf new file mode 100755 index 0000000..dc4a846 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed/HarmonyOS_Sans_Condensed_Bold.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed/HarmonyOS_Sans_Condensed_Light.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed/HarmonyOS_Sans_Condensed_Light.ttf new file mode 100755 index 0000000..80cd373 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed/HarmonyOS_Sans_Condensed_Light.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed/HarmonyOS_Sans_Condensed_Medium.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed/HarmonyOS_Sans_Condensed_Medium.ttf new file mode 100755 index 0000000..73d2223 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed/HarmonyOS_Sans_Condensed_Medium.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed/HarmonyOS_Sans_Condensed_Regular.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed/HarmonyOS_Sans_Condensed_Regular.ttf new file mode 100755 index 0000000..8f9f47b Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed/HarmonyOS_Sans_Condensed_Regular.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed/HarmonyOS_Sans_Condensed_Thin.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed/HarmonyOS_Sans_Condensed_Thin.ttf new file mode 100755 index 0000000..95c8688 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed/HarmonyOS_Sans_Condensed_Thin.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed/LICENSE.txt b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed/LICENSE.txt new file mode 100644 index 0000000..211a701 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed/LICENSE.txt differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed_Italic/HarmonyOS_Sans_Condensed_Black_Italic.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed_Italic/HarmonyOS_Sans_Condensed_Black_Italic.ttf new file mode 100755 index 0000000..6d678f4 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed_Italic/HarmonyOS_Sans_Condensed_Black_Italic.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed_Italic/HarmonyOS_Sans_Condensed_Bold_Italic.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed_Italic/HarmonyOS_Sans_Condensed_Bold_Italic.ttf new file mode 100755 index 0000000..168a4e0 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed_Italic/HarmonyOS_Sans_Condensed_Bold_Italic.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed_Italic/HarmonyOS_Sans_Condensed_Light_Italic.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed_Italic/HarmonyOS_Sans_Condensed_Light_Italic.ttf new file mode 100755 index 0000000..f1b71cd Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed_Italic/HarmonyOS_Sans_Condensed_Light_Italic.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed_Italic/HarmonyOS_Sans_Condensed_Medium_Italic.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed_Italic/HarmonyOS_Sans_Condensed_Medium_Italic.ttf new file mode 100755 index 0000000..51d40c0 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed_Italic/HarmonyOS_Sans_Condensed_Medium_Italic.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed_Italic/HarmonyOS_Sans_Condensed_Regular_Italic.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed_Italic/HarmonyOS_Sans_Condensed_Regular_Italic.ttf new file mode 100755 index 0000000..223e020 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed_Italic/HarmonyOS_Sans_Condensed_Regular_Italic.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed_Italic/HarmonyOS_Sans_Condensed_Thin_Italic.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed_Italic/HarmonyOS_Sans_Condensed_Thin_Italic.ttf new file mode 100755 index 0000000..a93bd5f Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed_Italic/HarmonyOS_Sans_Condensed_Thin_Italic.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed_Italic/LICENSE.txt b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed_Italic/LICENSE.txt new file mode 100644 index 0000000..211a701 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Condensed_Italic/LICENSE.txt differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Italic/HarmonyOS_Sans_Black_Italic.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Italic/HarmonyOS_Sans_Black_Italic.ttf new file mode 100755 index 0000000..2bf4653 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Italic/HarmonyOS_Sans_Black_Italic.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Italic/HarmonyOS_Sans_Bold_Italic.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Italic/HarmonyOS_Sans_Bold_Italic.ttf new file mode 100755 index 0000000..8b4cc6f Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Italic/HarmonyOS_Sans_Bold_Italic.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Italic/HarmonyOS_Sans_Light_Italic.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Italic/HarmonyOS_Sans_Light_Italic.ttf new file mode 100755 index 0000000..0fe624a Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Italic/HarmonyOS_Sans_Light_Italic.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Italic/HarmonyOS_Sans_Medium_Italic.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Italic/HarmonyOS_Sans_Medium_Italic.ttf new file mode 100755 index 0000000..6a8e1e7 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Italic/HarmonyOS_Sans_Medium_Italic.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Italic/HarmonyOS_Sans_Regular_Italic.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Italic/HarmonyOS_Sans_Regular_Italic.ttf new file mode 100755 index 0000000..fac9212 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Italic/HarmonyOS_Sans_Regular_Italic.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Italic/HarmonyOS_Sans_Thin_Italic.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Italic/HarmonyOS_Sans_Thin_Italic.ttf new file mode 100755 index 0000000..a5c4e3e Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Italic/HarmonyOS_Sans_Thin_Italic.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Italic/LICENSE.txt b/resources/HarmonyOS Sans/HarmonyOS_Sans_Italic/LICENSE.txt new file mode 100644 index 0000000..211a701 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Italic/LICENSE.txt differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic/HarmonyOS_Sans_Naskh_Arabic_Black.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic/HarmonyOS_Sans_Naskh_Arabic_Black.ttf new file mode 100755 index 0000000..300fc17 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic/HarmonyOS_Sans_Naskh_Arabic_Black.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic/HarmonyOS_Sans_Naskh_Arabic_Bold.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic/HarmonyOS_Sans_Naskh_Arabic_Bold.ttf new file mode 100755 index 0000000..da75755 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic/HarmonyOS_Sans_Naskh_Arabic_Bold.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic/HarmonyOS_Sans_Naskh_Arabic_Light.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic/HarmonyOS_Sans_Naskh_Arabic_Light.ttf new file mode 100755 index 0000000..685eea9 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic/HarmonyOS_Sans_Naskh_Arabic_Light.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic/HarmonyOS_Sans_Naskh_Arabic_Medium.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic/HarmonyOS_Sans_Naskh_Arabic_Medium.ttf new file mode 100755 index 0000000..8bb40f7 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic/HarmonyOS_Sans_Naskh_Arabic_Medium.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic/HarmonyOS_Sans_Naskh_Arabic_Regular.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic/HarmonyOS_Sans_Naskh_Arabic_Regular.ttf new file mode 100755 index 0000000..01e01c5 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic/HarmonyOS_Sans_Naskh_Arabic_Regular.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic/HarmonyOS_Sans_Naskh_Arabic_Thin.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic/HarmonyOS_Sans_Naskh_Arabic_Thin.ttf new file mode 100755 index 0000000..fc7f82d Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic/HarmonyOS_Sans_Naskh_Arabic_Thin.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic/LICENSE.txt b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic/LICENSE.txt new file mode 100644 index 0000000..211a701 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic/LICENSE.txt differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic_UI/HarmonyOS_Sans_Naskh_Arabic_UI_Black.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic_UI/HarmonyOS_Sans_Naskh_Arabic_UI_Black.ttf new file mode 100755 index 0000000..311cb10 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic_UI/HarmonyOS_Sans_Naskh_Arabic_UI_Black.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic_UI/HarmonyOS_Sans_Naskh_Arabic_UI_Bold.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic_UI/HarmonyOS_Sans_Naskh_Arabic_UI_Bold.ttf new file mode 100755 index 0000000..890889f Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic_UI/HarmonyOS_Sans_Naskh_Arabic_UI_Bold.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic_UI/HarmonyOS_Sans_Naskh_Arabic_UI_Light.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic_UI/HarmonyOS_Sans_Naskh_Arabic_UI_Light.ttf new file mode 100755 index 0000000..42c7476 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic_UI/HarmonyOS_Sans_Naskh_Arabic_UI_Light.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic_UI/HarmonyOS_Sans_Naskh_Arabic_UI_Medium.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic_UI/HarmonyOS_Sans_Naskh_Arabic_UI_Medium.ttf new file mode 100755 index 0000000..941afb1 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic_UI/HarmonyOS_Sans_Naskh_Arabic_UI_Medium.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic_UI/HarmonyOS_Sans_Naskh_Arabic_UI_Regular.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic_UI/HarmonyOS_Sans_Naskh_Arabic_UI_Regular.ttf new file mode 100755 index 0000000..60a409e Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic_UI/HarmonyOS_Sans_Naskh_Arabic_UI_Regular.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic_UI/HarmonyOS_Sans_Naskh_Arabic_UI_Thin.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic_UI/HarmonyOS_Sans_Naskh_Arabic_UI_Thin.ttf new file mode 100755 index 0000000..7b9dffe Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic_UI/HarmonyOS_Sans_Naskh_Arabic_UI_Thin.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic_UI/LICENSE.txt b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic_UI/LICENSE.txt new file mode 100644 index 0000000..211a701 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_Naskh_Arabic_UI/LICENSE.txt differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Black.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Black.ttf new file mode 100755 index 0000000..824b3d4 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Black.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Bold.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Bold.ttf new file mode 100755 index 0000000..5c925d1 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Bold.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Light.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Light.ttf new file mode 100755 index 0000000..8cfbc9d Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Light.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Medium.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Medium.ttf new file mode 100755 index 0000000..350f6a0 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Medium.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Regular.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Regular.ttf new file mode 100755 index 0000000..aff150a Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Regular.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Thin.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Thin.ttf new file mode 100755 index 0000000..6a58b15 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Thin.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_SC/LICENSE.txt b/resources/HarmonyOS Sans/HarmonyOS_Sans_SC/LICENSE.txt new file mode 100644 index 0000000..211a701 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_SC/LICENSE.txt differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_TC/HarmonyOS_Sans_TC_Black.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_TC/HarmonyOS_Sans_TC_Black.ttf new file mode 100755 index 0000000..cc28028 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_TC/HarmonyOS_Sans_TC_Black.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_TC/HarmonyOS_Sans_TC_Bold.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_TC/HarmonyOS_Sans_TC_Bold.ttf new file mode 100755 index 0000000..e408f06 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_TC/HarmonyOS_Sans_TC_Bold.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_TC/HarmonyOS_Sans_TC_Light.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_TC/HarmonyOS_Sans_TC_Light.ttf new file mode 100755 index 0000000..115e71d Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_TC/HarmonyOS_Sans_TC_Light.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_TC/HarmonyOS_Sans_TC_Medium.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_TC/HarmonyOS_Sans_TC_Medium.ttf new file mode 100755 index 0000000..0268f5d Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_TC/HarmonyOS_Sans_TC_Medium.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_TC/HarmonyOS_Sans_TC_Regular.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_TC/HarmonyOS_Sans_TC_Regular.ttf new file mode 100755 index 0000000..63f7a1b Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_TC/HarmonyOS_Sans_TC_Regular.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_TC/HarmonyOS_Sans_TC_Thin.ttf b/resources/HarmonyOS Sans/HarmonyOS_Sans_TC/HarmonyOS_Sans_TC_Thin.ttf new file mode 100755 index 0000000..0cc309c Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_TC/HarmonyOS_Sans_TC_Thin.ttf differ diff --git a/resources/HarmonyOS Sans/HarmonyOS_Sans_TC/LICENSE.txt b/resources/HarmonyOS Sans/HarmonyOS_Sans_TC/LICENSE.txt new file mode 100644 index 0000000..211a701 Binary files /dev/null and b/resources/HarmonyOS Sans/HarmonyOS_Sans_TC/LICENSE.txt differ diff --git a/resources/larimar-model.glb b/resources/larimar-model.glb new file mode 100644 index 0000000..a1e94c1 Binary files /dev/null and b/resources/larimar-model.glb differ diff --git a/resources/larimar-official.mp3 b/resources/larimar-official.mp3 new file mode 100644 index 0000000..95e4502 Binary files /dev/null and b/resources/larimar-official.mp3 differ diff --git a/scripts/build_demo.sh b/scripts/build_demo.sh new file mode 100755 index 0000000..c5035a1 --- /dev/null +++ b/scripts/build_demo.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# Renders the demo a human can actually watch, into build/demo. +# +# Every gate in this repository answers a machine's question — does it compile, +# do the tiers agree to 1 LSB, did the surfaces get reclaimed. None of them +# answers "does it look right", and that question is not optional for a renderer. +# This produces the artifacts to look at: the same game on every tier the host +# can offer, plus the Flutter application when it has been built. +set -euo pipefail + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +demo_dir="${LARIMAR_DEMO_DIR:-$repository_root/build/demo}" +pong="$repository_root/zig-out/bin/pong" +resolution="${LARIMAR_DEMO_RES:-960x540}" +pong_frames="${LARIMAR_DEMO_FRAMES:-600}" +gallery_frames="${LARIMAR_DEMO_GALLERY_FRAMES:-240}" +seed="${LARIMAR_DEMO_SEED:-42}" + +if [ ! -x "$pong" ]; then + echo "build_demo.sh: $pong is missing; run 'zig build larimar'." >&2 + exit 1 +fi + +mkdir -p "$demo_dir" +rendered=() + +render() { + local scene="$1" grade="$2" + local scene_flag="" + [ "$scene" = "ripple" ] && scene_flag="--gallery" + local output="$demo_dir/$scene-$grade.mp4" + local frames="$pong_frames" + [ "$scene" = "ripple" ] && frames="$gallery_frames" + + local log + # A tier the host cannot offer is a skip, not a failure: this machine may have + # no GPU, and a demo that refuses to render anything is worse than a partial + # one that says which parts are missing. + if ! log=$("$pong" $scene_flag --grade "$grade" --res "$resolution" --frames "$frames" \ + --seed "$seed" --out "$output" 2>&1); then + printf ' %-7s %-6s skipped — %s\n' "$scene" "$grade" \ + "$(printf '%s' "$log" | grep -m1 -E 'could not bind|no Metal|failed' | cut -c1-70)" + rm -f "$output" + return 0 + fi + local backend + backend=$(printf '%s' "$log" | sed -n 's/.*backend=\([a-z][a-z]*\).*/\1/p' | head -1) + if [ "$grade" != "ink" ] && [ "$backend" = "ink" ]; then + printf ' %-7s %-6s skipped — no backend on this host\n' "$scene" "$grade" + rm -f "$output" + return 0 + fi + printf ' %-7s %-6s %s (%s)\n' "$scene" "$grade" "$(basename "$output")" "$backend" + rendered+=("$scene-$grade.mp4") +} + +# The 3D reference asset, when this build has a Filament backend to draw it. +render_asset() { + local asset="$repository_root/zig-out/share/eshi/larimar_logo.glb" + [ -f "$asset" ] || return 0 + local output="$demo_dir/logo-brush.mp4" + local log + if ! log=$("$pong" --grade brush --asset "$asset" --res "$resolution" \ + --frames "$gallery_frames" --seed "$seed" --out "$output" 2>&1); then + printf ' %-7s %-6s skipped — this build has no Filament backend\n' "logo" "brush" + printf ' (./scripts/vendor_filament.sh && zig build demo -Dfilament=true)\n' + rm -f "$output" + return 0 + fi + printf ' %-7s %-6s %s (filament)\n' "logo" "brush" "$(basename "$output")" + rendered+=("logo-brush.mp4") +} + +echo "Rendering $resolution demos into $demo_dir" +for scene in pong ripple; do + for grade in ink paper brush; do + render "$scene" "$grade" + done +done +render_asset + +# The Flutter application is the other half of what there is to look at, and it +# is built by a different toolchain. +# +# It is built here rather than copied from wherever an artifact happens to be, +# because `flutter test -d macos` writes its own bundle to exactly that path: +# the integration test becomes the application's entry point. Shipping that +# artifact produces an app that runs the test suite, tears the widget tree down +# when it finishes, and leaves a blank window — which is precisely what it did. +flutter_app_dir="$repository_root/examples/larimar_flutter" +flutter_app="$flutter_app_dir/build/macos/Build/Products/Release/larimar_flutter.app" +if command -v flutter >/dev/null 2>&1; then + printf ' flutter building… ' + if (cd "$flutter_app_dir" && flutter build macos --release -t lib/main.dart >"$demo_dir/flutter-build.log" 2>&1); then + rm -rf "$demo_dir/LarimarFirstLight.app" + cp -R "$flutter_app" "$demo_dir/LarimarFirstLight.app" + rm -f "$demo_dir/flutter-build.log" + echo "LarimarFirstLight.app" + else + echo "failed — see $demo_dir/flutter-build.log" + fi +else + echo " flutter not installed — skipping LarimarFirstLight.app" +fi + +if [ -f "$repository_root/docs/larimar/media/eshiview-pong-01.png" ]; then + cp "$repository_root/docs/larimar/media/eshiview-pong-0"*.png "$demo_dir/" +fi + +cat >"$demo_dir/README.md" <&2 + exit 1 + fi +done +if [ ! -f "$asset" ]; then + echo "check_asset_render.sh: $asset is missing; run 'zig build shaders'." >&2 + exit 1 +fi + +# 3D geometry needs the Filament backend. A build or a machine without one is a +# skip that says so, the same rule the Metal shader validation follows. +probe=$("$pong" --grade brush --asset "$asset" --asset-instances 0 --res 64x36 \ + --frames 1 --hash 2>&1 || true) +case "$probe" in + *"backend=filament"*) ;; + *) + echo "check_asset_render.sh: no Filament backend on this host; skipping." + echo " (build with -Dfilament=true after ./scripts/vendor_filament.sh)" + exit 0 + ;; +esac + +work_dir=$(mktemp -d "${TMPDIR:-/tmp}/larimar-asset.XXXXXX") +trap 'rm -rf "$work_dir"' EXIT + +render() { + local instances="$1" directory="$2" + mkdir -p "$directory" + "$pong" --grade brush --asset "$asset" --asset-instances "$instances" \ + --res "$resolution" --frames 2 --dump "$directory" >"$work_dir/render-$instances.log" 2>&1 +} + +render 0 "$work_dir/empty" +render 1 "$work_dir/drawn" + +# ppmdiff exits non-zero when frames differ by more than the allowance, so a +# zero allowance turns it into "are these identical?" — and here they must not +# be. +if "$ppmdiff" "$work_dir/empty" "$work_dir/drawn" 0 >/dev/null 2>&1; then + echo "::error::the asset render is identical to an empty scene; nothing was drawn" >&2 + exit 1 +fi +echo " asset renders geometry the empty scene does not" + +# gltfio drops images it has no decoder for, mentions it once on stderr, and +# renders the model black — which a digest cannot tell from a dark material. +# The greybox has no textures, so only a real model exposed this; the check +# stays because the next asset will have them. +if grep -q "Missing texture provider" "$work_dir"/render-*.log; then + echo "::error::the loader has no decoder for a texture this asset uses:" >&2 + grep -h "Missing texture provider" "$work_dir"/render-*.log | sort -u >&2 + exit 1 +fi +echo " every texture the asset references has a decoder" + +# One upload, several instances: the asset count stays at one. +instanced=$("$pong" --grade brush --asset "$asset" --asset-instances 3 \ + --res 64x36 --frames 1 --hash 2>&1) +case "$instanced" in + *"instances=3 assets=1"*) echo " three instances share one uploaded asset" ;; + *) + echo "::error::three instances did not share one asset:" >&2 + printf '%s\n' "$instanced" | grep -m1 "eshi/host" >&2 || true + exit 1 + ;; +esac + +check_failure() { + local description="$1" path="$2" expected="$3" + local output + if output=$("$pong" --grade brush --asset "$path" --res 64x36 --frames 1 --hash 2>&1); then + echo "::error::$description was accepted; it must fail" >&2 + exit 1 + fi + case "$output" in + *"$expected"*) echo " $description refused: $expected" ;; + *) + echo "::error::$description failed without saying why:" >&2 + printf '%s\n' "$output" >&2 + exit 1 + ;; + esac +} + +check_failure "a missing asset" "$work_dir/absent.glb" "cannot read asset" +head -c 512 /dev/urandom >"$work_dir/corrupt.glb" +check_failure "a corrupt asset" "$work_dir/corrupt.glb" "not a glTF this loader accepts" + +# The capability ladder, stated by the engine rather than assumed by the host. +if ink=$("$pong" --grade ink --asset "$asset" --res 64x36 --frames 1 --hash 2>&1); then + echo "::error::the Ink tier claimed to load a 3D asset" >&2 + exit 1 +fi +case "$ink" in + *"unsupported grade or feature"*) echo " the Ink tier reports no 3D scene" ;; + *) echo "::error::the Ink tier failed for the wrong reason:" >&2; printf '%s\n' "$ink" >&2; exit 1 ;; +esac + +echo "check_asset_render.sh: the reference asset loads, instances, and draws." diff --git a/scripts/check_eshiview_host.sh b/scripts/check_eshiview_host.sh new file mode 100755 index 0000000..40d3f7f --- /dev/null +++ b/scripts/check_eshiview_host.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Drives the macOS EshiView texture host under leak and race diagnostics. +# +# Two passes over the same harness, because the two questions need different +# builds: an uninstrumented binary under `leaks --atExit`, which reports every +# surface the adapter failed to release, and a ThreadSanitizer build, which +# reports every unsynchronised access between the platform thread and the raster +# queue. See bindings/dart/larimar/macos/diagnostics/texture_host_main.mm. +# +# The engine-level half of this gate lives in check_eshiview_lifecycle.sh. +set -euo pipefail + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +plugin_root="$repository_root/bindings/dart/larimar/macos" +artifact_dir="${LARIMAR_ARTIFACTS:-$repository_root/build/larimar-diagnostics}" +cxx="${CXX:-clang++}" +cycles="${LARIMAR_HOST_CYCLES:-24}" + +if [ "$(uname -s)" != "Darwin" ]; then + echo "check_eshiview_host.sh: the EshiView texture host is macOS-only." >&2 + exit 1 +fi + +# The harness links the same engine framework the plugin compiles against. A +# fresh checkout may not have it yet, so precache before giving up: an absent +# framework is a setup problem, not a diagnostic result, and must not be +# reported as either a pass or a failure. +flutter_root="${FLUTTER_ROOT:-}" +if [ -z "$flutter_root" ] && command -v flutter >/dev/null 2>&1; then + flutter_bin="$(command -v flutter)" + while [ -L "$flutter_bin" ]; do flutter_bin="$(readlink "$flutter_bin")"; done + flutter_root="$(cd "$(dirname "$flutter_bin")/.." && pwd)" +fi + +# The copy embedded in a built .app is stripped of its headers, so only the +# SDK's xcframework can be compiled against. +sdk_framework="$flutter_root/bin/cache/artifacts/engine/darwin-x64/FlutterMacOS.xcframework/macos-arm64_x86_64" +framework_dir="" +if [ -d "$sdk_framework/FlutterMacOS.framework/Headers" ]; then + framework_dir="$sdk_framework" +elif command -v flutter >/dev/null 2>&1; then + flutter precache --macos >/dev/null + [ -d "$sdk_framework/FlutterMacOS.framework/Headers" ] && framework_dir="$sdk_framework" +fi + +if [ -z "$framework_dir" ]; then + echo "check_eshiview_host.sh: FlutterMacOS.framework not found; run 'flutter precache --macos'." >&2 + exit 1 +fi + +mkdir -p "$artifact_dir" +build_dir=$(mktemp -d "${TMPDIR:-/tmp}/larimar-eshiview.XXXXXX") +trap 'rm -rf "$build_dir"' EXIT + +build_harness() { + "$cxx" -std=c++11 -fobjc-arc -O1 -g -fno-omit-frame-pointer \ + -Wall -Wextra -Wno-nullability-completeness \ + -I"$plugin_root/larimar/Sources/larimar/include" \ + -F"$framework_dir" -Wl,-rpath,"$framework_dir" \ + -framework FlutterMacOS -framework Foundation -framework CoreVideo \ + -framework Metal -framework IOSurface -framework CoreGraphics -framework ImageIO \ + "$plugin_root/larimar/Sources/larimar/LarimarPlugin.mm" \ + "$plugin_root/diagnostics/texture_host_main.mm" \ + "$@" +} + +leak_report="$artifact_dir/eshiview-host-leaks.txt" +race_report="$artifact_dir/eshiview-host-race.txt" + +build_harness -o "$build_dir/texture_host" +build_harness -fsanitize=thread -o "$build_dir/texture_host_tsan" + +# Pass 1. `leaks --atExit` reports what the process still owns when it exits, so +# a surface the adapter forgot to release shows up as a rooted CVPixelBuffer. +set +e +LARIMAR_HOST_CYCLES="$cycles" leaks --atExit -- "$build_dir/texture_host" \ + 2>&1 | tee "$leak_report" +leak_status=${PIPESTATUS[0]} +set -e +if [ "$leak_status" -ne 0 ]; then + echo "check_eshiview_host.sh: the texture host leaked; see $leak_report" >&2 + exit 1 +fi +# `leaks --atExit` reports its own verdict and discards the child's exit code, +# so the harness's lifecycle verdict has to be read out of the report. +if ! grep -qE '^\[host\] status=(ok|skipped-no-metal)$' "$leak_report"; then + echo "check_eshiview_host.sh: the harness did not report a clean lifecycle; see $leak_report" >&2 + exit 1 +fi + +# Pass 2. Same cycles, this time with the platform thread and the raster queue +# under ThreadSanitizer. +set +e +LARIMAR_HOST_CYCLES="$cycles" \ + TSAN_OPTIONS="halt_on_error=0 exitcode=66 second_deadlock_stack=1" \ + "$build_dir/texture_host_tsan" 2>&1 | tee "$race_report" +race_status=${PIPESTATUS[0]} +set -e +if [ "$race_status" -eq 66 ]; then + echo "check_eshiview_host.sh: ThreadSanitizer reported a data race; see $race_report" >&2 + exit 1 +fi +if [ "$race_status" -ne 0 ]; then + echo "check_eshiview_host.sh: the sanitized harness failed; see $race_report" >&2 + exit "$race_status" +fi + +echo "check_eshiview_host.sh: $cycles cycles clean under leaks and ThreadSanitizer." +echo " $leak_report" +echo " $race_report" diff --git a/scripts/check_eshiview_lifecycle.sh b/scripts/check_eshiview_lifecycle.sh new file mode 100755 index 0000000..f15b49c --- /dev/null +++ b/scripts/check_eshiview_lifecycle.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# Runs the macOS EshiView lifecycle loop inside a real Flutter engine, snapshots +# the process from outside, and keeps the composited frames it captured. +# +# The snapshots have to come from here rather than from the test: the reference +# application runs under the App Sandbox, and a sandboxed process cannot open +# its own task port, so `leaks` refuses to examine it from within. The test +# instead asks for a snapshot by dropping a request file in its container and +# waits for this script to acknowledge it. +# +# The host-free half of this gate — ThreadSanitizer and `leaks --atExit` over +# the adapter itself — lives in check_eshiview_host.sh. +set -euo pipefail + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +app_root="$repository_root/examples/larimar_flutter" +artifact_dir="${LARIMAR_ARTIFACTS:-$repository_root/build/larimar-diagnostics}" +cycles="${LARIMAR_LIFECYCLE_CYCLES:-12}" +# Foundation and AppKit leak a little on their own during a run; the gate is +# about surfaces, which are three orders of magnitude larger than this. +tolerance="${LARIMAR_LEAK_TOLERANCE:-65536}" + +if [ "$(uname -s)" != "Darwin" ]; then + echo "check_eshiview_lifecycle.sh: EshiView is macOS-only." >&2 + exit 1 +fi + +mkdir -p "$artifact_dir" +log="$artifact_dir/eshiview-lifecycle.log" +: >"$log" + +cd "$app_root" +flutter test integration_test/eshiview_lifecycle_test.dart -d macos \ + --reporter expanded \ + --dart-define=LARIMAR_LEAK_HARNESS=true \ + --dart-define=LARIMAR_LIFECYCLE_CYCLES="$cycles" >>"$log" 2>&1 & +test_pid=$! + +container_tmp="" +snapshots="" + +snapshot() { + local phase="$1" + local app_pid + app_pid=$(pgrep -n -f 'larimar_flutter.app/Contents/MacOS/larimar_flutter' || true) + if [ -z "$app_pid" ]; then + echo "check_eshiview_lifecycle.sh: no running application to snapshot for '$phase'." >&2 + return 1 + fi + leaks "$app_pid" >"$artifact_dir/eshiview-$phase-leaks.txt" 2>&1 || true + footprint "$app_pid" >"$artifact_dir/eshiview-$phase-footprint.txt" 2>&1 || true + snapshots="$snapshots $phase" +} + +while kill -0 "$test_pid" 2>/dev/null; do + if [ -z "$container_tmp" ]; then + container_tmp=$(sed -n 's/^larimar-tmp: //p' "$log" | head -1 | tr -d '\r') + sleep 0.2 + continue + fi + request="$container_tmp/larimar-lifecycle.request" + if [ -f "$request" ]; then + phase=$(tr -d '[:space:]' <"$request") + rm -f "$request" + snapshot "$phase" || true + printf '%s' "$phase" >"$container_tmp/larimar-lifecycle.ack" + fi + sleep 0.2 +done + +set +e +wait "$test_pid" +test_status=$? +set -e + +tail -20 "$log" + +# Keep whatever the run captured, whether or not it passed: a failed frame is +# evidence too. +if [ -n "$container_tmp" ]; then + while IFS= read -r captured; do + if [ -f "$captured" ]; then cp "$captured" "$artifact_dir/"; fi + done < <(sed -n 's/^larimar-artifact: //p' "$log" | tr -d '\r') +fi + +if [ "$test_status" -ne 0 ]; then + echo "check_eshiview_lifecycle.sh: the lifecycle test failed; see $log" >&2 + exit "$test_status" +fi + +if grep -q '^larimar-skip: ' "$log"; then + echo "check_eshiview_lifecycle.sh: skipped — $(sed -n 's/^larimar-skip: //p' "$log" | head -1)." + exit 0 +fi + +leaked_bytes() { + # "Process 1234: 7 leaks for 320 total leaked bytes." + sed -n 's/.*for \([0-9][0-9]*\) total leaked bytes.*/\1/p' "$1" | tail -1 +} + +baseline_report="$artifact_dir/eshiview-baseline-leaks.txt" +final_report="$artifact_dir/eshiview-final-leaks.txt" +for report in "$baseline_report" "$final_report"; do + if [ ! -s "$report" ]; then + echo "check_eshiview_lifecycle.sh: missing $report; the snapshots never ran." >&2 + exit 1 + fi +done + +baseline_bytes=$(leaked_bytes "$baseline_report") +final_bytes=$(leaked_bytes "$final_report") +if [ -z "$baseline_bytes" ] || [ -z "$final_bytes" ]; then + echo "check_eshiview_lifecycle.sh: could not read leaked byte counts from the reports." >&2 + exit 1 +fi + +growth=$((final_bytes - baseline_bytes)) +echo "check_eshiview_lifecycle.sh: $cycles cycles;$snapshots snapshots kept." +echo " leaked bytes after cycle 1: $baseline_bytes" +echo " leaked bytes after cycle $cycles: $final_bytes (growth $growth, tolerance $tolerance)" +if [ "$growth" -gt "$tolerance" ]; then + echo "::error::EshiView lifecycle leaked $growth bytes across $((cycles - 1)) cycles." >&2 + exit 1 +fi +echo " artifacts in $artifact_dir" diff --git a/scripts/check_materials.sh b/scripts/check_materials.sh new file mode 100755 index 0000000..dbf8c84 --- /dev/null +++ b/scripts/check_materials.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Emits a Filament material for every shader in the corpus and compiles it. +# +# The gallery is the conformance corpus for the C++ shader subset — 20 programs +# that already run on Ink, OpenGL, and Metal. This asks the fourth target the +# same question, and it is a real question: a `.mat` fragment block is not a +# free-form shader, and the two limits below were found by running this, not by +# reading the specification. +# +# A shader may only fail to emit if it is listed here with its reason. That list +# is the point: it turns "some shaders don't work on Filament" into a fixed, +# named set that cannot grow without someone editing this file. +set -euo pipefail + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +matgen="$repository_root/zig-out/bin/eshi-matgen" +# The pinned distribution scripts/vendor_filament.sh fetches. +filament_path="${FILAMENT_PATH:-$repository_root/third_party/filament/v1.75.0}" +matc="$filament_path/bin/matc" + +# Shaders the material domain cannot express, and why. Both refuse loudly with +# a diagnostic naming the cause; see docs/larimar/SHADER_SUBSET.md. +expected_refusals="warp rainforest" + +if [ ! -x "$matgen" ]; then + echo "check_materials.sh: $matgen is missing; run 'zig build tools'." >&2 + exit 1 +fi + +# matc ships with a Filament distribution, which an ordinary checkout does not +# have — vendoring one is a PLAN Step 5 bullet. Until then the emitter half runs +# everywhere and the compile half runs wherever Filament is installed. Say which +# happened rather than reporting a weaker check as the full one. +compile_materials=1 +if [ ! -x "$matc" ]; then + compile_materials=0 + echo "check_materials.sh: no matc at $matc; checking emission only." + echo " Set FILAMENT_PATH to a Filament distribution to also compile the materials." +fi + +work_dir=$(mktemp -d "${TMPDIR:-/tmp}/larimar-materials.XXXXXX") +trap 'rm -rf "$work_dir"' EXIT + +compiled=0 +emitted=0 +refused=0 +status=0 + +for source in "$repository_root"/examples/*.cpp "$repository_root"/examples/pong/pong.gpu.cpp; do + name=$(basename "$source" .cpp) + name=${name%.gpu} + # Pong is the one shader with a uniform block; the gallery reads none. + uniform_floats=0 + case "$source" in + */pong.gpu.cpp) uniform_floats=64 ;; + esac + + if ! "$matgen" "$source" --uniform-floats "$uniform_floats" \ + -o "$work_dir/$name.mat" 2>"$work_dir/$name.refusal"; then + case " $expected_refusals " in + *" $name "*) + echo " refused $name — $(sed -n 's/.*: //p' "$work_dir/$name.refusal" | head -1 | cut -c1-60)…" + refused=$((refused + 1)) + ;; + *) + echo "::error::$name no longer emits a Filament material:" >&2 + cat "$work_dir/$name.refusal" >&2 + status=1 + ;; + esac + continue + fi + + case " $expected_refusals " in + *" $name "*) + echo "::error::$name now emits a material but is still listed as a refusal; update the list." >&2 + status=1 + ;; + esac + + if [ "$compile_materials" -eq 0 ]; then + emitted=$((emitted + 1)) + continue + fi + if "$matc" -p all -a all -o "$work_dir/$name.filamat" "$work_dir/$name.mat" \ + >"$work_dir/$name.matc" 2>&1; then + compiled=$((compiled + 1)) + else + echo "::error::matc rejected the material generated from $name:" >&2 + sed -n '1,10p' "$work_dir/$name.matc" >&2 + status=1 + fi +done + +if [ "$compile_materials" -eq 1 ]; then + echo "check_materials.sh: $compiled materials compiled, $refused refused for documented reasons." +else + echo "check_materials.sh: $emitted materials emitted (not compiled), $refused refused for documented reasons." +fi +exit "$status" diff --git a/scripts/check_tier_conformance.sh b/scripts/check_tier_conformance.sh new file mode 100755 index 0000000..c40d6df --- /dev/null +++ b/scripts/check_tier_conformance.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Compares the render tiers frame by frame against Ink. +# +# The claim this checks is the project's oracle: one material source, executed +# by the CPU tier and compiled by the GPU tiers, produces the same picture to +# within one least-significant bit. It was measured by hand once and written +# into a table in docs/larimar/ARCHITECTURE.md; a number nobody can re-derive +# stops being true quietly, so this makes it a command. +# +# Digests are the wrong instrument here and deliberately unused: `--hash` +# answers "is this tier deterministic?", which is a strict equality that tiers +# are not expected to satisfy against each other. Raw frames and a bounded pixel +# difference answer "do these tiers agree?" +set -euo pipefail + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +pong="$repository_root/zig-out/bin/pong" +ppmdiff="$repository_root/zig-out/bin/eshi-ppmdiff" +frames="${LARIMAR_CONFORMANCE_FRAMES:-60}" +resolution="${LARIMAR_CONFORMANCE_RES:-320x180}" +allowed="${LARIMAR_CONFORMANCE_LSB:-1}" +seed="${LARIMAR_CONFORMANCE_SEED:-42}" + +for binary in "$pong" "$ppmdiff"; do + if [ ! -x "$binary" ]; then + echo "check_tier_conformance.sh: $binary is missing; run 'zig build larimar' and 'zig build tools'." >&2 + exit 1 + fi +done + +work_dir=$(mktemp -d "${TMPDIR:-/tmp}/larimar-conformance.XXXXXX") +trap 'rm -rf "$work_dir"' EXIT + +# Renders one scene on one tier and reports which backend actually ran. A grade +# whose backend is unavailable falls back to Ink, and comparing Ink to Ink would +# pass while proving nothing — so the caller checks what came back. +render() { + local grade="$1" scene="$2" directory="$3" + mkdir -p "$directory" + local scene_flag="" + [ "$scene" = "gallery" ] && scene_flag="--gallery" + local output + output=$("$pong" $scene_flag --grade "$grade" --res "$resolution" --frames "$frames" \ + --seed "$seed" --dump "$directory" 2>&1) || { + echo "$output" >&2 + return 1 + } + printf '%s\n' "$output" | sed -n 's/.*backend=\([a-z][a-z]*\).*/\1/p' | head -1 +} + +compared=0 +skipped=0 + +for scene in pong gallery; do + ink_dir="$work_dir/$scene-ink" + ink_backend=$(render ink "$scene" "$ink_dir") + if [ "$ink_backend" != "ink" ]; then + echo "check_tier_conformance.sh: the ink grade selected '$ink_backend'; the reference tier is not itself." >&2 + exit 1 + fi + + for grade in paper brush; do + tier_dir="$work_dir/$scene-$grade" + backend=$(render "$grade" "$scene" "$tier_dir") || { + echo "check_tier_conformance.sh: $scene failed to render at $grade." >&2 + exit 1 + } + if [ "$backend" = "ink" ]; then + echo " $scene $grade: no backend on this host (fell back to Ink); skipped" + skipped=$((skipped + 1)) + continue + fi + printf ' %s %s (%s) vs ink: ' "$scene" "$grade" "$backend" + "$ppmdiff" "$ink_dir" "$tier_dir" "$allowed" + compared=$((compared + 1)) + done +done + +if [ "$compared" -eq 0 ]; then + echo "check_tier_conformance.sh: no GPU tier was available; nothing was compared." >&2 + exit 1 +fi + +echo "check_tier_conformance.sh: $compared tier comparisons within $allowed LSB at $resolution over $frames frames ($skipped skipped)." diff --git a/scripts/vendor_filament.sh b/scripts/vendor_filament.sh new file mode 100755 index 0000000..f88c779 --- /dev/null +++ b/scripts/vendor_filament.sh @@ -0,0 +1,119 @@ +#!/bin/sh +# Fetches the pinned Filament distribution into third_party/filament. +# +# Filament is a large CMake project with its own toolchain expectations, and +# building it from source would dominate this build — so consumers get the +# official prebuilt release instead, at a version this repository pins and a +# checksum it verifies. See docs/larimar/ARCHITECTURE.md §6.8. +# +# The same distribution serves three purposes: `matc` compiles the materials +# eshi-matgen emits, the static libraries back the Brush tier, and `gltfio` +# is what will own glTF parsing in PLAN Step 5. +set -eu + +FILAMENT_VERSION="${FILAMENT_VERSION:-v1.75.0}" +FILAMENT_VENDOR_DIR="${FILAMENT_VENDOR_DIR:-third_party/filament/$FILAMENT_VERSION}" +FILAMENT_BASE_URL="${FILAMENT_BASE_URL:-https://github.com/google/filament/releases/download}" +FORCE=0 + +# Checksums for the archives this version pins. Upgrading Filament means +# changing the version and both of these in one commit, with a full gate run — +# never one without the other. +SHA256_MAC="731b5fe71844e82e2ee45b891b05b9c07599fabccd297033275d90baefe97021" +SHA256_LINUX="c5d2e0f692e5fb98ed029a5a3a52c8174660d02844d5db5a804dc5264bbab6d1" + +usage() { + cat < + FILAMENT_BASE_URL Release download base. Default: GitHub releases. + +Options: + -f, --force Replace an existing distribution. + -h, --help Show this help. +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + -f|--force) FORCE=1 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +case "$(uname -s)" in + Darwin) platform="mac"; expected_sha="$SHA256_MAC"; library_arch="arm64" ;; + Linux) + if [ "$(uname -m)" != "x86_64" ]; then + echo "vendor_filament.sh: only x86_64 is pinned on Linux; this host is $(uname -m)." >&2 + exit 1 + fi + platform="linux"; expected_sha="$SHA256_LINUX"; library_arch="x86_64" + ;; + *) + echo "vendor_filament.sh: unsupported host $(uname -s); RC0 targets macOS and Linux." >&2 + exit 1 + ;; +esac + +# A distribution is "there" only if the pieces this repository uses are there. +# A half-extracted directory should re-fetch rather than fail later inside a +# build with a confusing missing-file error. +is_complete() { + [ -x "$FILAMENT_VENDOR_DIR/bin/matc" ] && + [ -f "$FILAMENT_VENDOR_DIR/lib/$library_arch/libfilament.a" ] && + [ -f "$FILAMENT_VENDOR_DIR/include/gltfio/AssetLoader.h" ] && + [ -f "$FILAMENT_VENDOR_DIR/LICENSE" ] +} + +if is_complete && [ "$FORCE" -ne 1 ]; then + echo "Filament $FILAMENT_VERSION is already vendored at $FILAMENT_VENDOR_DIR." + echo "Use './scripts/vendor_filament.sh --force' to replace it." + exit 0 +fi + +archive="filament-$FILAMENT_VERSION-$platform.tgz" +url="$FILAMENT_BASE_URL/$FILAMENT_VERSION/$archive" +tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/eshi-filament.XXXXXX")" +trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM + +echo "Fetching $archive..." +curl -fsSL -o "$tmp_dir/$archive" "$url" + +if command -v shasum >/dev/null 2>&1; then + actual_sha="$(shasum -a 256 "$tmp_dir/$archive" | cut -d' ' -f1)" +else + actual_sha="$(sha256sum "$tmp_dir/$archive" | cut -d' ' -f1)" +fi +if [ "$actual_sha" != "$expected_sha" ]; then + echo "vendor_filament.sh: checksum mismatch for $archive" >&2 + echo " expected $expected_sha" >&2 + echo " actual $actual_sha" >&2 + exit 1 +fi + +mkdir -p "$tmp_dir/extract" +# The archive's single top-level `filament/` directory is stripped, so the +# vendor directory *is* the distribution root that -Dfilament-path expects. +tar xzf "$tmp_dir/$archive" -C "$tmp_dir/extract" --strip-components=1 + +rm -rf "$FILAMENT_VENDOR_DIR" +mkdir -p "$(dirname "$FILAMENT_VENDOR_DIR")" +mv "$tmp_dir/extract" "$FILAMENT_VENDOR_DIR" + +if ! is_complete; then + echo "vendor_filament.sh: $archive extracted without the expected layout." >&2 + exit 1 +fi + +echo "Vendored Filament $FILAMENT_VERSION into $FILAMENT_VENDOR_DIR." +echo " zig build larimar -Dfilament=true" +echo " (override with -Dfilament-path=/path/to/filament)" diff --git a/third_party/NOTICE.md b/third_party/NOTICE.md new file mode 100644 index 0000000..314ba1f --- /dev/null +++ b/third_party/NOTICE.md @@ -0,0 +1,31 @@ +# Third-party attributions + +Eshi/Larimar is MIT licensed. This file records the third-party components a +build may fetch or link, and the obligations that come with them. Nothing here +is checked into this repository; `third_party/` holds fetched distributions and +is ignored by git. + +## Google Filament + +- **Version:** v1.75.0, pinned by `scripts/vendor_filament.sh`, which verifies + the release archive's SHA-256 before extracting it. +- **Upstream:** https://github.com/google/filament +- **License:** Apache License 2.0. The distribution's own `LICENSE` file is at + `third_party/filament/v1.75.0/LICENSE`, and `zig build larimar + -Dfilament=true` installs a copy to `zig-out/share/licenses/filament/LICENSE` + so it travels with any binary that links it. +- **What is used:** + - `bin/matc` compiles the material definitions `eshi-matgen` emits from + shader sources. It runs at build time only and is not redistributed. + - `lib//*.a` are statically linked into Brush-grade builds when + `-Dfilament=true` is passed. Ordinary Ink/Paper builds link none of it. + - `include/gltfio` is Filament's glTF loader, which PLAN Step 5 uses rather + than writing a second parser. + +Apache-2.0 §4 requires that redistributions carry the license and retain +attribution notices. Any published Larimar artifact that links Filament must +ship `share/licenses/filament/LICENSE`; the build produces it in the same step +that produces the binary, so the two cannot separate. + +Filament is not built from source in ordinary consumer builds — see +[`docs/larimar/ARCHITECTURE.md`](../docs/larimar/ARCHITECTURE.md) §6.8 for why.