Zinc Next: decisions after M1 to M6 (ZN-031)

Recorded 2026-10-06 from the results of the proof of concept. They close the “Decisions still open” of zinc-next-design.md (section 10).

1. Name of the desktop app: Zinc Atelier

ZincStudio already names the macOS box editor, so the app needs another name. Criteria: short, says “a place where you make things” (editor, devices, profiler in one app), not a generic word that is already a famous developer tool. “Forge” (Electron Forge, Minecraft Forge) and “Foundry” (Palantir, Foundry VTT) were set aside for that reason. The command line keeps the name zinc; the app is Zinc Atelier. Checked: no npm package and no GitHub repository of the closed spellings of the candidates. Not checked: trademarks and domains (task ZN-056). Fallbacks if it is taken: Zinc Forge, Zinc Foundry.

2. Shell of the app: a Zinc app (zinc:ui), not a web shell

The app is written in Zinc on zinc:ui and runs on the engine like any other app (the end goal in the product vision: one self-contained program for Windows, macOS and Linux; no Tauri, no Electron). The proof of concept shows the base is there: the whole zinc:ui stack (Solid signals, kit components, layout, text) runs in the interpreter and in AOT and draws pixel-identical frames (ZN-044, ZN-045). What is missing is planned as tasks ZN-047 to ZN-055: a live window and input, fonts and images baked by the engine, the host modules apps need, the shell itself, the QuickJS path, a stable IR, packaging, Windows and Linux hosts, and hardware validation.

3. QuickJS: the full engine, as a second engine

QuickJS is kept as a full engine next to the interpreter and the AOT output, not only for zinc:script: it runs plain JavaScript (npm code, eval, dynamic plugins) that the typed subset does not cover, behind the same host ABI. The typed engine stays the default and the fast path (the interpreter is 3 to 6 times faster than QuickJS on the numeric kernels, ZN-026). Constrained targets are evaluated separately (ZN-043, mquickjs). Task ZN-051.

4. --emit=ir: not stable now, and it must become stable

The IR text changed with almost every task of M3 to M6 (inlining, devirtualisation, closures, host calls), and its goldens are regenerated each time. It stays unstable until the passes settle. The aim is a stable, versioned format: a spec, a version number in the dump, a parser that reads it back, and a compatibility test over old dumps. Task ZN-052. Until then nothing outside the repository may depend on the text.

Also settled

  • clang is not replaced by an in-process backend for now: the pinned zig c++ is downloaded, checked and used in about 20 s (ZN-029) and cross builds for aarch64, armhf and x86_64 work. Revisit if the toolchain size or the first-build time proves a problem for users.
  • The default path for devices is bytecode upload to a preflashed core (ZN-030); AOT is the path for speed.

5. Name check for Zinc Atelier (ZN-056, 2026-10-06)

What was searched, and what came back (a web search and DNS and registry look-ups; no trademark office was queried, see the limits):

WhereResult
Web search “Zinc Atelier” (software, app, trademark)no product, company or mark of that name
Marks containing ZINC (USPTO listings found by the search)several registrations of the single word ZINC for software and apps (TemTree Co., Senseeker Engineering, Silver-Katz Entertainment, Summum S.A.) and a mark of Zinc Labs Inc (OpenObserve); older products named Zinc: the Zinc Application Framework (a C++ GUI toolkit), Zinc Inc. (messaging)
Domainszincatelier.com is registered (Cloudflare name servers, no site); zincatelier.io is not registered; zincatelier.dev and .app have no DNS records (probably free, to be confirmed at a registrar); zincforge.com is registered (Porkbun); zincforge.dev and zincfoundry.com have no records
Registrieszinc-atelier and zincatelier are free on npm, PyPI and crates.io
GitHubno repository named zinc atelier

Decision. Keep Zinc Atelier as the name of the app. No product carries it and the registries are free. The risk is the word ZINC, not Atelier: other owners hold ZINC marks in software classes, so the name is used descriptively as “Zinc Atelier, the app of the Zinc language” and not registered as a mark by us until a lawyer has cleared it. Domain: register zincatelier.dev (or .app) first; .com is taken. Fallback if a conflict appears: Zinc Foundry (zincfoundry.com looks free), not Zinc Forge (zincforge.com is taken). The legacy ZincStudio box editor keeps its name; the two do not coexist in one package (Atelier is the app of the new engine, ZincStudio belongs to the old compiler).

Limits: a trademark office search (USPTO, EUIPO, WIPO) and a registrar’s availability check are not scriptable from here; the table is evidence for the decision, not legal advice.

6. MicroQuickJS (ZN-043, 2026-10-06)

Measured (zinc-next-mquickjs.md): about as fast as QuickJS, 4 to 7 times slower than the Zinc interpreter; 103 KB of Thumb-2 code against 125 KB for the Zinc core; 8 KB of heap for fib against a 24 KB register file, and 4 bytes per number in an array against 8. It runs ES5 strict mode only, so the ES2022 path of ZN-051 does not carry over. Decision: not adopted, not vendored. Revisit for a product that needs user JavaScript on a device with under 100 KB of free RAM; then as a fallback engine behind zinc:script.

7. Parity plan decisions (2026-10-07)

Made under RULES.md from the four parity audits in docs/reports/parity/ (01 language and standard library, 02 examples, 03 plugins and targets, 04 library choices). Scores follow RULES.md section 4; the reasoning and the evidence are in the audit files.

#QuestionDecisionWhy in one lineRevisit when
D1Native-module ABIEvolve runtime/include/zinc_abi.h v4 into include/zn/native.h: a typed export table with the signature letters of runtime.h, bound by name at load, one CallNative opcode, versioning and finalizers from Node-API, own/borrow rules from WIT. libffi only for zinc:ffiExists and is tested on two engines; plugin C++ links unchanged through a zrt marshalling layer; no rewrite of ~12 000 linesAn ABI v2 is needed by a plugin that cannot express its API
D2Plugins: shared library or linked inDesktop interpreter: one shared library per plugin, built and cached by the pinned zig; AOT programs and firmware: static link, registered by generated codeSmall zinc, hot reload, cache; static where there is no dynamic loaderA target without dlopen needs the desktop path
D3Prototype plugin C++Compatibility adapters (src/native/zrt_compat) keep the 22 native specs unchanged; new native code uses the C ABISame behaviour at once, no churnThe adapters cost more than 10% on a plugin benchmark
D4PS2Build-only gate; the PCSX2 runner runs only when the owner supplies a BIOS pathAn emulator cannot run without a BIOS we may not shipThe owner supplies a BIOS
D5PS1Spike first (a freestanding runtime and fixed-point lowering), the target afterwards; not on the critical pathLargest and most uncertain targetThe spike’s verdict
D6Raspberry Piaarch64-linux is the primary Pi target (qemu-user as the gate); armv6 (armhf) secondaryMatches raspberry-pi.md; 64-bit Pi OS is the shipped OSA Pi 1 product need
D7Sockets and event looplibuv on desktop and Pi, llhttp plus own glue for HTTP, wslay for WebSocket, mbedTLS for TLS and crypto; mongoose and wolfSSL rejected for licenceMature, MIT/Apache, portable (also what a later Windows port needs)Footprint on small targets
D8SVG and LottieThorVG for both on desktop-class targets; nanosvg stays for the tiny profile. Goldens are re-baked in their own commits with a tolerance, the shared rasterizer remains the reference for shapesProven, maintained, one library for two formats instead of 1800 custom linesA pixel regression the tolerance cannot absorb
D9Display simulation depthLevel 1 (window plus frame hashes) for all, Level 2 (chip models for SSD1306, ST7789, WS2812, IS31FL3730, QMI8658 behind a bus shim) for the shipped boards“Validated by simulator” must mean the bus bytes were checkedChip models prove too costly for a panel
D10Regex, Unicode, JSON, numbersQuickJS-ng libregexp/libunicode (split files), libunibreak, yyjson, fast_float, dragonboxSame regex semantics as the JS engine, fastest permissive optionsA measurement says otherwise (each adopting task measures)
D11Fonts, images, renderingstb_truetype plus a desktop HarfBuzz/SheenBidi tier; stb_image/stb_image_write, libwebp as plugin; software rasterizer is the reference and GLAD GL 3.3/GLES3 a second path; SDL3 vendored staticallyKeeps pixels stable and sizes small; GPU is an additionGPU path drift in goldens
D12MediaVideoToolbox/V4L2 first, dav1d and openh264 as run-time plugins, miniaudio/minimp3 for audio; FFmpeg only as a dynamically loaded plugin (LGPL)Licence safetyA format gap
D13JITNo JIT now. If measured need appears, a sljit baseline JIT from ZBC. The AOT and the typed interpreter already meet the performance gates; the prototype’s AArch64 JIT is not portedComplexity and security surface for no measured gainInterpreter needs another 2x on a target without a compiler JavaScript libraries on QuickJS are a separate question: D44.
D14Fast-math AOT (ZN-042)No fast-math: bit-identical rounding across engines is a product rule. nbody stays about 2.9x native; vectorization without FMA contraction is a separate optimisation taskDeterminism over a benchmarkOwner asks
D15Corpora and fuzzingtest262 subset, WPT URL/encoding data, wasm testsuite, JSONTestSuite as pinned data; libFuzzer with ASan/UBSan on the parser and the ZBC verifierReuse existing oraclesnone

| D16 | Nullable scalars (ZN-065) | T \| undefined for number and boolean is the existing boxed T \| null representation (undefined reads as null). Map.get and find/at of scalars return it through generated lookups; truthiness of a nullable number or boolean uses a generated test | No new IR slot kind; reuses the Dyn-era box and its compare | Measured cost of the boxes on a hot path | | D17 | Narrowing of property paths (ZN-066) | Paths of locals, this, fields and constant or local indexes (this.head, n.next.next, a[i]) narrow like variables. A write to a local, field or element forgets the paths below it; a call p.m() forgets the paths below p; a loop forgets what it writes or calls on. Calls of free functions forget nothing (as tsc); m.get(k) is not a path | Sound for the common aliasing, no whole-program analysis, close to tsc so demos keep compiling | A demo needs call-through writes via a free function | | D18 | undefined versus null (ZN-074) | The two share one representation (a null reference, or a null box for scalars). The distinction is in the type: a union with null carries an undef flavour (optional fields and parameters, T \| undefined, Map.get, find, at, a?.b, a variable declared without a value) and the code generated for printing, templates, typeof and JSON follows it; a union that mixes a plain null in is a null one. Files that use Dyn values keep the Dyn undefined | No change to values, so the fast path and the IR are untouched (bench-m4 unchanged); a value that is null in one place and undefined in another prints by its static type, not by its history | A program that stores both into one variable and prints it | | D19 | JSON and number formatting stay in tree (ZN-092, revises D10) | The runtime keeps its own JSON.parse into Dyn (a strict recursive parser that shares keys, short strings and small numbers) and std::to_chars for number printing; yyjson, fast_float and dragonbox are not added to the runtime. Scored 1-5 by speed / size / risk: keep 4/5/5, yyjson then convert 3/3/3 (a second tree and a second pass to build the Dyn), fast_float and dragonbox 3/4/4 (the C++ library already gives shortest round-trip digits, the ECMAScript layout is ours) | Measured first: JSONTestSuite 95 y_ accepted, 188 n_ rejected, the 35 i_ as Node (tests/t1/jsontestsuite.sh); String(number) hash of 1 000 000 doubles of every magnitude equals Node (tests/golden/run/number_print.ts). The one slow part was JSON.stringify of a Dyn tree, written in Zinc: now a runtime call (bench/json.ts: parse+stringify x10 1373 ms to 227 ms; QuickJS 690 ms). No new library, no size change | A platform whose libc++ lacks std::to_chars for double (the ESP32 firmware has its own printing); a document nested deeper than 1000 levels (Node accepts more) | | D20 | // @ts-ignore and // @ts-expect-error (ZN-094) | Not honoured: a comment never hides a diagnostic. Scored by predictability / parity / effort: refuse 5/4/5, honour 3/3/2 | One rule for every file; the registry says what is wrong and the fix is in the code (a cast, an annotation, the construct). The prototype honoured them only through tsc, which Next does not run | A migrating project with ts-ignore lines gets new errors, listed in docs/diagnostics-vs-tsc.md | | D21 | Native code versus stand-ins (ZN-101, refines D2) | ZINC_NATIVE=auto (default): a stand-in x.next.ts / x.sim.ts next to the spec wins when there is one, native code is built when there is none; real forces the plugin’s native code (an error naming the cause when it cannot be built), sim is today’s behaviour. The shared library exports zn_module_<Name> (one module per plugin spec) instead of one zn_module_open. Scored by parity / determinism / effort: auto 5/5/5, real-first 3/2/3 | The 49 demos that run today keep their stand-ins; golden tests stay deterministic and need no compiler; a plugin without a stand-in becomes usable at once | real by default once every plugin builds in CI and a stand-in is only a fallback | | D22 | zinc:script on the engine’s QuickJS-ng (ZN-103, applies D10) | A built-in native module QuickJS (src/qjs/script_native.cpp, the C ABI, registered by zinc and linked into AOT programs that use it) implements plugins/script/native/quickjs.spec.ts. unknown crosses as JSON text (generated wrappers __nativeJson / __nativeValue / __nativeArgs), a script function as {"__zn_fn": n} (a handle into the Vm’s table, sent back it becomes the function). The plugin’s own vendored QuickJS and its zrt::Dyn host.cpp are not used by Next. Scored by one-engine / parity / effort: this 5/4/3, the plugin’s vendored copy through the thunk 2/5/4 (two QuickJS in one process, symbol clashes under -rdynamic) | One QuickJS, one regexp and unicode, no zrt in the script path; the prototype keeps its own host.cpp | Values whose JSON form loses information (NaN, undefined inside arrays, cycles) differ from the prototype’s Dyn snapshot | | D23 | SVG and Lottie renderers (ZN-106, revises D8) | Keep the prototype’s own renderers (plugins/svg/native/svg.host.cpp, plugins/lottie/native/lottie.host.cpp): they already run unchanged through the plugin pipeline (thunk + cache + dlopen/static link) on the host library’s rasterizer and are the parity baseline of every demo. ThorVG stays a later quality upgrade (more of the SVG and Lottie specs, expressions), to be weighed against a pixel-diff budget. Scored by parity / effort / coverage: keep 5/5/3, ThorVG 3/2/5 | No pixel re-bake and no 1 MB dependency for the same demos; the galleries draw real content now; the .next.ts metadata-only stand-in of lottie is removed | A demo needs a Lottie or SVG feature the custom renderers lack (then vendor ThorVG behind the same Spec) | | D24 | Vector tiles (ZN-108, revises D11 for the map) | Keep the prototype’s map engine (plugins/map/native/map.host.cpp: its own MVT decoder, triangulation and MapLibre-subset style evaluator): it already runs unchanged through the plugin pipeline and renders both map examples. protozero, vtzero and earcut.hpp stay a later upgrade if decoding cost (about 90 ms per new tile) matters on small targets. Scored by parity / effort / speed: keep 5/5/3, rewrite on protozero+vtzero+earcut 3/1/4 | No new vendored code and no pixel re-bake; the map draws real tiles | Pan cost on the Pi or a demand for more of the style spec | | D25 | glTF reading (ZN-119, revises D11 for the 3D plugins) | Keep the three plugin’s own reader (plugins/three/native/three.host.cpp, 489 lines, with the Zinc GLTFLoader on top): it runs unchanged through the plugin pipeline and renders both example models like the prototype. cgltf 1.15 and meshoptimizer 1.3 are vendored as an independent reader and as optimiser libraries: a T0 test diffs meshes, triangles, vertices and the world bounding box between cgltf and the loader. Scored by parity / effort / risk: keep 5/5/5, move the plugin onto cgltf 3/2/3 | Two readers agree on both models; meshoptimizer is there when a profile wants vertex-cache or quantised meshes | A model the prototype’s reader rejects or misreads (the test list grows with it) | | D26 | Target profiles on the host (ZN-120) | --profile <name> and zinc.json profile choose the table of the prototype (macos, linux, sim, wasm, rpi1, rmpp: f64; esp32, ps2: f32; ps1: fx12). The checker’s number alias (setNumberAlias) and the lowering’s default literal follow it; number[] meets the f64 host calls through a generated copy (arrayConversion); ** on non-f64 goes through f64; the heap budget (setHeapBudget, global new/delete and object memory counted by usable size, 1 MiB headroom for the interpreter) stops the program with out of memory (heap budget N bytes, asked A, in use U). run keeps gradual typing so the .f32 goldens print (they use any); a build under a strict profile is strict, and the library (zinc:*, lib/std, plugins) is exempt from strict. Scored by parity / effort / risk: this alias 5/4/4, a separate compile of the prelude per profile 4/1/3 | The 22 f32 conformance programs print their goldens; the allocator counts only when asked (one flag test per allocation) | fx12 (the Dyn prelude mixes f64 and fx12), plugin out-arrays under f32 (three), AOT builds with a budget | | D27 | System integration spikes S1, S4, S5, S10 on macOS (ZN-230) | S1 yes: the engine copied into ~/.zinc/cache/macos/devapp/<app.id>.app (8 MB, a copy takes milliseconds) with an Info.plist and an ad-hoc signature (codesign --force --sign - --identifier <app.id>, codesign -v: valid on disk) has a bundle id and display name (lsappinfo: LSDisplayName “Spike One”), and UNUserNotificationCenter answers there (getNotificationSettings: authorizationStatus 0 = not determined, no prompt); outside a bundle mainBundle.bundleIdentifier is nil. A hard link does not work: codesign replaces the file, the link count drops to 1 (copy, never link). Showing the permission prompt and a delivered-notification readback need one human click and were not run: dev falls back to osascript until the owner allows once; real notifications are certain only in --bundle builds. S5 yes for URLs: an NSAppleEventManager handler for kAEGetURL registered before finishLaunching receives the URL on a cold start (open zspike://cold/start?x=1 launched the app and delivered it) and on a running app; LaunchServices registers the bundle only at the devapp cache path (a bundle under /private/tmp is not found: lsregister -f after each refresh), so the handler needs no delegate wrapping; the dock menu stays class_addMethod on SDL’s delegate class (not measurable unattended). S4 not measurable unattended (a file panel waits for a click): decision NSOpenPanel directly in system_macos.mm, which also gives the sheet attachment. S10 yes: zinc native-gen accepts the three-function spec supports(): string, call(op, json): string, poll(): string (and a onEvent(cb) callback as process has); a Promise return is not expressible (Z5010), so async operations return a request id and finish with an event | Measured with a 60-line Objective-C++ program and the real native-gen; what needs a click or a screenshot is recorded as such, not guessed | Evidence for tray, dock and menu title screenshots, and the delivered-notification readback, when the owner runs ZINC_SYSTEM_SELFTEST once on the Mac | | D28 | Spike S1: order ranks (ZN-173) | Ranks do not pay on real Zinc frames; the kill criterion (draw calls <= 40% of kind-break batching) is not met. tools/rank-study on 6 frames (hero 3, 8, 12; navigation 3, 8, 12): painter-order batching already needs only 36 to 84 batches for 1045 to 1342 primitives (the recorder emits shapes grouped by kind); ranked batching needs 49 to 55 (hero 65% to 88% of painter, navigation 136%, 302 vs 323 in total). Decision: the GPU backends keep painter-order batching with lookback merge (architecture section 4.x rule 4) as the default; the rank tree (ZN-177) and the rank-driven batch planner (ZN-178) become optional T3+ work behind a measured need, not a prerequisite. The CPU-cost half of S1 (5k prims < 0.5 ms on the Pi) is moot. Report: docs/reports/render-rank-study.md | Scored by evidence: ranks 1/5 on this corpus, painter order 4/5 | A scene with deep overlap (many small interleaved kinds) in the benchmark corpus | | D29 | GL floor (ZN-176, amends D11 and ZN-116) | The floor is GLES2 / GLSL ES 1.00 (T2: Pi 1/2/3, WebGL1); GLES3 / GLSL ES 3.00 (T3: Pi 4/5, WebGL2) and desktop GL 3.3 core (T4) are paths of the same code, not the baseline. D11’s “GL 3.3 / GLES3 via GLAD” is read as “desktop GL 3.3 core and GLES3 are supported”, not as the minimum. One GLSL source per shader with a generated prelude: #version 100 (ES2, #define ZN_ES2), #version 300 es (ES3, ZN_ES3) or #version 330 core (ZN_GL33), and macros for attribute/in, varying/out, texture2D/texture, gl_FragColor/fragColor. Features above the floor are probed by proc/extension availability (instancing: glVertexAttribDivisor, else EXT_instanced_arrays, else expanded quads), never by version. Matches the owner’s goal of full WebGL compliance from Pi 3 (WebGL1) to desktop (WebGL2) | Scored by reach: GLES2 floor 5/5 (the only API on the Pi 1/2/3 and the one the implemented renderer and the Pi validation use), GLES3 floor 2/5 (drops every Pi before the 4), per-version shader copies 1/5 (drift) | A Pi-class device needing GLES3-only features on every frame | | D30 | Spike S6: native T4 backend (ZN-199) | No sokol_gfx and no Dawn. Raw GL / GLES through the D29 prelude is the baseline on every target; ANGLE (Metal backend) is an optional, separately built libEGL + libGLESv2 pair for macOS only, behind the existing GL renderer, because Apple froze its GL at 4.1. Scores (1-5, sum of 8 criteria): ANGLE 29, sokol_gfx 26, Dawn 19. sokol: Zlib, one header, seconds to build, but GLES2 is not a target, sokol-shdc wants GLSL 450 with tags (replaces D29’s single-source prelude) and it is not a GL drop-in, so three.js and WebGL1/2 would need a rebuilt GL state machine. Dawn: WGSL only (rewrite of every shader), needs ES 3.1 or Vulkan so Pi 3 is out, 15+ pinned repos, tens of minutes of build, 8 to 15 MB. ANGLE: BSD-3, takes GLSL ES 1.00/3.00 as is, is the WebGL-conformant implementation that Chrome and Firefox use (matches the owner’s full WebGL goal), 8 to 11 MB per dylib on macOS, gn + depot_tools build with no official CMake (community zig/vcpkg builds exist). Linux and Pi 4/5 use Mesa GL/GLES directly, no ANGLE. Follow-up before adopting ANGLE-Metal: measure the macOS gn build time, a pinned prebuilt dylib size and the licence bundling; if too heavy, macOS ships native GL 4.1 with a core-profile prelude. Numbers not fetched (licence files, line counts, build times) are marked unverified in the agent’s report; the build-time claims are not measurements | Single-source shaders 5/2/1 (ANGLE/sokol/Dawn), WebGL 5/1/1, build cost 2/5/1 | The owner drops Pi 3 or WebGL parity (sokol becomes viable), or the ANGLE spike shows an unacceptable build or size cost | | D31 | File watching for zinc dev (ZN-141, revises the efsw plan) | Polling: the project tree (without build, dist, node_modules, .git) is stat-ed every 40 ms and the settled state (30 ms later) triggers a check and a restart; no efsw. A project has tens to a few hundred files, a pass costs well under a millisecond of CPU, it is the same code on macOS, Linux and the Pi, and the measured save to new process time is about 55 ms (the interpreter starts in milliseconds), far below the 2 s gate. The prototype does not keep module state across reloads either, so restarting the process loses nothing the prototype offers. Scores: polling reach 5 (no vendored library, no inotify/FSEvents/kqueue split), efsw latency 5 against 4, efsw cost 2 (a vendored C++ library with three backends) against 5 | A project with tens of thousands of files, or a battery-bound laptop where the 40 ms wake-ups matter: then efsw (MIT) behind the same snapshot() seam | | D32 | Shaped text per project (ZN-224) | Opt-in: zinc.json "text": "shaped" (or ZINC_TEXT=shaped for zinc run). Text with combining marks, Hebrew to Indic and Southeast Asian scripts, ZWJ, Arabic forms or emoji goes through src/text/layout.h over the embedded TTFs (the project’s assets/*.ttf are the fallback chain), glyph bitmaps cached by gid; Latin, symbols and CJK keep the codepoint tables, so all examples stay pixel-identical with it on. Default-on for desktop scored 3 against 5 (no visible gain for Latin, 1.5 MB of shaping code in every program); a run-time loaded plugin 2. zinc build links the tier only on request (+1.5 MB). Cross builds do not link it yet. | | D33 | Fonts beyond TrueType: CFF, system fallback, colour emoji (ZN-225) | No FreeType. stb_truetype reads CFF (OpenType OTTO) outlines, HarfBuzz shapes any of them, so FreeType (a 1 MB+ dependency with its own rasterizer, two rasterizers to keep identical) is not worth it: scored 2 against 5. The resource baker now accepts CFF fonts (embedded, no glyphs baked) and a baked font without glyphs always goes through the shaping tier. System fonts policy: a program that asks for shaped text gets, behind its own embedded fonts, a short per-platform list of system files opened the first time a code point is not covered or an emoji presentation selector (U+FE0F) appears: macOS Apple Color Emoji.ttc, Apple Symbols.ttf, Arial Unicode.ttf; Linux DejaVu Sans. No fontconfig/CoreText enumeration (no dependency, no scan cost); an app that needs a face ships it in assets/. Tiny profiles link nothing of this. Colour: sbix bitmaps (PNG strikes decoded with stb_image, box-scaled) are drawn; COLR/CPAL and CBDT (Noto Color Emoji on Linux) and variable-font instances are not yet. | | D34 | WebP and JPEG decoding speed (ZN-226) | libwebp 1.5.0 vendored (decoder and encoder, BSD-3): .webp assets bake like PNGs, ZINC_SHOT=*.webp and zinc capture --format webp write lossless WebP. libjpeg-turbo is not linked: stb_image stays the JPEG decoder. Measured (this Mac, M-series, -O2, 4032x3024 quality-90 JPEG of 1.5 MB, best of 5): stb_image 70.5 ms, libjpeg-turbo (turbojpeg, TJFLAG_FASTDCT, RGBA) 27.6 ms, 2.55 times faster, but the two IDCTs round differently, so baked pixels and goldens would change with the machine’s libraries; 70 ms for a 12 MP photo does not justify a second decoder. If a program ever decodes many large photos at run time, libturbojpeg can be offered through the same plugin route as the camera plugin. | | D35 | The style tasks no longer wait for the side records (ZN-251) | The visible style features (sizes, flex, z-index, borders, typography, themes, queries) depend on the tasks they really need, not on ZN-251 and ZN-189. The side records and the SoA node storage are a memory optimisation of UiNode (design: docs/reports/ui-style-system.md 5.3); until they land, a new property is one more field of UiNode, and ZN-251 later moves the extended ones into the record in one mechanical change that the pixel and dump goldens verify. Waiting for them held 20 tasks for a refactor that changes no behaviour. Scored: keep the chain 3, drop the dependency 7 (value to the owner now, same end state). | | D36 | Spike: baseline JIT for ZBC with sljit (ZN-150) | No-go, measured by the upper bound. AOT is what a perfect JIT would reach, so its speed over the interpreter caps the JIT’s (median of 11 runs, bench/m4.json, macOS arm64): fib 44.7 ms interpreter / 12.1 AOT (3.7x), nbody 494 / 110 (4.5x), mandelbrot 104 / 29 (3.6x), spectralnorm 775 / 65 (11.9x), sort 140 / 105 (1.3x), binarytrees 36 / 19 (1.9x), strings 47 / 34 (1.4x); a repainted UI page of 12 rows, buttons and a text field (tests/golden/ui-alloc): 0.09 ms per frame interpreted, 0.033 ms AOT (2.7x), and hero’s paint phase is 0.26 ms (p50) in the interpreter. A baseline JIT without register allocation reaches about a third to a half of AOT on numeric code (1.5x to 3x here) and almost nothing on code that is bound by the runtime (strings, sort, allocation, host calls), which is what UI code is: it does not reach the criterion of 2x on UI and numeric code. Every hosted target already has AOT (zinc build); the interpreter is 5x QuickJS. A JIT adds executable-memory handling (W^X, entitlements on macOS, impossible on iOS and consoles), a second code path to keep bit-identical with the interpreter and AOT, and a 16 KB+ dependency. Revisit only if an app must load new code at run time on a device without a compiler and the interpreter proves too slow there. JavaScript libraries on QuickJS are a separate question: D44. | | D37 | Where plugins live, and the GitHub organization (owner, 2026-10-08; ZN-335, ZN-352) | An organization zinc-engine holds the engine, one repository per official plugin, the signed index, a plugin starter and the app templates. Official plugins stay developed in the monorepo (plugins/) and are published as mirror repositories (git subtree split, tags, their own CI) while the native ABI moves (ZN_ABI_VERSION 1); a plugin leaves the monorepo for good only once the ABI is frozen (ZN-353). Community plugins live in their authors’ repositories: the index points at any git source and trust comes from signatures and rebuilds (ZN-340, ZN-341), not from the host. Scores (fit x3, performance x3, size x2, maintainability x2, licence/supply x2, portability x1, effort x1): mirrors first 5,3,3,5,4,3,4 = 55; separate repositories now 4,3,3,2,4,3,2 = 44 (one PR per plugin on every ABI change); monorepo only 2,3,3,4,3,3,5 = 43 (no per-plugin versions, CI or outside maintainers). Creating the organization and its repositories is the owner’s step. | Revisit when the ABI is frozen, or when a plugin gets an outside maintainer. | | D38 | Where the Nuxt UI-style kit lives (ZN-357, owner: “make the best choice”, 2026-10-08) | A std module, zinc:ui/nuxt in lib/std/nuxt/, written once for both UI models through the kit’s @jsxHelpers ./host like zinc:ui/kit; examples/nuxt-ui is its gallery and dashboard app. Scores (fit x3, perf x3, size x2, maintainability x2, licence x2, portability x1, effort x1): std module 15+9+6+6+10+4+3 = 53; example-local kit 9+9+6+8+10+4+4 = 50. The std module wins on fit: the app templates of M19 (ZN-315..) and any app can import it, as they import zinc:ui/kit; the cost is a public API to keep stable and the module registration. Nuxt UI’s theme is Tailwind classes, which zinc:ui already reads, so the kit maps its classes rather than porting Vue code; its tokens carry the MIT notice of nuxt/ui. Revisit if the kit stays used by the example only after M19’s templates land. | | D39 | How AppImages are made (ZN-320.03) | appimagetool (MIT, pinned with its SHA-256 like zig) on a Linux host or the Linux CI job; zinc export --appimage on macOS refuses and says so. An AppImage is the type-2 runtime ELF followed by a squashfs image, and nothing on this Mac writes or reads squashfs (no mksquashfs, unsquashfs, appimagetool; the runtime’s squashfuse is Linux-only), so no option can be verified here, and the AC itself is a run in a Linux container. Scores (fit x3, effort x2, licence x2, reach x2, risk x2): appimagetool on Linux 12+10+10+4+10 = 46; our own uncompressed squashfs writer plus the pinned type-2 runtime 15+4+10+10+4 = 43 (any host, but a file-system writer is the kind of format the rules say not to reinvent, and it is unverifiable without Linux); libsquashfs of squashfs-tools-ng built with zig 15+4+4+10+6 = 39 (LGPL-3 inside the engine). Parked with the Linux-only runs (ZN-054 family, ZN-393): the .deb (ZN-320.01) already covers Debian-family Linux from any host. | Revisit when a Linux runner exists in the CI (then the pinned appimagetool), or if macOS-to-Linux AppImages are asked for (then the own writer, tested on that runner). | | D40 | What zinc carries (ZN-330, owner 2026-10-08: “everything optional, the engine as agnostic as possible”) | Four verdicts, measured on macOS arm64 (bytes written into zinc from a linker map, zero-fill excluded, total 9.4 MB of code and data in an 11.7 MB file; clean build time of each library alone at -j3, 106 s for everything). Plugin module, done: WebGL (src/gl, glad, glslang) in libzn_webgl loaded on the first QuickJS context (ZN-330.01): zinc 13.98 to 11.66 MB; -DZN_WEBGL=OFF skips its 16 s of build. Plugin module, next: SDL3 1.56 MB / 10 s as the window backend (ZN-330.03); HarfBuzz + SheenBidi 1.09 MB / 15 s for "text": "shaped" only (ZN-330.04). Optional build flag (exists): mbedTLS 0.57 MB / 5 s (ZN_TLS: https, MQTT over TLS, crypto.subtle); the C test module Fixture 3 KB (ZN_NATIVE_FIXTURE); the graphics host (ZN_HOST_GFX). Core, kept: the compiler (frontend 1.51 MB / 11 s, ir 0.36 MB / 4 s, zbc 0.15 MB, aot 0.08 MB), the runtime and interpreter (rt 0.11 MB, vm 0.07 MB, mimalloc 0.10 MB, regexp 0.09 MB: RegExp of every program), zinc:gfx and the headless HAL (0.27 MB / 4 s), the resource baker (0.25 MB / 8 s: zinc build bakes fonts and images), the toolchain manager (0.26 MB), yyjson (0.21 MB), libunibreak (0.14 MB: Intl.Segmenter and line breaking), Yoga (0.07 MB / 4 s: the rn layout of every zinc:ui program that asks for it), libuv + llhttp (0.14 MB: zinc:http and the event loop), libyaml (0.06 MB), libwebp (0.37 MB / 3 s: WebP assets and zinc capture), QuickJS-ng (0.77 MB / 6 s: the second engine of D3 and zinc:script of D22; it becomes a module only with the minimal per-architecture zinc of ZN-348), and the CLI tools (sim, prof, lsp, dev, templates: 0.4 MB together). Scores per part (size saved x3, users who need it x3 inverted, effort x2, risk x2, 1-5): WebGL 5,5,4,4 = 46; SDL3 5,3,2,2 = 32; shaped text 4,4,3,4 = 38; mbedTLS as a module 3,2,3,4 = 29 (most network programs want https: the flag is enough); QuickJS 4,2,2,3 = 28; Yoga, libuv, libyaml, libwebp, unibreak 1-2,1-2,3,4 = 20 to 26 (under 0.4 MB each: a module costs more in loading code and packaging than it saves). | Revisit with ZN-348 (a runtime-only zinc per architecture drops the compiler, the CLI tools and QuickJS), or if a part grows past 0.5 MB. ZPK reader and decoders (ZN-433, macOS x86_64): 215.6 KB as zinc links them (reader 7.0, lz4 70.3, zstd decoder 136.6), 127.0 KB dead-stripped; core (lz4 everywhere, zstd off for ESP32); the writer’s 0.57 MB only in the CLI. | | D41 | The TUF client of the plugin and template index (ZN-336.01) | A minimal C++ client of the TUF 1.0 specification in src/tc (root chain, timestamp, snapshot, targets, delegations by path, thresholds, expiry, rollback and hash checks), over the Ed25519 of monocypher already vendored (D-update, ZN-324), yyjson and OLPC canonical JSON; metadata in the spec’s own format, so the repository side can be written by python-tuf (reference implementation, MIT / Apache-2.0) and an interop test reads python-tuf’s metadata with our client. Evidence: no maintained general-purpose C or C++ TUF client exists (the TUF project and Repository Service for TUF list python-tuf, go-tuf, tough in Rust and tuf-js; Uptane’s aktualizr is an automotive stack around Boost, libsodium and OSTree); the client side of the spec is about 600 lines of verification logic over primitives we already ship; python-tuf 6.0.0 installs with pip for the CI and the interop test. Scores (fit x3, performance x3, size x2, maintainability x2, licence x2, portability x1, effort x1, 1-5): minimal C++ client 5,5,5,3,5,5,3 = 64; tough as a Rust static library 3,5,3,4,5,3,2 = 53 (a Rust toolchain in every build of zinc and of the cross targets); go-tuf as a helper binary 2,4,2,4,5,3,3 = 46 (8 to 10 MB per target, a process per check); python-tuf’s ngclient 2,3,1,5,5,2,4 = 43 (a Python runtime on every machine, the ESP32 path impossible); aktualizr 2,4,1,2,3,2,1 = 33. The attacks the client must refuse are the spec’s (freeze, rollback, mix-and-match, arbitrary package, threshold, delegation) and are its tests (ZN-336.02). | Revisit if a maintained C or C++ TUF client appears, or if TAP 4 (multiple repositories) or TAP 8 (key rotation) is needed. | | D42 | The transparency log of publications (ZN-343.01) | A static Merkle log beside the index on Pages: RFC 6962 / 9162 hashing (leaf SHA-256(0x00 || entry), node SHA-256(0x01 || left || right)), one entry per published artifact (its index path and SHA-256), a checkpoint (size, root) signed with Ed25519 in the index’s canonical JSON form, an inclusion proof per artifact and a consistency proof from every earlier published size, all written by tools/tlog in the publishing job; the client (src/tc/tlog.cpp) checks the checkpoint signature, the inclusion of what it installs and the consistency with the last checkpoint it saw. The construction is the one of the Go checksum database and Sigsum (static files, signed checkpoints, proofs the client checks itself); Sigstore Rekor is the same idea as a service. Scores (fit x3, performance x3, size x2, maintainability x2, licence x2, portability x1, effort x1, 1-5): static log on Pages 5,5,5,4,5,5,4 = 67 (same host, mirrors and offline cache as the index, no service to run, about 150 lines over the SHA-256 and Ed25519 zinc ships); Sigstore’s public Rekor 3,3,3,3,5,3,2 = 45 (an external service and its API versions, a network round trip per check, no C++ client, its own trust root); a self-hosted Rekor 3,4,3,2,5,3,1 = 45 (a server and its database to operate). A malicious but signed release then has to be appended where everyone sees it, and a log that forks or rewrites its history fails the consistency check of every client that saw it before. | Revisit when witnesses can cosign checkpoints (C2SP tlog-witness), or if the index moves to Sigstore. | | D43 | What zinc run executes (ZN-398, owner 2026-10-09: “in the prototype, run was not the VM; even run had very good performance”) | A program that draws is compiled to native code and the executable is cached; the interpreter stays for zinc dev, --interp and test runs. Measured on bouncing-ball with 200k balls in a window (macOS arm64, 10 s runs alternated): interpreted run 23.9 fps, compiled run 31.7, prototype run (native) 33.7; startup of a cached run 0.27 s against 0.23 s interpreted, first run of a version about 4 s more (the C++ build). Options scored (speed x3, first-start latency x2, dev loop x2, test determinism x2, effort x1; 1-5): (a) compile and cache for programs that draw, interpreter for dev, --interp and headless or deterministic runs: 5,3,5,5,4 = 42; (b) interpreter by default, --release flag: 2,5,5,5,5 = 37 (the owner’s first impression is the slow path); (c) start interpreted and swap to the compiled program on the next run: 4,5,4,4,2 = 35 (two engines per session, a first run that misleads); (d) compile everything, command-line programs too: 5,2,3,3,4 = 33 (a script pays 4 s for no visible gain). The cache key is the program’s C++ with its baked resources, zinc.json, the engine binary and libraries, the plugins and CXX: a change anywhere rebuilds; no compiler or a failed build falls back to the interpreter with a line on stderr. Command-line arguments now reach compiled programs (zinc:sys args()). ZN-605: the run cache also records the files the compile and the bake read (sources, zinc.json, assets, fonts, plugin directories, with times and sizes) and the engine and environment; a cached run checks them and starts the executable without compiling (bouncing-ball 0.27 s to 0.16 s, the app alone 0.15 s; hero about 1.9 s to 0.2 s, its compile being 1.2 s), and a changed input is compiled once in-process (no zinc build subprocess), the C++ key deciding whether the native build runs. | Revisit when the interpreter reaches compiled speed on drawing programs, or if the first-run build time grows past 10 s (then build in the background and run interpreted meanwhile). | | D44 | QuickJS speed: AOT, JIT and the native boundary (QJS-01; docs/reports/quickjs-aot-jit-and-ffi.md) | No QuickJS JIT. Fix the boundary and accelerate library hot spots now; QuickJS bytecode -> C AOT only through a gated spike. Measured on the M1 Pro: QuickJS spends 2.9-4.8 ns per bytecode, 85-97% of samples in the interpreter loop, and per-opcode fast paths change workloads by 0-5%; V8 jitless is 1.7-2.7x faster (the ceiling of interpreter work), V8’s JIT 8-36x. A hand-made bytecode -> C translation of the sprite loop is 5.9x faster (7.4x with inline caches), of three.js’s Matrix4.multiplyMatrices 4.9x, bit-identical with -ffp-contract=off; speculative code 85x. The boundary costs 20-45 ns per call except typed arrays (1,360 ns: an exception used as a type test, ZN-567) and the host thunk (139-156 ns: two vectors); a typed fast-call path saves another 25-30%; JS-side command buffers cost 300-430 ns per command and are rejected; one native accelerator cuts the three.js frame by 24%. Options scored (gain x3, targets incl. iOS/consoles x2, effort x2, risk and maintenance x2, upstreamability x1; 1-5): (a) boundary fixes, typed fast calls, accelerators, bytecode cache: 2,5,5,5,4 = 40; (f) a JIT engine (JSC/V8) on desktop, ZN-586: 5,1,3,3,3 = 32; (b) bytecode -> C AOT with ICs: 3,5,2,3,2 = 31; (e) interpreter work in a fork (ICs, register VM, 64-bit NaN-boxing; upstream removed its ICs in 2025): 2,5,2,3,3 = 29; (d) optimizing JIT for QuickJS: 5,2,1,1,1 = 24; (g) Zinc’s typed AOT for npm libraries: 1,5,1,2,3 = 22; (c) copy-and-patch baseline JIT: 3,2,1,2,1 = 20. Do (a) now (ZN-567, QJS-02/03/04/05/08, ZN-575), (b) as the spike QJS-06 (the only option that raises the ceiling on every target, iOS and consoles included) with a gate (>= 2x on the three.js and matter-js frames, code <= 15x the bytecode, test262 identical), (e) only as upstream proposals (QJS-07 if (b) is a no-go), (f) stays ZN-586; reject (c), (d), (g). Engine changes live in a patch queue on pristine quickjs-ng, each proposed upstream. | The interpreter, not the binding, is the compatibility tier’s ceiling; native code is the only large lever, and only AOT native code runs on every Zinc target (iOS 9 and consoles forbid executable memory); Zinc already ships the C toolchain and a compile-and-cache run model (D43). Consistent with D13/D36: JS libraries cannot use the typed AOT, but their bytecode can be compiled to C. | QJS-06 below 1.5x on library frames (then QJS-07 and the native tier carry the load); a product that must run downloaded JavaScript fast on desktop (then ZN-586); quickjs-ng merging inline caches or a fast-call API upstream (then drop the patch). | | D45 | Which fonts a program bakes (ZN-428) | The embedded TrueType files are subset with hb-subset to the scanned characters plus Latin-1, Latin Extended-A and common punctuation (a "text": "shaped" project keeps the whole files); the grid font’s 32, 48 and 64 px tables are baked only when the program’s own files call gfx.text; the library’s files count for sizes and characters (a kit Button really is text-sm) but not for faces and case maps (italic, font-mono, uppercase in lib/std/ui.ts are table entries, not uses); gfx.font keeps the prototype’s answer, the closest baked size and -1 for a family not baked (an exact size rasterized from the embedded file changed tests/conformance/canvas2d, whose monospace falls back to sans in the prototype; reverted in ZN-604). Measured with zinc bake: game-2d 1,706 KB to 307 KB, kit-gallery and text 3,576 KB to 786 KB, hero 6,805 KB (7.8 MB before the subset) to 1,467 KB; bake time unchanged (1.2-1.5 s for hero). Rejected: skipping the library’s files altogether (kit-gallery 209 KB) because the runtime rasterizer (runtime/ttf.cpp, exact coverage) and the baker (4x4 supersampling) differ by up to 40 levels on antialiased edges, so baked sizes must cover what the program draws to keep the prototype’s pixels (canary 1 of 4 against 4 of 4). | Revisit if the runtime rasterizer is made identical to the baker’s (then only the program’s own sizes need baking), or for MSDF fonts (ZN-442). | | D46 | The AOT program diet, part 2 (ZN-604) | A program with more than 2 MB of generated C++ is compiled as up to 3 translation units at once (contiguous functions with external linkage; the first unit has the tables and main; small programs such as the benchmarks stay one unit with static functions); the generated C++ is compiled without the stack protector (it indexes its frames with constants only) and linked without local symbols (ZN_KEEP_SYMBOLS=1 keeps them for profilers); compiled reference counting skips the zinc mem statistics, which only the interpreter keeps (op::retainC, op::releaseC); exception handlers test their class out of line; the vendored SDL3 is built without its dynamic API table (a two-line patch behind ZN_SDL_NO_DYNAPI), whose pointers kept every SDL function from dead-stripping; the module and the baked resources are string literals instead of lists of integers; the checks the functions repeat are short macros. Measured on hero (macOS arm64, M1 Pro, -j3): binary 9.65 to 7.62 MB, zinc build 43.8 to 13.9 s, generated C++ without the resource literal about 16 to 9.55 MB (13.7 MB with it); interpreter and AOT frame hashes identical on hero, nuxt-ui, rn-showcase and bouncing-ball. Rejected, measured: retain and release out of line (another 0.6 MB, but binarytrees +9.6%, mapset +4.4%, dynsum +2.5%); releaseC forced inline everywhere (+0.8 MB, +13 s, no kernel faster); -Os (7% less code, same time); .incbin of the resources (inline assembly that the cross and wasm builds do not all accept). | Revisit with ZN-427 (an object cache per function makes a one-function rebuild take seconds) and ZN-413 (fewer retains and releases to emit). | | D47 | Tiles in the software raster (ZN-410) | A frame of 2048 commands or more is binned once into 16 x 16 tiles, walking the commands from the last to the first: a tile takes a command unless an opaque square-cornered RECT or the CLEAR above it already covers it whole outside any clip, and the walk stops once every tile is covered; each band then paints its tiles’ lists with the same code as render(), CLIP and UNCLIP replayed in every tile their box touches. A frame keeps render() when the tiles would repeat more set-up work than they hide (4 x the extra calls of rounded shapes, text, polygons and images against the hidden commands; then no binning for 30 frames), past 16 nested clips, or with ZINC_TILES=0. Measured (zinc capture --bench, M1 Pro): the 200k-ball scene at 1280x960 22.4 to 8.3 ms on 1 thread, 13.9 to 6.3 ms on 4, same pixel hash; the render corpus keeps its hashes, b1-quads 1.77 to 1.23 ms on 1 thread, the others within noise. Rejected: two forward passes (count, then fill) 12.9 ms, the binning being 77% of it; binning every large frame (b2-rounded, 6000 overlapping rounded rects, 14 to 54 ms: each tile sets up the shape’s rows again). | Revisit with a GPU backend by default (PERF-13), or to bin in parallel when one band thread binning stalls the others. | | D48 | How a WebGL canvas reaches the page (ZN-411) | gl.zincPresent scales the canvas down and flips it on the GPU (glBlitFramebuffer, linear for the 2x supersampling: the average of 4 texels) and reads it into a pixel buffer object without waiting; the read is copied into the Surface’s image in the next frame’s present, or at the host’s frame end when no present follows (a canvas drawn on demand). The canvas is shown one frame late, like a browser compositor; deterministic runs read in the same frame (ZINC_GL_PRESENT=sync|async chooses). GLES 2 and 3x / 4x supersampling keep the full read averaged on the CPU. Measured on webgl-cube in a window (Retina, 1440x960 canvas, sample of the main thread): zincPresent 79% to 2.8% (the full read: 30%, of which the GPU wait 32% and the driver’s detiling 21% once the downsampling moved to the GPU; the CPU 2x2 average 45%); 65 to 80 fps with frame hashing on. Rejected: a synchronous read after the GPU downsampling (the wait for the GPU stays, 62% of the main thread); composing the canvas texture on the GPU (the window HAL composes on the CPU; with a GPU 2D backend, PERF-13, the canvas texture can be drawn without any read); shadow state for compositeClear (not visible in the samples). | Revisit with the GPU 2D backend (PERF-13), or if a program needs the canvas in the same frame outside tests. | | D49 | Fewer GPU primitives in the GL replay (ZN-412.01) | The display-gl renderer draws the opaque square RECTs and CLEARs outside clips first, front to back with depth writes, through a program of their own (one 24-byte instance a rectangle on desktop GL, four 16-byte vertices on GLES 2), then every other command depth-tested at its place in the paint order; a list of 2048 commands or more is first culled on the CPU (zrt::raster::cull: 8 px tiles, walking back from the last command, rows of open tiles as 64-bit masks, superblocks of 8 x 8 tiles), so commands covered whole are never submitted. The frames are the same with and without either (ZINC_GL_OCCLUDE, ZINC_GL_CULL). Measured on bouncing-ball 200k in a window (M1 Pro, 640x480 surface, GPU timer queries): replay 15.4 ms (GPU done) and 5.5 ms of CPU before; occlusion alone 4.0 ms of GPU and 2.0 ms of CPU (a tiling GPU removes hidden pixels itself, the primitives are what cost); with culling 1.4 ms of GPU (46k of 200k quads) and 3.6 ms of CPU, 2.9 of it culling. Rejected: culling on 4 threads by bands of tile rows (slower: every band reads every command); 16 px tiles for culling (balls of 8 to 18 px rarely cover one). ZN-412.05: the display-gl surface of bouncing-ball is 320x240, so the tiles now take 2/3 of the opaque rectangles’ mean size (4 px there: 7.9k quads instead of 46k), the culling list comes out of the walk front to back with the opaque ones flagged (no pass over every command afterwards), and each command’s tile range is packed first on 4 threads (the memory-bound half), the walk reading 8 bytes a command: 0.66 ms of GPU and 1.77 ms of CPU submission. A software prefetch in the walk gained nothing. | Revisit with ZN-590 (GPU strokes and fills). | | D50 | Which loads lend their value without a retain (ZN-413) | Each load (GetField, GetGlobal, ArrGet, Map.get) is judged on its own: its value is lent, with no retain and no release, when no use consumes it (a call argument, a store, a return, an edge) and no instruction in its live range may release a reference or run program code: calls, stores of references, console output, a ToStr of an object, and the runtime rows that release or call back (rtMayRelease, false for the string rows, the read-only Array, Map and Set members and the graphics host). What it was read from stays alive meanwhile: a global (nothing in the range can store to it) or the anchor, the tracked value at the root of the chain of loads, whose last use moves to the lent value’s last use. This replaces the rule that lent only in functions without any call. ZN_NO_LEND=1 retains every load, to measure. Measured: bouncing-ball’s frame function (everything inlined) keeps 1 retain (the HUD’s) instead of 9; zinc mem at 200k balls, 1 retain per frame instead of 400k; the compiled 200k-ball loop 2.70-2.85 to 2.51-2.68 ms per frame (headless, 1100 minus 100 frames, two alternations). An ASan and UBSan build ran 107 programs (the run goldens, the kernels, tests/data/rc_lend.ts) without a report and with the same ZN_LEAK_CHECK results with and without lending; run goldens and destruction order unchanged; 8 ZBC disassembly goldens regenerated (fewer RC ops, shifted offsets). Not done: borrowed parameters (a callee that never consumes a parameter does not release it and its callers do not retain it; a convention per function and per vtable slot, ZN-606); the release fast path inline was measured in D46 (+0.8 MB, no kernel faster) and stays out of line; compiled code already skips the zinc mem counters (D46). | Revisit when a profile shows retain and release pairs around calls of methods that are not inlined (ZN-606), or if a runtime row is added that releases or calls back without being listed. | | D51 | Borrowed parameters (ZN-417, ZN-606) | A reference parameter that its function never consumes (no store, no return, no edge, no call argument whose callee owns it) is borrowed: the caller keeps its reference across the call and the callee neither retains nor releases it. The convention is inferred over the whole program as a greatest fixed point, per function and per vtable slot (a slot borrows what all its methods borrow, and its methods follow the slot); the methods the runtime calls (call of closures, __errorString) keep owned parameters, because runClosure, callComparator and exceptionText hand over a reference. The native module ABI is unchanged (native calls never consumed). With it, a call of a function that cannot store a reference (no store of a reference, no runtime row that releases or calls back, no console output, no native call, no throw, and only calls of such functions; per vtable slot for virtual calls) no longer ends the live range of a lent load (D50). ZN_NO_BORROW=1 restores the previous convention, to measure. Chosen over ZN-417’s first plan (every parameter borrowed by default, the callee retaining what it keeps, a native ABI version bump): inference changes no ABI and no back end, since retains and releases are IR operations every engine runs as written. Measured on the Intel Mac: hero start (zinc mem, 1 frame) 70,130 to 56,398 retains (-20%), 60 frames 324,597 to 240,119 (-26%), allocations and frees unchanged; AOT script time of maps/navigation’s paint 7.18 to 7.05 ms per frame (-2%), hero’s frames unchanged (a retain is an inline increment in compiled code). ASan and UBSan over 132 programs (run goldens, the M3 conformance set, the kernels, examples/lang): no report; rc (no leak over the corpus, the frozen destruction order) passes; 10 ZBC disassembly goldens regenerated. The rest of hero’s start is stores of shared sentinel objects into the fields of each UiNode (13,770 retains in its constructor), which a store must count: ZN-616. | Revisit if the runtime starts calling another method by name (it must then keep owned parameters), or with ZN-616’s immortal constants. | | D52 | Immortal constant objects (ZN-616) | Not done: shared defaults read from module constants and stored into fields keep their retain and release. Measured on the Intel Mac, AOT: 2,000,000 objects each storing 6 shared sentinels into fields (12 M retains, and 12 M releases when they die) take 125-132 ms, the same loop storing null 89-91 ms: about 1.5 ns per retain or release. hero’s start does 13,770 such retains in UiNode.constructor, about 40 us of a 200 ms start (0.4 ms on a Pi 3, ten times slower). Making the objects of never-reassigned constants immortal would remove that, but the program-end teardown would then have to free them after everything else and release their children without freeing twice when two constants point at each other, and the frozen destruction order (tests/golden/rc/order.trace) would change: real risk (a use-after-free at exit) for no visible gain. Retain and release counts are not a cost target by themselves: D50 and D51 were kept because they also removed work (2-26% fewer operations in loops, -2% paint on navigation). | Revisit if a profile of a real program shows retain or release above 2% of its frame or start time. | | D53 | Interpreter hot paths (ZN-421) | Not done: the three changes of PERF-22 do not pay on the measured loop, so the interpreter is unchanged. bouncing-ball with 200k balls, interpreted, headless, ZINC_PROFILE effects p50 over 120 frames, idle Intel Mac, 3 alternations: the original 18.98-19.11 ms (AOT 8.2 ms). Testing the hostFast table in L_Rt before rtCall (which tests it first anyway): 19.05-19.28, no gain. A SetField that skips the field-kind look-up (upper bound, unsafe for reference fields): 17.31-17.46 (-9%). Done safely (the verifier marks the stores whose field holds no reference, the interpreter rewrites them in its code copy into an internal opcode, no ZBC change): 20.5-20.9, slower, and 20.5 with the rewrite switched off in the same binary: adding a handler to the dispatch function changes the compiler’s code for the whole loop by more than the store saves. Globals are already one indexed load. The loop runs about 45 instructions per ball at about 2 ns each: what would pay is fewer instructions (constant globals as immediates, fused field updates such as x += vx * dt, the width()/height() host calls per ball), measured with the M4 kernels to keep the dispatch layout honest. | Revisit with a plan that cuts the instruction count, or when the interpreter runs on a device core where it is the only engine. | | D54 | Profile-guided builds of zinc (ZN-424) | Not adopted: the release build stays without a profile. tools/pgo builds an instrumented zinc (-fprofile-instr-generate), runs the training set (the M4 kernels interpreted, a compile of examples/hero, 90 headless frames of hero, nuxt-ui and bouncing-ball), merges the 10.7 MB profile with llvm-profdata and builds zinc with -fprofile-instr-use (Apple clang 17, -O2). Measured against the normal build on the idle Intel Mac (median of 5): M4 kernels interpreted from -6% (dynsum) to +7% (fib, empty), about -1% overall; a compile of hero -2%; bouncing-ball 200k interpreted +2%; hero’s frames unchanged. The task kept PGO only at 5% or more. The binary is smaller (11.9 against 13.8 MB) since cold code is optimised for size. | Revisit with another compiler or flags (-O3, LTO across runtime and host, PERF-24), on another machine (a Pi, where caches are small), or with a training set from a real application: tools/pgo does the whole measurement. | | D55 | QuickJS bytecode to C, the qjs2c spike (ZN-599, QJS-06 of D44) | No-go: no ahead-of-time compilation of QuickJS bytecode; the levers of D44 (native accelerators, inline caches as an upstream proposal, ZN-600) carry the speed of JavaScript libraries. tools/qjs2c copies the interpreter’s own opcode bodies per instruction (constant operands, direct jumps, no dispatch; exceptions return to the interpreter’s frame; patch 0002-aot-hooks binds at the first call). Measured on the idle Intel Mac against the interpreter, the 30 hottest functions of each workload compiled: three.js 500 meshes 7.31 -> 5.71 ms (1.28x), sprite 1.40x, objects 1.08x, matter-js 300 bodies 9.42 -> 7.32 ms (1.29x); the gate was 2x on three.js and matter-js. Code is 53-61x the bytecode (gate 15x). Correctness holds: the QuickJS T1 programs (483 functions compiled) and the test262 regexp subset (861 functions, 2,533 of 2,557, the same failures) match the interpreter. 71-72% of the time stays inside the compiled bodies: stack traffic through memory (a C-array stack measured the same: it escapes into the slow paths), a refcount pair per local read, a shape lookup per property access, integer-only fast paths, the generic call entry. Report: docs/reports/zinc-next-qjs2c.md. | A copy of the interpreter removes only the dispatch, as quickjs-aot (+36%) and the quickjs-ng maintainer (1.2-1.5x) said; the hand translations’ 5-7x come from compiler work (stack to SSA, borrowing, inline caches, unboxed doubles), i.e. an optimizing JIT done ahead of time, a project of its own with a 15x+ code cost. | A product needs 2x or more on JavaScript library frames on a target without a JIT (iOS, consoles) after accelerators and inline caches; or quickjs-ng gains inline caches or a register VM upstream (smaller, faster bodies to copy: rerun the gate with tools/qjs2c, kept off by default); or a partial evaluator of C interpreters (weval-style) becomes usable with clang. | | D56 | Inline caches in the QuickJS interpreter (ZN-600, QJS-07 of D44 after D55) | Not adopted: the QuickJS interpreter keeps its own property lookup. Monomorphic per-site caches for get_field, get_field2, put_field and get_var (own properties checked by the atom at the cached index; prototype properties up to three deep through referenced hashed shapes; globals guarded by global_var_obj’s shape and size; a table per function after 32 calls or back jumps) hit 89-99.9% of accesses and change nothing that matters on the idle Intel Mac: three.js 0.98-1.00x, sprite 0.97-0.98x, objects 1.01-1.06x, matter-js 0.98-1.02x (gate: 15% on two workloads, none slower); the classic variant that hits on one shape comparison is slower (sprite 0.94-0.95x). Correct on test262 regexp and the QuickJS T1 programs; 1.6-5 KB per warmed function. The patch is kept as docs/reports/zinc-next-qjs-ic.patch, report docs/reports/zinc-next-qjs-ic.md. | QuickJS’s inlined find_own_property already costs about what a cache check costs, the interpreter’s time is in dispatch, stack and refcounts (D55), and the larger JS_CallInternal loses a few percent; upstream removed its caches in 2025 (1.037x) and closed a monomorphic one in 2026 for flat results. | ZN-600.01 measures the patch on Apple Silicon (the criterion named the M1 Pro) and adopts it as patch 0003 only if it passes the gate there; or quickjs-ng changes its property lookup or its dispatch. | | D57 | The typed WebGL API of compiled Zinc code, zinc:gl (ZN-562) | zinc:gl keeps WebGL’s names and argument order and types what WebGL leaves as any; the QuickJS binding is not moved onto zinc:gl’s rows. Every method of the QuickJS binding (243, WebGL 1 and 2 and the extensions’ methods) has a zinc:gl method of the same name, generated with its host row and C++ from one table (tools/gen-zinc-gl). Typed arrays are GLArray subclasses holding a Zinc array and an element kind (readPixels and getBufferSubData fill the array given); the queries that return any return a GLParam (kind, numbers, text, object id); getExtension returns a boolean and the extension’s methods are the context’s (createVertexArrayOES…); bufferData with a size is bufferDataSize; present(image) replaces zincPresent; a lost context is simplified (no DOM events; restoreContext gives a new context). Both bindings call the same WebGL1 class, which holds the validation; tests/golden/webgl/api_tour.{js,ts} prints the same 27 lines through QuickJS and zinc:gl (interpreted and built). | Moving the QuickJS binding onto the rows would add the HostCall decoding (about 40 ns, ZN-562.03) to every JS call and change no validation; a typed API cannot return any, and generating the 245 rows keeps the three layers in step. | The WebGL1 conformance runs through zinc:gl (a port of the pages to Zinc), or a type of any lands in Zinc. | | D58 | zinc:3d on the GPU (ZN-563) | A second native backend of zinc:3d, render3d.gl.cpp (GL 3.2 core / GLES 3 subset, its own SDL3 context, like display-gl), reproduces the software renderer’s shading; option renderer: auto (default) = the GPU where the target has the backend (macOS now) unless the run is deterministic, software, gpu; ZINC_3D overrides it. Same interface as the software renderer, which it includes as the fallback (no context) and the oracle: per-vertex Lambert lighting clamped per channel, flat faces from screen derivatives, nearest texels, colour in screen space, triangles submitted counted (three.js’s semantics). The frame is read into the runtime image one frame late through a pixel buffer (synchronous in deterministic runs), RGBA bytes with the shaders writing blue first; the colour attachment is a texture because Apple’s GL fills a pixel buffer read from an RGBA8 renderbuffer with garbage. The GL calls are direct, not zinc:gl’s rows (C++ to C++, no validation per call). The window’s GL present now makes its own context current and puts the caller’s back (the host swapped zinc:3d’s hidden context, without vsync) | Scored: GPU backend in C++ 4/5 (2.4 ms for 1000 meshes vs 3.2 software and 73 for three.js on QuickJS, one source for GL and GLES), the same renderer in Zinc over zinc:gl 2/5 (4+ host calls per mesh, a decoded array per matrix), Phong / Standard per pixel now 1/5 (no specular or roughness in the interface; frames would leave the oracle) | A scene where the software renderer wins at default size (small scenes on a fast CPU): renderer: "software" in zinc.json; Phong / Standard with an interface that carries them | | D59 | iOS and Android targets (ZN-372, the mobile spike) | Both are viable: go, Android first, in the cross-platform pass at the end. Hosting: an SDL3 shell, the static engine with targets/macos/hal_sdl.cpp on SDL3’s UIKit and Android backends. Toolchain: zinc export --target ios\|android generates an Xcode or Gradle project that the platform clang builds, signs and packages; the pinned zig is not used for mobile. Target ids ios, ios-sim, android. Scores (RULES §4, max 70), hosting: SDL3 shell 62, our own UIKit and Android HALs 53, a web view around the wasm build 42, zinc:ui on native widgets 33; toolchain: generated project 60, pinned zig with the platform sysroots 47, prebuilt engine with bytecode only 42. The shell reuses the one HAL and keeps pixels and demos unchanged; the generated project is what every reference product does and the only option that meets Apple’s Xcode requirement without a second toolchain. Store builds: AOT by default, the interpreter and QuickJS only with code bundled in the package, plugins linked statically, no dlopen, no zinc:process on iOS, over-the-air .zapp updates off by default on iOS, zinc dev reload in debug builds only. Pixels: the software raster through SDL_Renderer first, GLES 3 replay on Android next, the iOS GPU back end a later measured decision. Nothing was built for a phone: the development Mac has neither Xcode nor the Android SDK, so the runs on a simulator and an emulator (criterion 1 of ZN-372) are tasks of M26. Revisit when SDL3’s text input or lifecycle proves inadequate on a device (then a HAL of our own for that platform), when the software raster misses 60 Hz on a mid-range phone, or when zig ships bionic and an iOS SDK. Full record, sources and what is not verified: docs/reports/zinc-next-mobile-spike.md (sections 10 and 11). | | D60 | The network log of the inspector (ZN-370) | One recorder in the host (src/host/netlog.cpp), not in the plugins: fetch, MQTT and sockets all end in src/host, so it sees every request and the HAR export (zinc run --har, ZINC_HAR) works without the inspector. One multiplexed row host.netLog(op, arg) (start, drain events, response body, request body) instead of four, because the host table is shared with the device cores and the zinc:gl rows. WebSockets are decoded from the socket bytes in the host (no hook in plugins/socket, shared with the prototype); MQTT clients and WebSockets both use the protocol’s WebSocket events, other outgoing TCP is a tcp://host:port request; a redirect is one entry per hop, as in a HAR. Recording starts when zinc:devtools loads, so requests made before DevTools attaches are listed; the inspector’s own loopback port is not recorded. Bounds: the last 1000 entries, 4 MiB per body, 10000 messages per stream, 8 MiB of undrained events. Secrets are masked at record time: Authorization, Proxy-Authorization, Cookie, Set-Cookie, header names containing token, secret, password, api-key, session or credential, the names in ZINC_NET_SENSITIVE, and user:password@ in URLs; query parameters, form fields and JSON fields named in ZINC_NET_SENSITIVE are masked too, and the HAR file is kept current while the program runs (both D63). Checked by tests/t0/netlog.sh (7 requests in the Network domain, HAR 1.2, TLS timings); the DevTools panel itself was exercised through the protocol and Chrome’s HAR importer, not looked at. | | D61 | Strokes on the GPU: a STROKE command (ZN-590.01) | A display that replays the command lists on the GPU (the macOS GL window, display-gl) sets gfx::gpu_strokes; gfx.stroke() then records a STROKE command (the polyline in physical pixels, its width, closed in pad bit 1) instead of stroke_contours’ outline. The GL replay draws one box per segment and the fragment shader takes the capsule distance (round joins and caps), a pixel belonging to the nearest of the segment and its two neighbours so it is blended once; the software raster tessellates a STROKE it meets (same outline, same frames at scale 1). Without a GPU display nothing changes (the outline is recorded, goldens untouched); ZINC_GPU_STROKES=0|1 forces either way. Differences from the software frame: round caps on lines thinner than 2 px (the software has none), analytic edges instead of 4x4 supersampling, a self-crossing translucent stroke blends twice where non-adjacent segments overlap | Scored: STROKE + capsule shader 5/5 (navigation in the GL window 232-247 -> 12.8 ms per frame, 836 CPU tiles -> 0.1), tessellating on the CPU and filling on the GPU 2/5 (needs the POLY fills of ZN-590.02 first, the outline is 10-40x the points), keeping the CPU tiles 0/5 | Strokes drawn with miter joins or dashes; plugins (canvas2d, svg) that tessellate their own strokes moving to STROKE | | D62 | POLY fills on the GPU: stencil then cover with distance fringes (ZN-590.02) | The GL replay fills LINE/POLY with the stencil: the contours’ triangle fans count the winding at the pixel centres (7 bits, nonzero by facing, even-odd by bit 0); the pixels near an edge get their coverage from the distance to it (an EDGE quad per edge, owned by the nearest of the edge and its neighbours), inside only where the edge bounds the fill there (winding 1 on its left, -1 on its right, so overlapping contours leave no seam), each pixel blended once through bit 7; a cover quad fills the rest; the stencil is zeroed over the box. The fringe quads are uploaded once and drawn by each pass. The canvas gradient paint (grad 4) and a framebuffer without 8 stencil bits keep the CPU coverage tiles; ZINC_GL_POLY=0 forces them. The framebuffer is depth 24 / stencil 8 (OES_packed_depth_stencil on GLES 2), the GL windows ask for 8 stencil bits. The svg plugin emits STROKE commands for its plain strokes when the display draws them (exactly abutting contours of a tessellated stroke would still show a seam) | Scored: stencil + distance fringes 4/5 (no CPU geometry for animated content: lottie 11.4 -> 2.8 ms, hero 4.0 -> 0.5, svg 3.6 -> 1.4, canvas 9.7 -> 3.9 to submit; GLES 2), CPU triangulation (libtess2 / earcut) cached by shape 2/5 (re-run every frame for moving content, a new vendored library), MSAA 1/5 (none on GLES 2, 4 samples against the raster’s 16, changes every other command’s edges), the CPU tiles 1/5 (836 per frame for a moving map) | Coincident opposite edges (abutting contours) in POLY input; a static scene where the tile cache was cheaper (navigation at rest: 1.0 -> 1.4 ms to submit) | | D63 | The inspector port, masked fields and a HAR that survives a kill (ZN-633, ZN-635, ZN-636) | The inspector port reaches the program as ZINC_DEVTOOLS_PORT, which zinc run sets from zinc.json plugins.devtools.port (0 takes a free port, named on stderr); fields of queries and bodies are masked for the names of ZINC_NET_SENSITIVE only; the HAR file is rewritten while the program runs, at most once a second, on the loop’s own turns. Port: an environment variable set by the CLI, like ZINC_KEYBOARD and ZINC_SCHEME (one consumer, and main.cpp stays wiring only); a board file’s plugins.devtools is not read and zinc build does not bake the port. Masking: the built-in header words (token, session…) do not apply to bodies, where such fields are often the data being debugged; masked at record time are query parameters (request URLs, WebSocket upgrade targets, Location), urlencoded form fields and JSON fields at any depth (request and response bodies, WebSocket and MQTT text). JSON fields are found by a scanner of strings and brackets in netlog.cpp (about 80 lines), not by yyjson: the host library links no JSON parser (yyjson would enter every AOT link line), the body must stay byte for byte, and truncated or line-delimited JSON is masked too; it builds no values, so it is a tokenizer, not the parser that RULES §3 forbids. Not masked: free text, multipart forms (ZN-640), plain TCP bytes, names written percent-encoded or with JSON escapes, JSON inside a JSON string. HAR: loop::pump() and loop::wait() call netlog::tick, which writes the whole file and renames it over the target when the log changed; no signal handler (it cannot build the HAR safely) and no libuv timer (a repeating timer wakes loop::wait early, and due setTimeouts then fire early); the file may be a second behind at a kill. Checked by tests/t0/devtools_port.sh (two programs inspected at once), netlog.sh and netlog_kill.sh (SIGTERM and SIGKILL; the test fails on the binary before the change). The tests take free ports from tests/free_ports.py, so the socket, dev_mode and netlog tests of two trees no longer collide (UDP ports of socket.ts: ZN-639). Not measured: the cost of rewriting a large log; not proven: an AOT-compiled run. Revisit when the host library links a JSON parser, or when a board file needs its own port. | | D64 | lib/std stays inside what the prototype compiles (ZN-637) | lib/std/ui.ts and what it imports keep compiling with the prototype, and tests/t1/proto_devtools.sh (the prototype builds and runs a zinc:ui + zinc:devtools app in the sim and as a macOS build, checked by scripts/cdp-check.mjs, about 25 s) guards it for every change to lib/std/ui.ts. Found by ZN-637: the new net member of the devtools Spec had broken nothing, but the prototype could build no zinc:ui program since 2026-10-08, so tools/proto-capture capture and tools/example-output could make no new prototype golden. Causes, each fixed where it broke: commandCount / commandsFree missing from lib/gfx.d.ts and the sim; UI_LAYOUT, UI_PRESET and the module zinc:__layout known to Zinc Next only (now constants of the prototype’s zinc:platform and a stub lib/compat/proto-layout.ts); onResize missing from the prototype runtime and the sim; a Map.get() number compared with undefined in propId (?? 0 now); a closure that calls itself in inspect() (a module function now; the prototype accepts such a closure and emits C++ that does not compile, ZN-642). Rejected: a frozen copy of lib/std for the prototype (two libraries to keep equal), and recursive closures in the prototype’s emitter (the prototype is touched for fixes only). onResize in the prototype is accepted and never called: a port of ZN-608 would change the prototype’s windows and needs teardown on hot reload; no task is filed for it. Checked on Zinc Next after the change: ui, ui_style, jsx_style, ui_a11y, perf_hud, scale_push pass and the build stays warning-free. Left: the prototype’s sim conformance fails on three zinc:net programs for another reason (ZN-641). | | D65 | When a program’s console output is written (ZN-638) | At each turn of the event loop, not per line and not only at exit. The typed engines kept console.log in Machine::out until exit, on a terminal as well as into a file or a pipe (only QuickJS used stdio), so there was no single place to fix: hostRt now calls Machine::flushOut() on host.evReady (the first host call of every turn of both prelude loops) and on host.loopWait (before the loop sleeps), and the QuickJS loops fflush(stdout) at the top of each turn. Machine::outLive is set only by runModule (zinc run, AOT programs, the wasm hosts): zinc profile, zinc mem and the device core keep the program’s output for themselves, unchanged. Rejected: a write per line (one write per console.log in loops that never yield, and three places to change); a write before every host call (it changes the stdout/stderr interleaving of goldens captured with 2>&1). Node is the reference for the visible behaviour (a line of a running program can be read from the file). Cost: one write of 5 to 8 us per turn that printed (docs/reports/zinc-next-perf.md, ZN-638); kernels unchanged (jsonout 0.99). Checked by tests/t0/console_live.sh (timer and frame loops, file and pipe, the three engines; zinc mem still gets the whole output). Left: output printed before a blocking host call or a child process (ZN-643), and console.error / sys.exit writing the program’s output to stdout under profile, mem and the device core, which existed before (ZN-644). Also from this chain, ZN-384: the WebGL limits live in include/zn/webgl_limits.h (a header of their own: no WebGL names in the core’s limits.h), and the lint shared_headers reads a #define only at the start of a line, so GLSL text in strings passes and src/gl is no longer excluded. | | D66 | The assets directory of a built program (ZN-314) | A program built by zinc build finds its assets relative to its executable: ZINC_ASSETS if set (what zinc run does), else assets/ beside the executable (what zinc export ships), else one level above it (<project>/build/<name>), else assets/ of the directory it is started in; resolved once, in assetsDir() of src/host/sys_host.cpp, which every asset open and the permission exemption go through. Before, only ZINC_ASSETS was read: the AOT build of examples/hero stopped with ENOENT: open city.bin, and the assets/ that zinc export copies was never read. Scores (RULES §4, max 70): A relative to the executable then the start directory 66; B the absolute project path written into the binary at build time 52 (it breaks the byte-identical builds of ZN-333 and is wrong once the app is shipped); C assets embedded in the executable as the prototype did 52 (the right long-term answer, but it is the pack work of ZN-433 and ZN-436). Two permission guards came with it, because enforcing programs now get an assets directory: the exemption for the app’s own assets no longer covers a path with a .. component (it was a prefix comparison, so readText('../x') skipped fs:read), and an enforcing program does not take the start directory’s assets. Checked: the AOT build of examples/hero, headless, 30 frames, equals the interpreter’s frame (tools/pngdiff: 0 differing pixels); tests/t1/aot_assets.sh. Left: the macOS .app does not carry its assets (ZN-645); the exemption also covers writes (ZN-646); the guide still describes the prototype (ZN-647); Linux’s /proc/self/exe branch waits for the cross-platform pass. Revisit when ZN-436 lands: packs replace the directory behind the same function. | | D67 | What zinc loads at launch (ZN-592.01) | zinc links libSystem and libc++ only: the window host (targets/macos/hal_sdl.cpp, hal_cocoa.mm, SDL3 and the GUI frameworks) is the module libzn_window.dylib beside it, loaded with RTLD_GLOBAL at the first window (src/host/window_module.cpp forwards the sdl_hal_* and hal_set_* functions and keeps what the setters got until then); CoreFoundation is opened by the one call that needs it; zinc exports no zn::, harfbuzz or libc++ symbol (tools/zinc-unexported.txt), so dyld has no weak definition to coalesce. Measured on the Intel Mac, zinc --version: 115.5M -> 31.5M instructions, 21 -> 8.3-10 ms wall (an empty program: 4.6). The module alone gave 7%: on macOS 15 libSystem already loads CoreFoundation, Foundation and ~560 images for any program, so the frameworks cost little until something links CoreFoundation, which runs ~400 initializers (Network, SkyLight: ~2 ms). The big cost was 629 weak-def coalescing binds (388 of them for the interpreter’s Machine::execT template referenced from a table) and the libc++ weak definitions zinc exported, which made dyld patch the shared cache; no plugin or module imports a hidden symbol (checked against the imports of every cached plugin, libzn_webgl and libzn_window). Compiled programs link the window host statically (zn_host_window), as before; the display drivers, zinc:3d and libzn_webgl resolve SDL3 from the module, loaded before them (a plugin whose load fails on an _SDL_ symbol is loaded again after it); zinc fuse carries the module inside the fused executable (trailer ZNFUSED2), written into the cache on the first window. Without the module a program that draws runs headless and says why. Rejected: -no_fixup_chains (dyld 4 binds lazy pointers at launch too: no gain), -fvisibility=hidden on the internal libraries (same effect as the list, many targets to touch, and libc++‘s typeinfos stay default), a launch closure (dyld 4 keeps none for a command-line tool). macOS only: Linux keeps SDL linked into zinc until the cross-platform pass. Left: zinc’s own ~3.5 ms (binds, 28 initializers, pages of a 12 MB binary) keep jsonout’s interpreter run behind QuickJS (10.6 vs 6.9 ms best of 40): ZN-592.03. | | D68 | What a compiled program links (ZN-592.06) | zinc build links the window host (libzn_host_window.a, whole), SDL3 and the GUI frameworks only into programs that draw (a zinc:gfx row), use zinc:gl or call native plugins (which may call SDL3); the others get src/host/hal_dispatch.cpp’s weak stand-ins and never open a window. On macOS every program is linked with -dead_strip (meant by ZN-603 but only on arm64) and -no_exported_symbols (a compiled program loads no plugin; dyld then coalesces no weak definition). Fonts and images are baked only into programs that draw, in AOT and in the interpreter’s cache. mimalloc skips its heap purge at exit (MI_SKIP_COLLECT_ON_EXIT), the VM’s call stack is calloc’ed (its pages are touched as it grows). A console program with a timer: 25 libraries -> libSystem and libc++, 3.9 -> 1.4 MB, 16.6 -> 7.0 ms on the Intel Mac (an empty program 4.9); console.log(1) compiled 6.6 -> 5.1 ms; zinc run of a cached program 5.2 -> 4.1 ms over an empty program. Rejected: dropping the window only for programs without any host call (timers, files and sockets are the console programs that matter); linking the window lazily (a static program cannot). Left: the ~240 libSystem imports the host’s row dispatch pulls into every program that calls the host (ZN-592.07). | | D69 | How M4 judges a kernel shorter than a launch (ZN-648) | tools/bench-m4 judges jsonout’s “not slower than QuickJS” threshold on each engine’s work: the cycles the process ran for the kernel minus those for an empty program of the same kind (zinc run empty.zbc, the compiled empty program, qjs empty.js; /usr/bin/time -l on macOS, CPU time elsewhere), measured in alternation, the fastest of each (ZN-654: medians swung 0.8-1.5x at load 8, the fastest agreed within 15%); the launches and the work are reported beside the totals, which stay in the table. jsonout does ~1 ms of work, so its total compared zinc’s 12 MB engine starting (~350 libSystem imports, ZN-592.03) with qjs, which a run-only interpreter would not close (ZN-592.05). Wall time and CPU time could not measure 1 ms of work on the development Mac while other trees build (load 7-10): the difference between two 10-20 ms medians swung from 0 to 2x; cycles agreed across runs (interpreter ~5.5M, compiled ~3.9-4.6M, QuickJS ~4.3M). jsonout is named short in the tool (its work is ~1 ms in every engine), not classified by a measured launch, which load moved too (dynsum and strings were taken for short ones). Result: the threshold now measures the interpreter’s work; it was a real loss (0.77x by medians), which ZN-654 turned into 1.00-1.16x (integer strings by to_chars, one concatenation per serialised object). Rejected: dropping jsonout from the thresholds; longer inputs (the corpus outputs are frozen); one launch measured apart from the kernel runs. The engines’ start-up is followed by the start-up section of the same tool and by tests/t1/launch_time.sh. | | D70 | zinc:audio (ZN-390) | zinc:audio is a plugin (plugins/audio) on miniaudio 0.11.25 with stb_vorbis, vendored inside the plugin (plugins/audio/vendor/ with a PIN file) rather than in third_party/: a plugin carries what it compiles for its mirror repository and zinc add, as sqlite does. The engine core has no audio and a program that does not import it is unchanged (227,152 bytes either way). API: load, decode(bytes), tone(hz, seconds, wave), a Sound with play (each call a new voice, 32 voices, the oldest one-shot gives way), stop, volume, loop, a master volume, and mix(frames) on the null backend, which is what headless and deterministic runs bind (a Zinc stand-in native/audio.sim.ts: nothing is built, time stands still unless mix() moves it). The mixer is ours and always 48 kHz stereo; the device opens on a thread, because opening Core Audio synchronously took 87 to 537 ms (tone() plus play() now return in 1.3 ms). Sounds are kept as float: miniaudio 0.11.25 drops the last frames (up to 512) of a non-float source, at a cost of 21 MiB per minute of 44.1 kHz stereo (ZN-653; reporting it upstream is the owner’s step). 'noise' uses our own xorshift because ma_noise white noise is all positive in that version. load() never throws: a bad file is a silent Sound and one warning. Size: +550 KiB on game-2d, +1.36 MiB on a command-line program; miniaudio’s text is 693 KB as shipped, 477 KB at -Os. Checked on macOS only: tests/t1/audio.sh (mixing, volume, loop, 32 voices, resampling, WAV, FLAC, MP3, Ogg, on the interpreter and the AOT with the same 25 lines) and one real run on Core Audio that opened, played 0.26 s and closed; nobody listened. Not built: pause, seek, pitch, pan, fades, streaming (notes for ZN-440 and ZN-573). Left: Linux (ZN-651), packed apps carrying their native plugins (ZN-652). | | D71 | How zinc:gfx draws through a 2D matrix (ZN-263.01) | gfx.setTransform(a, b, c, d, e, f) (canvas order, logical pixels, after translate, reset each frame) turns each box command that follows (rect, rounded rect, gradient, border, shadow, image, text) into an XFORM command: its screen bounding box as the command’s box, the command in its own coordinates and the matrix in the point pool. Polygons, paths and strokes are transformed point by point when recorded. The software raster draws an XFORM pixel by pixel through the inverse matrix with the distance functions of the untransformed commands (images and glyphs sampled bilinearly); the GL replay draws it with its usual paths, quad() taking the corners through the matrix and giving the shader the pixel’s own coordinates. Tiles, damage, culling and occlusion see a box like any other, and without a matrix nothing is recorded differently (the prototype’s pixel goldens: the same 40 of 42 as before, the two known 3D differences). Rejected: a matrix in every Cmd (24 more bytes for every command of every frame), a transform state command (every pass that reads boxes would have to follow it), rendering transformed subtrees into an offscreen image (runtime images have no alpha, and text would be resampled twice), triangulating boxes into polygons (no text or images). Limits (ponytail): a clip under a matrix is the bounding box of the transformed rectangle; a transformed stroke keeps a round pen of the matrix’s mean scale; the GL replay ignores the scale for edge smoothing and draws turned images without rounded corners. Checked by tests/t1/gfx_transform.sh (interpreter and compiled program equal to the software golden, GL within the tolerance policy). | | D72 | glTF in zinc:3d (ZN-658, extends D25) | loadGltf(asset) is a module of its own, zinc:3d/gltf (an alias in plugins/3d/plugin.json, file plugins/3d/gltf.ts), that reads through the three plugin’s native glTF reader, not cgltf. A native module is linked whole into every program that imports its spec (no pruning of natives), so loadGltf in zinc:3d itself would grow every 3D program; a module imported only by the programs that load models keeps the others byte-identical (measured: the same SHA-1 before and after for a zinc:3d program, +194 KB for one that imports it). The reader is the one D25 kept for three (GLB, data URIs, normalized attributes, strips and fans, embedded images), so the engine keeps one glTF reader to keep right and cgltf stays the independent check of tests/native/gltf_check.cpp. Scores (fit x3, size x2, maintainability x2, effort x1): three’s reader in a separate module 5,5,5,5 = 40; a cgltf-based native module in a new plugin 5,5,3,2 = 33 (a second reader, a second image decoder, its own sim); in zinc:3d’s own native module 5,1,3,3 = 26 | A model the shared reader misreads (then both loaders get the fix), or a target where three’s native does not build (esp32: the 3D plugin’s module stays usable without glTF) | | D73 | Source rectangles and images from bytes in zinc:gfx (ZN-432) | drawImageRect(image, sx, sy, sw, sh, dx, dy, dw, dh, alpha, flags) (flags FlipX, FlipY, Nearest) is one IMAGE command of the usual 48 bytes: the box of the whole image goes in the four fields an IMAGE did not use (raster::image_box, pad bit kImageBox), a negative size mirrors; imageFromBytes(bytes) decodes PNG and JPEG into a runtime image that keeps its alpha. Rejected for the rectangle: the point pool (it would cap a frame at 8192 sprites, 256 on ESP32), a bigger command, and CLIP + IMAGE + UNCLIP (3 commands and a GL flush per sprite, no mirror). The linear path computes exactly what canvas2d’s emulation computed, so zinc:canvas’s 9-argument drawImage moved onto the row with its pixels unchanged. Sampling follows the GPU, not the 2D canvas (a linear sample reads the texels around the source rectangle, clamped at the image edge; atlases extrude): software equals GL, and linear sprites differ at their edges in a browser. imageFromBytes uses a second static stb_image in zn_codec (built programs link zn_codec, not zn_res or libwebp): +50.7 KB; a file that declares more than 2^24 pixels is refused; 64 runtime images at once (ZN-670). Under setTransform (ZN-263.01) the box travels in the existing XFORM payload and the Cmd, with no change of either; the raster clamps the half-pixel sample of an edge to the rectangle (a turned nearest sprite otherwise showed dots of the neighbouring frame) and smooths the edge, the GL replay cuts it (ZN-678). The appended gfx rows are one list, isGfxTailRow, read by isSysRow and by QuickJS’s installHost, which so gained setTransform, resetTransform, onResize, commandCount and commandsFree. canvas2d behaviour changes that no golden covered: imageSmoothingEnabled = false samples nearest on baked images too; a 1:1 source rectangle of a runtime image at a fractional position is blended, not row-copied. Checked: image_rect (sprite sheet, flips, alpha, edge cut, the same hash on the interpreter, the AOT and QuickJS; 10 sprites through a matrix), image_rect_gl, image_rect_wasm (headless Chrome, nearest sprites exact, game-2d’s sprites in the export), the pixel canary, gfx_transform unchanged, proto_devtools and a prototype run of examples/canvas/sketch. Browser host: imageFromBytes, baked fonts, gradients, clip, translate and setTransform are not done (ZN-672, ZN-673). Numbers: docs/reports/zinc-next-perf.md, ZN-432. | | D74 | The museum table: project format, light table and object viewer (ZN-650.01 to .04) | examples/museum-table is a player: everything it shows comes from one project file (JSON, version 1; src/project.ts reads, validates with messages naming the field, copies and writes it; load then save is byte-identical), in the manner of Klynt: a media library (photo, model, panorama, text, audio, video, each with texts by language and a required credit), sequences holding items in space and time, links (a button, or a hotspot on an item), presentations, storyboard positions (x, y is the centre of a 240 by 150 node), and the table, kiosk, accessibility and theme settings the owner asked for. Photo cards are plain zinc:gfx draws under setTransform (ZN-263.01), not textured quads in a zinc:3d scene (the first version: nearest texels, no alpha, one repainted image per card, the 64-image limit): anti-aliased edges, real shadows, text sharp at every zoom, 150 lines less; the cost is the software raster (115 ms for 8 cards, captures only, ZN-680). Gestures are the table’s own code, not plugins/gestures, which has no per-target finger ownership (ZN-684): each gesture owns its card, and a finger added or lifted moves it by less than a unit (the test refuses a step over 16 units, 3 degrees or 6% of scale). In the object viewer the object turns and the camera and lights stay; a view is yaw, pitch and a distance in radii, field of view 40 degrees; the turning, inertia and glide are src/orbit.ts, which the panorama view of ZN-650.09 extends. Tests compare frame hashes and logs, not PNG files. The sample, “floating-world”, is real and CC0: nine photographs from The Met, three from the Art Institute of Chicago, a Kangxi vase of the Smithsonian (decimated from 150,000 to 12,000 triangles with the vendored meshoptimizer; the OBJ ships because the 150k GLB would take the example to 11.4 MB, ZN-681), a Poly Haven panorama; 7.1 MB in all; works the Met’s API does not mark public domain were left out. Measured in a window on the GPU: 30 moving cards at 60 fps at 1920x1080 (114 to 118 without vsync), 40 fps at 3104x1746 (ZN-679); the object draws in 0.7 ms (OBJ) or 1.4 ms (150k GLB). Not verified: no touch hardware (scripted touches only); the French and English texts were written from the museums’ records and not re-read by a curator; nothing generated by say is committed (the narration will use an open voice, ZN-650.05). | | D75 | More than one window (ZN-663) | A window beside the main one draws a slice of the frame’s command list into pixels of its own, on the CPU, shown by its own SDL_Renderer; zinc:ui exchanges its per-surface state around that window’s frame. The runtime records the window’s commands between beginWindow and endWindow like beginImage (physical pixels at the main window’s scale), rasterizes them only when their hash changed (raster::frame_hash), hands the pixels to the HAL and drops them from the main list; while the callback runs, the input, width(), height() and keep() are the window’s. The HAL contract is six optional calls (hal_win_*, weak defaults: main window only); the SDL HAL routes events by window id, hides a closed main window while others are open and ends the program with the last one; the headless HAL plays window N / close script events. zinc:ui keeps one set of module variables (the code reads one surface) and swaps the ~70 interaction variables with a Surface object around a window’s frame; the clock, animations and scrolls step once per engine frame, and a change made while one surface draws marks the others dirty from outside (extLayout, extPaint), which never marks back, so two idle windows stay idle. Scores (fit x3, effort x2, risk x2, performance x2, maintainability x2): CPU slice per window 4,4,4,3,4 = 46 (the GL replay and the damage diff stay single-window; a 1280 x 960 window repaint costs a full raster when it changes); a per-window context of the whole runtime (command buffers, damage, GL replay, HAL state as objects) 5,1,2,5,4 = 39 (every global of gfx.cpp, gl_replay.cpp and hal_sdl.cpp moves: the shared core, weeks); zinc:ui rewritten around a surface object 5,1,1,4,5 = 37 (thousands of references in lib/std/ui.ts, shared with the prototype) | A window whose CPU raster shows in profiles (then the GL replay per window), or zinc:gl / QuickJS programs that need a second window | | D76 | The museum table’s storyboard editor (ZN-650.07, slices 1 and 2) | The editor is an application of its own, examples/museum-table/editor/ (a zinc.json has one entry), a desktop tool for mouse and keyboard that imports the player’s format module and writes exactly serializeProject’s text. Every operation leaves a project parseProject accepts, so Save never writes a file that cannot be reopened: deleting a sequence removes or retargets the links and points that led to it in the same undo step (a dialog gives the count and offers another target). Undo keeps a copyProject per step and merges typing; “unsaved” compares bytes; 50 mixed operations undone and redone give back every file byte for byte. An id derived from a title follows it while it still matches. The storyboard draws a sequence as a 240 by 150 node centred on its x, y, zooms by layout in four steps (zinc:ui paints no transform), and a link shows a dot that opens into its label. The owner’s answers: guided by default for museum staff, with an advanced mode (Cmd+E) that is a view over the same commands (exact numbers, the selection as the file has it, the key of every command); the interface itself in French and English from one string table (strings.ts, typed keys), apart from the language switch of the content. Thumbnails come from the project folder through readBytes and imageFromBytes (one decode per frame, 320 px, 40 kept: the 64 runtime images limit, ZN-670). Workarounds marked in the code: a constant of Latin characters until fonts at run time (ZN-669), the shell’s PWD and LANG until zinc:sys gives the launch directory and the locale (ZN-687). Checked by tests/t1/museum_editor.sh (scripted sessions building projects from empty, file and frame goldens); never run in a real window. Left as subtasks: settings forms (ZN-650.07.01), points of interest on a model and in a panorama (.02), preview by embedding the player (.03), narration, sound and film (.04), tools for agencies (.05). | | D77 | zinc:physics3d on Jolt and the 3D pinball (ZN-583.02, ZN-649.01, ZN-649.02) | The pinball examples/pinball3d plays a table file in real 3D with complete 3D physics, as the owner asked: zinc:physics3d (plugins/physics3d, Jolt Physics 5.6.0, MIT, vendored in the plugin as four unity files with its pin and checksum) scored 62 against Box2D in the table plane 58, Bullet 46 and hand-written physics 43 (RULES §4). The plugin: a typed API (World, Shape sphere / box / capsule / cylinder / convex hull / mesh / height field, bodies with plain fields read in bulk after each step, hinge and slider with motors, contacts with the approach speed, sensors, ray and shape casts, layers, save and restore of the state), one thread, no clock, the same calls on the interpreter, the AOT and QuickJS through a generic native bridge (src/qjs/native_js.cpp: plugin zinc: specifiers resolve under QuickJS), deterministic (600 steps replayed identically from the start and from a saved state on the three engines). A fast ball: 10,000 scripted shots (27 mm, 3 to 20 m/s, 240 Hz) and none through a wall, a ramp, a flipper or another ball, under two documented conditions: a kinematic flipper needs 4 collision steps (960 Hz), and two fast balls head-on need collision steps = ceil(closing speed x dt / (0.5 x radius)), because Jolt sweeps a body against where the other was. Cost: 1.92 MB in a program that imports it, nothing in one that does not; cold plugin build 48 s, a wrapper edit 6.7 s. The table format is one schema (examples/pinball3d/src/format) that gives the validation with messages naming the field, the canonical text (load then save is byte-identical), the printed reference docs/pinball-table-format.md and, later, the editor’s forms; one list per element kind; ramps, rails and upper playfields are 3D paths with heights; a package (.zpinball) is the JSON with its assets in base64. The game is a library an editor can embed (src/game.ts: Game, placeBall, show(scene)); flippers and plunger are kinematic bodies at 960 Hz; switches are lines tested in the game (Jolt sensors are not swept); both layouts, landscape and portrait, are data. First table: “Tidal Lock”, original (ramp with a wire rail, dock, bumpers, missions and ranks, multiball, ball save, tilt, high scores). Checked: physics3d, pinball3d_format (16 broken files, each message naming its field), pinball3d and pinball3d_aot (a scripted 60 s game with the same 203-line log on the interpreter in both layouts and compiled; six pixel goldens exact on the software renderer; the GPU frame held to SSIM 0.95 since zinc:3d multisamples by default: antialiased edges are not the software frame’s). Not met or not shown: the 3 ms step for 1,000 bodies (4.1 to 4.5 ms on a loaded, heat-throttled machine, Jolt’s own step being 4.3 ms: ZN-690), 120 fps (a 60 Hz display; physics and rules 0.3 ms, scene and panel 0.6 to 0.8 ms per frame), an upper playfield on Tidal Lock, textures and modelled pieces (ZN-649.08), sound (ZN-649.03); no human has judged the feel. | | D78 | Fonts at run time (ZN-669, revisits D45) | The runtime rasterizer (runtime/ttf.cpp) is the baker’s algorithm (src/res/res.cpp, the prototype’s resources.ts): contours in font units, quadratics flattened into 8 segments, composites by offset and x/y scale, nonzero winding sampled 4 x 4 per pixel in doubles without fused multiply-adds (pragma in ttf.cpp; plugins already use -ffp-contract=off). A glyph rasterized at run time is the baked glyph of that size, so gfx.loadFont(file, px) (a baked family or a .ttf of the assets, read at run time when the baker did not embed it) draws exactly like a size written in the program, and a baked font that lacks a character hands the text to its twin rasterized from the same file (raster::text_font). A program whose own files have a text field embeds its fonts with every character they have (hb-subset to the whole cmap, still without hinting or layout tables). D45’s alternative of an exact-size gfx.font stays rejected (the prototype answers the nearest baked size); loadFont is the explicit call. Measured: run time vs baked frame identical (tests/t1/fonts_runtime.sh); first measure of 37 glyphs 0.6 ms at 24 px, 3 ms at 96 px (17 to 85 us per glyph), then 1 us; programs without fields bake byte-identical resources, with fields +366 to +387 KB (hero, forms); bouncing-ball’s binary +24 bytes; HiDPI text changes by up to 35 levels on antialiased edges (scale_push re-recorded). Scores (fit x3, pixels x3, effort x2, size x2): the baker’s algorithm at run time 5,5,4,4 = 46; keep the signed-area rasterizer and bake a ladder of sizes 2,3,5,2 = 33 (what the museum table did); full fonts for every program 5,5,5,1 = 42 | Glyph rasterization at large sizes in profiles (then an active-edge table in cover()), or a program that types characters its fonts do not have (then the system fallback of D33 for the glyph tables) | | D79 | The museum table: sequences, narration and the table mode (ZN-650.05, ZN-650.06) | The player now plays the Klynt part of a project: timed items, links, hotspots, overlays and next; a storyboard view with the visited trail; guided presentations with narration and subtitles; and a table mode for one or two visitors sharing one story. Narration is synthesised with Piper 2023.11.14-2 (MIT) and two voices whose model cards allow distribution: French fr_FR-mls-medium, speaker 1840 (trained from scratch on Multilingual LibriSpeech, CC BY 4.0), English en_GB-cori-medium (trained from scratch on LibriVox recordings, public domain); refused: every voice fine-tuned from en_US-lessac (data licensed to a named licensee), from en_US-ryan (CC BY-NC-SA) and fr_FR-tom (AGPL). The tool lives outside the repository (~/.cache/zinc-museum-tts); tools/narration.sh regenerates the 8 clips (MP3, 533 kB) and their WebVTT cues from the transcripts of the project; nothing made with the macOS say command is committed (the owner’s decision after the licence concern). A narration is said once per visit and always in a presentation; its length comes from the cues, not from the decoded sound, so headless runs are exact. A fade is a dip to the table’s colour; next waits for free hands. Table mode: one frame for everything, turned toward a side by a tap or press within 15% of that edge (guessing the side from every touch turned the table under a visitor); photo cards stay free; an attract loop after 60 s and a reset after 120 s, both in the project file, and the reset is checked to give the frame of a fresh start; the attract loop is silent; a faulty project is reported on the error output and never drawn to a visitor; kiosk.fullscreen takes effect with ZINC_KIOSK=1 so a developer is not trapped. No field was added to the format; two meanings were made precise in the reference (a hotspot’s fractions are of the picture for a photo or a film; an overlay closes by a link or next to the sequence underneath). zinc:gfx setTransform was enough for the turned panels. Checked by museum_visit (a scripted visit: three links, the storyboard, a jump, a presentation, the language switch, on the interpreter and compiled) and museum_mode (four sides, two visitors taking turns, idle and reset). The example is 8.0 MB (limit raised to 12 MB). Short of the brief: a paused presentation restarts its step because zinc:audio cannot pause or seek (ZN-695); the film shows its poster (ZN-650.08); smaller things in ZN-650.13. Not verified: nobody listened to the narration (pronunciation of ukiyo-e, Ninomaru, Van Gogh), no sound went through Core Audio, no window was opened, no touch hardware. | | D80 | The pinball’s sound, look and initials (ZN-649.03, ZN-649.08, ZN-649.09) | Every sound, image, model and light of Tidal Lock is named in its table file and made by a script kept in the example; the game stays a silent library that leaves cues, and TableAudio plays them. Sound: 40 MP3 files, 595 kB (64 kbit/s mono through LAME: within the 1 MB budget, a mature encoder on the machine, and loops without a gap measured through zinc:audio). The mechanical sounds are cut from six recordings under CC0 1.0 on freesound.org, each licence read on its own page on 2026-10-10 (a 1977 Gottlieb machine by schafferdavid: flippers, bumpers, plunger, drain; a marble on a table by NeatoEnt: the roll, a drop, a hole), recorded in LICENSES.md and in the table’s credits; the other 28 (effects, 11 jingles, 4 pieces of music, one per game state, cross-faded in 0.4 s) are synthesised by tools/tidal_lock_sounds.py. Because the game only leaves cues, a scripted game has a sound log (pinball3d_sound, the same on the interpreter and compiled; renaming a sound in the table changes what plays with no code change). The rolling sound is one loop whose volume and rate follow the fastest ball. Look: a painted playfield and decals laid flat from above, modelled pieces through OBJ files (the model path now runs), a cabinet, a backglass, and an upper playfield at 5.5 cm with its own flipper, all drawn by tools/tidal_lock_art.py from the table file (14 images, 4 models, 679 kB). zinc:3d has neither blending nor a light per piece, so a pool of light is an opaque patch of a second, lit painting that matches the first at its rim: pools are on the playfield only and must not overlap (ZN-696). Jolt’s results depend on the order bodies are made in: the deck’s bodies moved a bumper hit by 1 ms, so physics.ts makes upper-level bodies after the playfield’s and the balls, and the scripted log is the old one plus the deck flipper’s lines. Two standups were moved rather than a guide added (a new playfield-level body would have changed the scripted log). Initials are entered with the game’s own controls; --scores=<file> keeps the table of a deterministic run or of a cabinet in a known file. The schema gained what the editor asked for: Field.role (point, path, area, direction, profile, rotation, offset), Field.unit, checkTable. Frame in a window (2560x1600 physical, GPU, load 4.4 to 5.2): physics and rules 0.35 to 0.42 ms, scene and panel 1.3 to 1.8 ms compiled (0.6 to 0.8 before the look), 2.0 to 2.3 ms on the interpreter, both at the display’s 60 Hz. Not verified: nobody listened to the sounds or played the table (ZN-649.12); the scores’ storage path between two real runs; a .zpinball package carrying sounds, images and models. | | D81 | The pinball table editor (ZN-649.04, ZN-649.06) | The editor is an application of its own, examples/pinball3d-editor/, that embeds the game as a library (Game, TableScene, TableAudio, View: nothing rewritten) and whose forms are GENERATED from the table format’s schema: a field added to the format appears in the editor without editor code. The document is the format’s own tree; every operation is addressed by path and checked by the game’s loader (checkTable) before it replaces the document, so Save never writes a table the game refuses; a rename or a deletion is mended from the loader’s own fault messages rather than from a copy of its rules; undo keeps the earlier trees (50 mixed operations undone and redone give every file byte for byte); a point drag edits the document live as one undo step (8.3 ms per edit on the 17 KB table). The schema says what a field is for: Field.role (point, path, area, direction, profile, rotation, offset, level) and Field.unit, so the top view moves, turns, mirrors and draws any element without knowing its kind (mirrored copies of Tidal Lock’s left guide, slingshot and flipper are byte for byte its right ones). Play in the editor is the real game: a ball dropped at a point gives line for line what the game’s probe prints for the same drop; the game’s own window opens beside (several windows, ZN-663); sounds are heard through TableAudio; rules and missions are edited as forms of the same generic kind (a rule made in the editor adds its 300 points to a bumper in the game’s probe). The owner’s answers: guided by default with an advanced mode over the same commands, the interface in French and English, work in a folder and export one package (.zpinball: Tidal Lock with its sounds, images and models plays the scripted game from the package with the same log). Tests reach things by key, path or name (Cmd+F a field, Cmd+G a thing or +kind to add, Cmd+J the file’s text), never by the position of a line, so the format can grow; frames are compared by bytes, else on the top view’s crop (tools/pngdiff --crop), the form’s pixels on a fixture schema, 3D frames by SSIM. A table made from nothing by a scripted session plays the game’s scripted game with a score (81,500) and is the start of the second table (ZN-649.05). Short of a non-coder’s needs: file fields are typed paths, colours are hex, rules are forms rather than sentences, the playfield painting is made outside the editor; a package altered after export is not refused at load (no hash function in the standard modules, ZN-698). Not verified: a sound heard, the windows on a real screen, any human use. | | D82 | Goldens recorded on Apple Silicon, checked on other machines (ZN-612) | Each comparison gets the measure that fits what differs, chosen so that the Intel Mac’s honest differences pass and a real regression fails. The 3D example frames (three_3d) differ by an edge pixel (3d-model: 3 pixels, max 61) or a rounding of 1 (three-cubes: 5 pixels) although every host, plugin and AOT unit is built with -ffp-contract=off: the remaining source is the platform libm under Math.sin/cos (Apple’s arm64 and x86_64 libm differ in last bits). They compare within 1 per channel and 8 pixels (pngdiff --tol 1 --max-diff 8): a shape moved by a pixel (15739 pixels) or a colour changed by 3 (every pixel) fails. The cause is filed rather than hidden: ZN-703 routes Math through fdlibm, as V8 and so the sim do, after which the frames can compare exactly again. The video’s first frame (video) is a hardware decoder’s output through FFmpeg’s SIMD colour conversion (x86 and NEON paths are not bit exact; SWS_BITEXACT would cost the C path at playback): it compares on the picture (--crop 0,0,192,108) by PSNR ≥ 30 dB and mean error ≤ 4 (new pngdiff --psnr/--mae): the Intel decode scores 32.2 dB / 2.3, while a one-pixel shift (22.8 dB), swapped channels (16.9), a brightness off by 6 (mae 5.8), a limited/full range mistake (28.3), BT.601 read as 709 (29.5) and nearest chroma (26.8) fail. The WebGL conformance gate keeps one pass list and adds, per GPU family (the unmasked vendor of the context the pages get: apple, amd, intel…), the pages that family’s driver is known to fail (tests/golden/webgl/conformance-known-amd.txt, page and why): they print KNOWN, every other page of the pass list still fails the gate. Of the five AMD pages two are ours, not the driver’s (WebGL must zero what GL leaves undefined: ZN-701) and three are driver bugs browsers rewrite around (ZN-702); two CPU-heavy pages that timed out under load (59 s and 12 s alone on the Intel Mac) got longer hang guards instead of list entries. Rejected: one tolerance for every golden (it would hide regressions where exact output is the norm), re-recording the goldens on the Intel Mac (they would then fail on Apple Silicon, where they were made). | | D83 | The web environment for JavaScript libraries on QuickJS (ZN-566, ZN-568) | A thin environment of our own, lib/std/webenv.js (67 KB of JavaScript read at run time, so zinc carries no byte of it, +14.5 KB for its native side src/qjs/webenv.cpp), off by default (zinc.json web), written against what the game libraries actually call: window, document with the elements they create, events and listeners, requestAnimationFrame on the engine’s frame loop, timers, pointer and key input, fetch / XHR / URL / Blob on the host’s network and files, localStorage, navigator, performance, and a canvas element whose getContext('webgl' | 'webgl2') is the existing WebGL binding. Scores (RULES §4): a thin environment of our own 67; the same with proven parsers where the spec is a parser 64 (its rule is applied: URL is the WHATWG parser of lib/std/web.ts, structuredClone is QuickJS’s serializer, DOMParser waits for a vendored XML parser, ZN-706); a DOM library in JavaScript 43 (only linkedom runs on QuickJS: 411 KB, 19 to 21 ms, +5.2 MB, and it lacks rAF, fetch, input events, layout and canvas contexts). To load it the type stripper of the QuickJS engine keeps static and blanks Zinc decorators. Engine robustness that real libraries needed (ZN-566): the stack limit follows the thread’s real stack less 1 MiB, capped at 8 MiB, so deep recursion is a catchable RangeError (depth 6,837 on the default stack) instead of a SIGSEGV; an unhandled promise rejection prints Uncaught (in promise) with its stack and dispatches unhandledrejection, and without a web environment ends the program with 101, as the typed engine and Node do; onFrame registered after an awaited timer receives frames. Booting unmodified, each with a T1 test: p5.js 1.11.13 (WEBGL mode), three.js r186 with OrbitControls, PixiJS 7.4.3; not yet: PixiJS 8.22 (Vertex Array Objects are not supported) and Phaser 3.90 (Invalid call of 'texture'), both from WebGL binding defects of ZN-565, then image loading (ZN-569); getContext('2d') draws nothing until ZN-570. p5.js is LGPL-2.1 and is NOT in the repository (RULES §3 keeps LGPL out of the tree): tools/test-lib p5-1.11.13 takes it from its npm release into ~/.zinc/cache/test-libs/, checked against a pinned sha256, and webenv_p5 is skipped when it is neither cached nor downloadable; PixiJS and three.js (MIT) are vendored. Cost: +6.5 ms to create a context with the environment on (10.2 against 16.7 ms, nearly all of it parsing the JavaScript: a bytecode cache, ZN-575, would remove most); first frame 320 to 420 ms after the context for the three libraries, frame 0.3 to 0.8 ms on their minimal scenes (load 4.4 to 5.5). Not verified: a WebGL canvas in a real window (the live path ran on SDL’s dummy driver only), the resize event, https and permissions with fetch. | | D84 | The interpreter’s debugger (ZN-371.01) | A debug build, not a debug mode of the normal one: zinc debug lowers the program with a DbgLine at each statement and a DbgVar after each write of a local, skips inlining and recursion unrolling (every call keeps its frame), and the ZBC emitter turns them into a Line instruction (24-bit index into the module’s line table: file, line, column) and a move into the local’s home register (one per named local, right after the parameters, below every call window; a reference there is retained, the previous one released, and every home released at return). The debugger stops at a Line whose byte it set (breakpoints set the bytes of their line, stepping sets them all, a pause request would too) and walks the interpreter’s frame stack for the call stack; the depth decides step over / out. Locals are read from the homes, formatted from the variable’s IR type (numbers, booleans, strings, arrays, objects by the field names a debug build keeps, a cell’s value). Why homes rather than tracking SSA values: the optimiser and the register allocator are untouched, the value of a variable is in one place at every statement, and the retain keeps a reference alive however early the RC pass releases its SSA value (a leak only when an exception unwinds the frame, in debug builds). The cost is all in the debug build: normal builds are byte-identical (59 programs compared) and the interpreter runs them in a copy of its loop whose Line handler is empty — the first version, one loop with a never-taken call to the hook, ran every program 6-7% slower (fib, nbody, instructions retired): a call anywhere in the computed-goto loop changes the register allocation of all of it, cold attribute or not; the copy costs 73 KB of binary. A debug build runs 1.4x (nbody) to 2.1x (fib, inlining and unrolling off) the instructions of a normal one, the same with the debugger idle or armed on a line not reached. The engine’s own code (the prelude, zinc:* modules, lib/std, lib/compat, plugins) gets no lines or homes: it runs without stops and stays out of the stack. Debug modules are never encoded (the line table and the homes live only in memory); the verifier refuses a Line index past the table. Front ends: the command line now; DAP (VS Code) and CDP (Chrome’s Sources panel) are ZN-371.02 and .04 on the same core. | | D85 | The museum editor: preview, points of interest, project settings (ZN-650.07.03, .02, .01) | The player became a class the editor embeds (examples/museum-table/src/player.ts: Player(project, firstSequenceId, lang) with frame(dt, x, y, w, h); main.ts is only the window, the command line and onFrame), its files come through functions the host replaces (src/files.ts: the editor points them at the project folder, the table at its assets), and whoever owns the window clears it. Preview: “Play from here” runs the real player on the unsaved project, in the editor or in a second window (ZN-663) that follows the sequence being edited 0.25 s after the last change; the file on disk is untouched by a preview. Points of interest: a click on the model or in the panorama is turned into a place by ObjectView.ray against the OBJ’s triangles or PanoramaView.unproject, kept with four digits, a view in whole degrees; the drawn position matches the click within 1.5 pixel in the test; a glTF or GLB model has no triangles in the editor, so its point goes on the plane through the middle of the object (zinc:3d has no ray cast, ZN-683). Items on the table are drawn by one zinc:gfx function under a matrix at every angle, and turned by a handle. Settings: forms with a live sample that is the real player; removing a language removes its texts in the same undo step; a shorter reach moves the buttons back within it; the preview buttons sit at the corner of each view and the settings open from the project’s name, because the top bar has no room left in French at 1440. The format gained no field (two exports, mediaJson and sequenceJson). The three criteria of ZN-650.07 are met by scripted sessions that build three sequences from an empty project with media, texts in two languages, links and points; left: narration, sound and film (.04), tools for agencies (.05). Short of the brief: fonts and logo are plain fields because the table does not apply them yet; text sizes are kept but the player cannot show them (ZN-650.10); the default language cannot be changed. Not verified: a real window (the second window on the GPU, a narration heard); other CPUs (the points’ coordinates and three frames depend on sin and cos and are exact on this Mac, see D82); a glTF model in the points editor. | | D86 | The pinball: pools of light, the editor’s files and looks, a second table (ZN-649.10, .13, .15, .05) | A second table, “Twilight Fair” (a funfair at dusk, chosen over the abyss because it opposes Tidal Lock in colour and sound and its strings of bulbs use overlapping pools of light), was made with the editor only: assets/tables/twilight-fair/table.json is the byte-for-byte result of five scripted editor sessions kept in the repository (tools/twilight_fair/sessions, replayed by the test pinball3d_second_table), and its commit holds no game or editor code. What the editor could not do, which is the honest measure of it: two rules with a condition (all lane lights on; all coconuts down) had to be written in the advanced field as the file has it, because the form cannot build a lights or targets condition one field at a time (every intermediate state is refused, ZN-649.14); off-grid places, angles and the kicker’s exit were typed in forms rather than pointed at; the painting (11 images), 2 models and 34 synthesised sounds were made by scripts outside the editor (715 KB). The scripted game runs on both tables (74,410 on the new one, a mission done). Pools of light are additive meshes cut to their level (zinc:3d blending, ZN-696), so they overlap and exist on the upper playfield; Tidal Lock is migrated (its second painting is gone, 208 KB less) and look.textureLit still works for a table that names one. The rules hear a bump of the table and a letter signed. In the editor: a file field is a chooser of the folder’s files that knows its kind (Schema.asset(name, kind, doc): image, sound, model), imports go to art/ and sounds/ and ask before replacing, refresh is a command and not a watcher, unused files are listed; a colour is picked in a square with the table’s palette, a light’s glow by a handle that comes from the role radius and not from the field’s name, the panel’s items are dragged in each layout, the camera is set in the 3D pane. Two editor defects found by making the table were fixed (a click picked the wrong line of a filtered list). Not verified: nobody played either table or heard them (ZN-649.12); the real file dialog and a drag from the Finder; a file changed by another program. | | D87 | The debugger’s expressions and exceptions (ZN-371.05) | Watches, hovers, breakpoint conditions and logpoint messages are evaluated by the debugger itself, not compiled into the program: the frontend’s parser reads the text, names resolve to the paused frame’s locals, its arrow function’s captures and the program’s globals, and the operations are typed by the declarations a debug build keeps (IR type names of locals, fields and globals), so the errors are the checker’s own: Z0101 unknown name, Z0106 no such property, Z0107 operator on these types; a literal is a number as the checker types it. Why not the checker on a snippet: it needs the whole program (re-checked at every stop, 0.1-0.8 s), and running the result would mean loading code into a machine whose class table is fixed. What it covers: members (declared class first, .length, .size), indexing of arrays and strings, unary, arithmetic, bitwise, comparison, equality, && || ??, ?:, as, !. What it refuses: calls and assignments (a watch never changes the program), templates. Conditions use JavaScript truthiness; an error in a condition stops with the error; a logpoint’s {expression} parts are evaluated and its message goes to the front end (stdout, a DAP output event, Runtime.consoleAPICalled), never a stop. Exceptions: the debugger’s copy of the interpreter loop calls the debugger before a Throw unwinds; it walks the frames’ handlers as the unwinding would (without unwinding) to tell caught from uncaught, and stops on uncaught or on every throw as asked (catch, DAP exception filters, Debugger.setPauseOnExceptions); normal runs never reach it. Debug builds now keep field type names and the program’s own globals (names and types; the engine’s are left out), and arrow functions’ captures show as a Closure scope. | | D88 | // @ts-ignore (ZN-155, the parity gate) | Not honoured: the diagnostic stays an error. Zinc Next compiles the checked types into its code (typed registers, the AOT’s C++): a type error that is ignored leaves nothing correct to run, where the prototype’s simulator ran the program as JavaScript and its C++ build failed as here. Options scored: honour it only on the simulator profile (fit 2: a program that runs in one place and not in another; effort 4) 34; turn the line into Dyn values (fit 3, performance 2, maintainability 2) 38; refuse with the error (fit 4: the same result on every engine and target, maintainability 5, effort 5) 52. tools/parity-audit lists the row as BY-DECISION. | | D89 | The ZPK pack format (ZN-433) | ZPK, an indexed binary stored inside the .zapp (report games/toolchain-assets-loading.md 6.1: ZPK 66, ustar plus an index 60, zip through miniz 53, PhysicsFS 48). 32-byte header (ZPK1, version, flags, count, alignment, offsets and sizes of the TOC, names and zstd dictionary); TOC rows of 64 B, one cache line (u64 offset, stored size and size, group, kind, codec, 16 B of the SHA-256 of the stored bytes, checked before a decoder sees them), sorted by FNV-1a 64 of the name then the name bytes; data in (group, name) order, aligned per profile (16 B by default, a power of two up to 1 MiB). Codecs none, lz4 (hc 12), zstd (19) and zstd with a dictionary trained single-threaded (deterministic); an entry that does not shrink is stored as is; the same inputs give the same bytes in any order. Reader::indexHash() (header, TOC, names, dictionary) is what a signed manifest records; per-entry hashes give integrity only. lz4 1.10.0 and zstd 1.5.7 vendored, zstd without its assembler. Revisit: a faster SHA-256 if first reads show in launch times; zstd’s size macros where its decoder is too big (docs/zpk.md). | | D90 | UI transitions (ZN-274) | No record per node: each writer of a style (rebuildStyle, syncState, applyProp) notes what the properties it changes show and starts, retargets or skips their tweens at once; running tweens live in anims (52 B more per transitioning node, where a per-node record cost about 0.8 KB, 2.4 KB for transition-all). A budget of 8 tweens at once on heaps of 256 KiB or less (esp32, ps1), shared by transitions and animate(); past it a change takes its end at once. duration-N alone transitions every property, as CSS’s initial transition-property: all; the default curve is Tailwind’s cubic-bezier(0.4, 0, 0.2, 1). Colours ease at paint time, channel by channel in i32 (fx12-safe), from the colour shown when the target changes; the opaque hover:/focus:/active: text and border colours still switch at once. A node’s first style does not transition, as in CSS. The ‘record nothing per frame’ half of ZN-274’s criterion 2 moved to ZN-364.01 (review of 2026-10-10, RULES §6). | | D91 | How asset importers run (ZN-434) | Pinned upstream CLI tools as subprocesses, plus importers written in Zinc (report games/toolchain-assets-loading.md section 9, scored per RULES §4: 66, against in-process importers behind a C table 55 and everything as Zinc scripts 45). The built-in copy, image and font-baker importers stay in-process in the CLI. Artifacts are cached by an XXH3-128 key (xxHash 0.8.4) of the source bytes, the importer and its options and the engine’s executable stamp, in ~/.zinc/cache/assets (trimmed LRU to 2 GiB); each group becomes a ZPK pack (D89) rewritten only when its key changes. Revisit if spawning costs show on many small imports. | | D92 | zinc:assets v2 at run time (ZN-436) | Groups are the <group>.zpk packs of the asset build (D91), mapped in place and searched in order; assets.mount() puts a pack in front (mods, patches), overlays apply per entry name. Handles are counted and need an explicit release() (Zinc has no finalizers for plain objects). Two queues (load, prefetch) with promotion, four jobs at a time on the libuv pool, each with a pooled zstd context; one turn per frame makes finished jobs resident in queue order up to 4 MB (ZINC_ASSET_TURN); a deterministic run waits for them, and one-core targets decode in the turn, sliced by bytes rather than by a clock so runs stay deterministic. The ram budget in force is enforced everywhere (the 1 GB desktop default included; a project raises it in zinc.json): loadGroup refuses a group that would cross it, naming both. zinc run builds and mounts dev packs only for projects that declare assets.groups, so existing demos run as before. | | D93 | UI gradients (ZN-261) | One gradientPaint command, a rounded box (RECT) carrying the canvas2d paint record (linear, CSS-ellipse radial, conic, +8 repeating, N stops), not a POLY with the canvas paint: the edges get the coverage of every other box, one quad on the GPU instead of stencil passes, and the record, paint_at and the shader are canvas2d’s. The GPU computes up to 8 stops analytically in the shader (scored 62 against a 1x256 ramp texture page 55: no texture page, the raster’s rounding); more stops come from a CPU tile. An axis gradient whose stops share one alpha is drawn as chained two-stop rects with seams on whole pixels (the old from/to command unchanged). Colours interpolate in sRGB, CSS’s default (Tailwind v4 asks oklab: later.md). A missing from-/to- stop is transparent, as in Tailwind. Below T2 (esp32, ps1, ps2) two stops along an axis, below T3 a conic is its stops’ average, each fallback logged once. | | D94 | Images in a box: object-fit, background images, tint (ZN-262) | One painter for <image> fit and background images, in lib/std/ui.ts only: each tile is one drawImageRect of the visible part of the image, edges on whole pixels (no seams), a clip only for a rounded box; a plain <image> keeps the prototype’s single drawImage command. The tint multiplies (drawImageRect’s multiply, ZN-581), as the criterion asks; React Native’s tintColor replaces opaque pixels instead, and the two agree on white and grey template icons (docs say so); the class is twrnc’s tint-* (Tailwind has none). Tile budget: 16 tiles on T0, 64 on T1, 4096 above and never more than commandsFree() - 2; past it, fewer larger tiles laid over the box, logged once. The box is both positioning and painting area (borders are paint-only) with its single radius; an image and a gradient on one node paint the gradient over the image (CSS’s scrim over a photo). | | D95 | Sprite atlases (ZN-438) | An "as": "sprite" rule packs PNG, JPEG, WebP, SVG, .aseprite (cute_aseprite 1.04) and TexturePacker or Aseprite sheets into atlas pages with stb_rect_pack 1.01; the atlas is assembled when its pack is written, from per-file cached frame artifacts named relative to their file (a cache key covers bytes, not paths); pages keep straight alpha; the frame table is text, read by the pure-Zinc zinc:sprite; trim keeps one transparent texel and extrusion is counted in points, so fractional positions draw exactly the loose image; image() of zinc:gfx finds pack images and zinc:sprite falls back to the loose files without packs, so no new host API. Straight against premultiplied alpha: premultiplied rgba8 changes translucent texels, which breaks pixel identity with the loose images (criterion 2); the GL replay already blends straight alpha. Text table against a binary one: the table is small, read once, and parsed in Zinc on every engine without a host row. stb_image_resize2 (named by the task) is not vendored: nothing in an atlas resamples. | ZN-439 (mipmaps, rgb565/rgba4444 pages) asks for resampling or premultiplied pages; a sheet where the text table shows in load profiles | | D96 | The engine splash (ZN-446) | zinc.json splash (or targets.<id>.splash) becomes an uncompressed .splash entry of boot.zpk: a ZSP1 header (background, fit, minMs, fadeMs) and the image as ZIM1 rgba8, an SVG drawn at 8 px per unit; it is scaled on the CPU when presented, not pre-scaled at build time; a built program shows it first thing in main, zinc run only when it runs the program itself (interpreter, .zapp, QuickJS) and hands it to a compiled executable through ZINC_SPLASH; every SDL main window is created hidden and shown after its first present, then presented again (Electron’s show-after-ready-to-show); the browser export puts the splash in index.html as the canvas’s CSS background with a data URL. Scaling at present time: the desktop surface depends on density and zoom, known only at run time, and a frame of rows works with every window HAL without a new entry; pre-scaling in the panel’s format stays for fixed-panel targets. One shower per run: when zinc run and the executable both showed it, the window closed at the exec and came back ~370 ms later. fadeMs is stored for ZN-447. | A fixed-panel target (fbdev, ESP32) shows a splash; ZN-447’s loader screens take over the window | | D97 | Asset hot reload in zinc dev (ZN-450) | When a save changes only files under the assets directory of a project that packs its assets, zinc dev rebuilds the dev packs, compares the hash of every pack entry before and after, and sends the entries whose bytes changed (asset <kind> <size> <name> plus the bytes, then end) over a local socket pair the program inherits as ZINC_DEV_FD (unset and close-on-exec once read); the program swaps them in front of its packs on the libuv loop, in place for resident entries and atlas page images (same id, new pixels), and raises a host event per name for assets.onReload; anything else still restarts. Bytes rather than paths, so a device link (zinc dev --device, TCP) can carry the same lines; entries chosen by hash rather than by a source-to-entry map, because one sprite change rewrites a whole atlas page and its table. An import error prints and keeps the old asset. | QuickJS gets host events; a project without packs wants reload of its baked loose images | | D98 | Canvas 2D backend of the web environment, text-first tier (ZN-570) | canvas_ity (ISC, pinned at 8be29cb, three recorded patches) draws everything; our text tier shapes and passes glyph outlines as paths. Scores A 42, B 55, C 58, D 64, E 50 in the task notes. Measured: a 34-character line into 512 x 128 in 1.3 ms, a 640 x 480 fill in 4 to 5 ms, SSIM against headless Chrome 0.976 to 0.989 (text scene, PixiJS 8, Phaser 3, p5 2D). | The full tier (ZN-571, ZN-572) decides again after a full-screen p5 measure. | | D99 | Where Canvas 2D lives (ZN-570) | Linked into zinc (zn_qjs), not a libzn_canvas2d module as the task said: the text tier is already in the binary and zinc grows by 94 KB (0.7 %). | Move it behind a module if the full tier grows it much. | | D100 | The loader stage (ZN-447) | zinc.json loading becomes a generated Zinc module (zinc.json.loading.ts, made by the CLI only when the file compiled is the project’s entry) that loadProgram initialises before every module of the entry, as if the entry imported it first; QuickJS evaluates it first too, so the interpreter, a built executable and QuickJS run one source. It draws its screen each frame while the declared groups preload (loading.groups, else boot when declared, else every group), holds minMs, and cross-fades its last frame into the program’s first frames through a new zinc:gfx overlay(); no loader when the groups are already resident or finish within the first 16 ms. Default screens ship as the plugins/loader plugin (zinc:loader bar and splash, zinc:loader/ui); the plugin name lives in the CLI, not the frontend (RULES §7). Rejected: a C++ loop in the host (it cannot run Zinc screens) and deferring the main module’s initialisation (IR changes). | A program that wants its own loader logic beyond a screen module; the browser export and the ESP32 core run the stage | | D101 | Gamepads in the window host (ZN-574) | SDL3 is built with joystick, gamepad, HIDAPI and the virtual joystick as SDL’s defaults have them, haptic off (SDL ties the virtual driver, which the tests use, to HIDAPI). Gamepads are zinc:gfx rows and navigator.getGamepads() with the W3C standard mapping. Cost: libzn_window.dylib 2,141,424 to 2,532,656 bytes (+391 KB). | A real pad was never attached: only the SDL-to-W3C table is proven. | | D102 | Language forms beyond the prototype (ZN-160) | Each form is lowered in the frontend onto what the IR already has: a rest parameter is an array parameter with a rest flag on the function type, the checker packing the trailing arguments into an array literal at the call site; a tagged template becomes a call with a string[] of the cooked strings (TemplateStringsArray is string[]), String.raw the template with its backslashes doubled; for…in a for…of over a key list taken from the static type (whole-program view) or, for Dyn values, from the prelude’s __dynKeys; an object literal with get/set or with methods that use this an instance of a class generated for it; labels map onto the loops’ break and continue targets. Nothing changes in the IR, the VM or the AOT, so typed programs pay nothing (the 21 IR goldens are unchanged). Costs taken: the object literal prints as its generated class and its members carry no closure (a getter reading a local of the enclosing function is refused, Z0005); for…in lists the static type’s fields. | Methods that carry a closure; a program whose for…in must see a subclass’s fields through its base type | | D103 | Full Canvas 2D backend (ZN-571) | The full tier stays on canvas_ity, patched and completed in our layer; no Skia, no ThorVG. Scores: Skia 50, compositor over ThorVG 52, canvas_ity kept 57. The deciding number: p5 1.11.13 with 1,000 ellipses at 800 x 600 takes 30 to 37 ms a frame on QuickJS with every context method empty, so no backend reaches 60 fps there; the script is the bound, not the rasteriser. Taken beside other builds (load 6 to 11), ratios only. Skia was not built and ThorVG not prototyped. | Revisit if the JIT engine (ZN-586) makes the rasteriser the bound, or a demo needs full-window Canvas 2D at 60 fps without WebGL: then Skia as an on-demand module. | | D104 | Tile maps (ZN-443) | A built-in importer "as": "tilemap" (yyjson) reads Tiled .tmj/.tsj (layer data as arrays or base64, plain, zlib, gzip, zstd; groups, parallax, tint, typed object properties) and LDtk .ldtk (all levels and worlds, IntGrid, auto tiles, entities with fields) into a text table <map>.tilemap; each tile a map uses becomes its own atlas frame through the sprite atlas (D95), extruded and trimmed, with a transposed copy for Tiled’s diagonal flip, so drawing only mirrors; the pure-Zinc zinc:tilemap draws the cells in view. .tmx/.tsx (XML) are refused with the advice to save as JSON: no XML parser is vendored. Built-in against a Zinc script importer, scored per RULES §4: 63 to 52 (no script-importer mechanism exists yet: finding the files a map names, putting tiles in the atlas); the table format would stay, so zinc:tilemap would not change. One frame per used tile against the whole tileset as one frame: no seams when scaled, only used tiles packed, no rotation at draw time. Text table as in D95. | User-defined importers or a second data format; maps over about a million cells (binary u16 layers); users asking for .tmx (tmxlite or pugixml) | | D105 | Plugins for artists and makers: OSC, serial, MIDI (ZN-716, ZN-717, ZN-715) | OSC is Zinc code on zinc:socket, no library, as zinc:osc/full beside the built-in zinc:osc of ZN-085; serial is POSIX termios behind one file per platform (libserialport is LGPL); MIDI is RtMidi 6.0.0 (MIT), three unmodified files in plugins/midi/vendor, built with exceptions. Scores in each task’s notes; the rejected serial candidates were scored from memory. | No instrument, board or adapter was attached: loopback, a pseudo-terminal and CoreMIDI virtual ports only. One zinc:osc instead of two is a line for later. | | D106 | Windows toolchain (ZN-714) | Windows programs are built with the pinned zig for x86_64-windows-gnu (mingw-w64), linked statically, not MSVC: the same pinned compiler as every target, no Visual Studio or SDK, no redistributable. A console program links today (1.1 MB, system DLLs only); nothing has run on Windows. | Revisit if a plugin needs an MSVC-only SDK. | | D107 | Threads in zinc:physics3d (ZN-583) | A world steps on one thread unless it asks: threads: n runs Jolt’s own JobSystemThreadPool with n-1 workers plus the caller, threads: 0 one per physical core (hw.physicalcpu on macOS); a threaded world follows Jolt’s determinism rules (listeners under a mutex, collision steps numbered by a step listener, contacts sorted by step, then ended ones, then value; moved bodies by index), so drop.out is byte for byte the same on 1, 2, 4 and 12 threads and on the three engines. Not on by default: 20 bodies step in 0.056 ms on one thread against 0.14 to 0.15 ms on six, and the pinball relies on the one-thread contact order. One per core rather than per hardware thread: 12 threads 4.76 ms against 3.84 ms for 6 (medians of 5, loaded machine). The contact listener is set only when a body asks for events (-1.9%% instructions). | A program stepping several large worlds (one shared pool); Linux, where hardware_concurrency counts SMT threads | | D108 | Textures (ZN-439) | basisu 2.50 is the encoder hub, a pinned tool built from its checked source archive with the pinned zig (no SSE, OpenCL or astcenc) into ~/.zinc/toolchains; the texture importer takes the target profile’s texture list in release builds (rgba8 in dev, a rule’s format wins), makes mipmaps with stb_image_resize2 2.18 (alpha-weighted, linear light) and premultiplies alpha by default; 16- and 32-bit layouts are made in-process, bc1/bc3/bc7/etc1/etc2/astc/pvrtc through basisu (UASTC per level, -export_ktx) at a fixed 4 encoder threads; ZIM1 (include/zn/zim.h) carries every native block format with mipmap and sRGB flags so one reader maps all of them in place, KTX2 only for the browser profile; the GL replay uploads blocks where the driver lists the format (ETC1 as ETC2 RGB8 where only ETC2 exists), else bcdec/etcdec decode on the CPU; atlas pages stay rgba8 with straight alpha (D95). An image with alpha takes the first format of the list that has alpha, an opaque one the first or a later smaller one without alpha (bc7|bc1). Encoder scored 64 against 49 and 49 (report section 9); delivery scored per RULES §4: pinned sources built with zig 61, vendoring 13 MB of sources 52, linking the encoder into zinc 46. ASTC and PVRTC have no CPU decoder: the software raster shows them transparent with one warning. | The Pi runs short of CMA memory; quality complaints (astcenc, bc7enc_rdo, etcpak as options); aarch64 gives other bytes; basisu’s first use (190 MB of sources, ~6 min build) becomes a problem (prebuilt through the signed index, ZN-337) | | D109 | Models (ZN-441) | gltfpack is a pinned tool built with the pinned zig from the meshoptimizer v1.3 archive (the vendored library’s SHA-256, without Basis or WebP), sharing basisu’s builder (D108); the model importer takes a model’s images out first and sends them through the texture importer (entries <model>.<n>.<ext> the model points to by URI), then writes format: "glb" (default, for three: gltfpack -cc -kn, quantised attributes and EXT_meshopt_compression) or format: "zmesh" (for zinc:3d: gltfpack -noq read back with cgltf and flattened into include/zn/zmesh.h: float positions, normals, uvs, colours, 16-bit indices in parts of at most 65535 vertices, copied as they are by meshLoad); the three plugin decodes meshopt with the engine’s vendored decoder, compiled in through -I <engine>/third_party. Delivery scored per RULES §4: built with zig 65, vendoring its sources and shipping a second binary 56, linking it into zinc 48. Decoder in three: compiled from the engine’s third_party 67, copied into plugins/three/vendor 60, no compression 47 (zn_meshopt is a CMake library plugins cannot link). The .zmesh keeps zinc:3d’s float layout so both renderers take it as is; the int16 layout of the design waits for the software renderer. | Pi 1, PSP or 3DS need int16 positions; a program moves named parts (a node table); plugin keys must cover engine third_party files | | D110 | Speed of JavaScript libraries (owner 2026-10-12, on examples/js-p5 at 40 fps: the number of boxes of a sketch must not matter; “we want a fast QuickJS or our VM fast”, with JIT support in QuickJS and a JavaScriptCore engine as an option; reviews D13, D36, D44, D55, D56 and section 3; reopens ZN-586) | Two routes, both kept. (1) QuickJS stays the engine of every target and the reference, and is made fast in our tree: first a compiler of its bytecode, ahead of time (ZN-723): the operand stack and the locals as values, borrowed references, a cache per property site, direct calls, then number types behind guards; hot functions only, compiled and cached as D43 does, the interpreter staying the semantics and the fallback. The same compiler gets a machine-code backend, a JIT, afterwards, where memory may be executable; fast paths in QuickJS’s runtime go in the patch queue. (2) JavaScriptCore is an optional engine behind the same host table and web environment (ZN-586): a module beside zinc, the system framework on macOS, never required. Libraries do not move to the Zinc VM. D13, D36 and D56 stand; D55’s no-go covers the copy of the interpreter it measured, not a compiler. Measured on the M1 Pro (load 4.5 to 6, one run each: to retake alone; docs/reports/zinc-next-perf.md). js-p5 (144 lit boxes, 800 x 500 window, 1600 x 1000 canvas, 120 Hz display): 25 ms a frame, three refreshes: script 17.2 ms (its WebGL calls 1.6), canvas image upload 1.2, swap 0.5, the rest waits for the refresh; of the script’s samples inside zinc the interpreter loop is 57% and QuickJS’s runtime 43% (property definition, object creation, malloc and free: p5 copies its state at every push()); p5 1.11 makes 6,480 setUniform, 3,312 _arraysEqual, 1,152 Matrix.copy and 4,301 WebGL calls a frame. 60 fps needs the script 1.3x faster, 120 fps 2.9x. The ceilings, same files (bench/qjs), ms per frame, QuickJS / JavaScriptCore without JIT / V8 jitless / JavaScriptCore / V8: three 4.97 / 2.34 / 3.09 / 0.204 / 0.271; sprite 8.02 / 3.74 / 8.29 / 0.250 / 0.477; objects 0.747 / 0.598 / 0.463 / 0.118 / 0.100; p5shape 8.45 / 6.05 / 4.75 / 0.31 / 1.71: the best interpreters are 1.0 to 2.1x QuickJS, the JITs 5 to 32x. The system JavaScriptCore embedded in a program runs without its JIT (6.16 ms on p5shape) unless the executable carries com.apple.security.cs.allow-jit (0.41 ms; an ad hoc signature is enough). Read the same day. PocketJS (commit 8a08f203, 2026-10-10) has no fast QuickJS: an interpreter with no JIT and no bytecode, kept out of the frame (cores in Rust own state and time, one crossing per steady frame, styles and keyframes baked), and its new MicroTS compiles a typed TypeScript subset to Rust with no guest engine, which is Zinc’s typed AOT; it runs no browser library. Longbridge’s quickjs-jit 0.12.11 (MIT; 59,000 lines of Rust on Cranelift and a 2,800-line patch of quickjs-ng; +3.7 MB; test262 identical) is a JIT inside QuickJS: 32 to 45x on numeric kernels and 0.99 to 1.00x on its own realistic application panel, because its policy refuses 124 of about 255 opcodes (closure variables, this, typeof, for of, try) and every call returns through JS_CallInternal (its analysis of 2026-09-27). Options scored (RULES section 4: fit x3, performance x3, size x2, maintainability x2, licence and supply x2, portability x1, effort x1); (a) and (c) are both taken, they do not exclude each other: (c) JavaScriptCore as an optional engine: 4,5,4,3,4,2,3 = 54; (a) a compiler of QuickJS bytecode, ahead of time, a JIT backend after it: 5,3,4,2,5,5,1 = 52; (e) QuickJS as it is, with accelerators per library: 1,1,5,5,5,5,5 = 46; (b) libraries on the Zinc VM: 3,2,3,2,5,5,1 = 41; (d) a JIT inside QuickJS before the compiler: 3,3,4,1,5,2,1 = 41; (f) adopting Longbridge’s JIT now: 3,2,3,2,5,3,3 = 41. | D44’s levers are spent: its boundary fixes are done and the binding is 9% of js-p5’s script, its spike gave 1.08 to 1.40x (D55), its caches 0.97 to 1.06x (D56), and both its revisit conditions hold; the option it kept in reserve, a JIT engine on desktop, is the only measured way to browser speed (6 to 32x) and the owner asks for it as an option. It stays an option, QuickJS the default: iOS and consoles forbid a JIT (without it JavaScriptCore is 1.2 to 2.1x), Windows and Linux have no system engine, macOS wants an entitlement, and one reference engine keeps the tests deterministic. For QuickJS itself no interpreter gets there, so the lever is native code, and D55 named what the hand translations did that a copy does not (L1 5.9x, L1.5 7.4x, speculative 85x on the sprite loop; 4.9x on three’s multiplyMatrices): compiler work, which D55’s revisit condition (2x or more on library frames on a target without a JIT) now asks for. Ahead of time first, as D36 and D43 chose for typed code: every target runs it, and the profiler dump (ZN-598), the bind hook (patch 0002) and the test262 gate exist. The JIT is a backend of that compiler: Longbridge’s shows that emitting machine code is not the hard part, covering closures, this, calls and objects is, and that work is the same ahead of time and at run time. Not the Zinc VM: its frontend stops at line 3885 of three.core.js and its Dyn objects are hash maps without caches, slower than QuickJS on property code (dynsum 39.2 against 30.2 ms); unmodified libraries need the whole of JavaScript’s object model and library, a second JavaScript engine; the VM stays the engine of typed code, where D13 and D36 hold. What route (1) does not promise: expected 2 to 3x on library frames at L1.5 (the report’s estimate, not measured), allocation-heavy code stays bound by QuickJS’s runtime, and browser speed needs speculation and a moving collector: on desktop that is route (2). | ZN-723’s gate fails (below 2x on the three and matter-js frames with values, borrowing and caches): then JavaScriptCore carries desktop speed and the compiler is rescored; ZN-586’s spike shows the JavaScriptCore binding costs what the JIT gains (4,301 WebGL calls a frame on js-p5), or Apple restricts the entitlement; Longbridge’s JIT covers closures, this and calls and gains on library frames (then score adopting it against our backend); quickjs-ng gains a register bytecode or caches. | | D111 | The GL replay on GLES 2 (ZN-725, owner 2026-10-12 with a Pi 3B+ plugged in) | On GLES 2 every mode of the replay is drawn by a program of its own, built at first use from that mode’s block of the shader source, and the antialiased edges of POLY and STROKE are thin strips whose coverage comes from the vertices. Desktop GL keeps its single program and its pixels. display-gl’s default renderer stays cpu on GLES 2 and display-fbdev’s bands stay the default display of a Pi 3. | Measured on the Pi 3B+ at 1400 MHz (hero, present p50): navigation driving 181 to 25 ms, home 31 to 15, gallery 33 to 23, first frame 1.1 to 0.29 s. Options measured: program classes (UI core and vector) were slower than one program per mode, since a GPU without branches still pays a class’s ladder; 4x MSAA with plain triangles 35.5 ms against 29.7 for strips on navigation, and it only covers frames drawn straight into the window. The software bands still lead (19.1 ms) at the price of 289% of a core against 56% and of heat, and the GL renderer loses on gallery (the rounded clip reads the framebuffer) and has had no long run on partially changing screens: hence the defaults. Numbers and the open points: docs/reports/raspberry-pi.md, backlog/later.md. | | D112 | Text under a scaling matrix (ZN-726, owner 2026-10-12 on museum-table; amends D71) | A TEXT drawn through a matrix that scales takes its glyphs from the first size, not below its size on screen, of a ladder a quarter of an octave apart (8, 10, 11, 13, 16, 19, 23, 27, 32, 38, 45, 54, 64, 76, 91, 108, 128 px), drawn at 84 to 103% of their size, with the advances of the base font times the scale: a text keeps its line breaks at every zoom and is sharp. Within 0.84 to 1.03 and under a matrix that only turns, the text’s own glyphs serve, byte for byte as before. At most 64 fonts are made for scaled text (ZRT_SCALED_FONTS, 0 on small targets), never freed. | Scored as RULES 4 says: the font’s own bitmaps resampled (D71) 55, blurred, the owner’s complaint; a font per exact size 46, every visible glyph rasterized at each frame of a zoom; the ladder with the nearest size 55 (63% of the edge energy of the text set directly at x3); the ladder with the first size not below 58 (77 to 83%), chosen; an SDF atlas 46 (a dependency and a path in both rasterizers); outlines as polygons 46 (cost per frame). zinc:ui drew a scaled Text in the nearest font the program had and never through the matrix: it does now. Revisit if a zoom at rest must equal text set directly, or if a Pi hitches at the ladder steps (backlog/later.md). | | D113 | Boxes under a matrix that only scales and moves (ZN-730, owner 2026-10-12 on museum-table’s film; amends D71) | Under a matrix without rotation or skew (b = c = 0, a and d positive) a RECT, BORDER, SHADOW or IMAGE is recorded at its place and size on the screen, as the command given directly would be, and not as an XFORM. A radius, a border’s width and a shadow’s blur need a uniform scale (a = d); TEXT keeps D112; a gradient paint record keeps the matrix. | The XFORM path draws pixel by pixel through the inverse matrix: on a Pi 3B+ the film’s picture (854x480 drawn 500x281) cost 82 ms of CPU under a scale-only matrix against 13.5 drawn directly, and every card of museum-table is drawn through one matrix since D112. Rejected: a fast case inside draw_xform (the GL replay and the damage would still see a bounding box and a payload). Seen and accepted: an unturned image that moves or zooms lands on whole pixels, as every direct image does (three museum frames and one image_rect golden changed, by 1 to 2 levels inside the picture and on its edge rows). Revisit if edges that snap show during a zoom. The other half of the film’s 11 pictures a second was not the raster: swscale read the V4L2 decoder’s capture buffer, mapped without a cache, at 85 ms a picture; zinc:video copies the frame into ordinary memory first (10 ms). | | D114 | Zero-copy video: a display’s hardware layers (ZN-731, owner 2026-10-12: “zero-copy, no millisecond lost without a reason”) | A display driver may offer hardware layers (HalDisplay.layers, runtime/include/hal.h). zinc:video then asks the decoder for its pictures as dmabufs (DRM PRIME from h264_v4l2m2m) and gives the runtime image a HalPicture instead of pixels; the runtime offers each upright, whole IMAGE of such a picture to the driver before the frame is drawn; a driver that takes it shows the dmabuf on a plane and the rasterizer writes a hole there. On a Raspberry Pi the layers are overlay planes of the Hardware Video Scaler inside display-fbdev: the picture on one, the software frame on an ARGB plane above it, taken only while a hardware picture is drawn, with plain KMS ioctls; the mode and the console’s primary plane are never touched. What is drawn over the film stays in front: the rows it touches are drawn a second time over white holes and the two renders give each pixel’s alpha. A film turned, blended, with rounded corners of its own or drawn in part, and any display without layers, take the path of D113. | Scored as RULES 4 says (fit, performance, size, maintainability, licence, portability, effort): planes inside display-fbdev 64 (5,5,5,4,5,3,3), chosen: programs and their zinc.json do not change, a screen without a film is today’s fbdev byte for byte, a killed program leaves the console (the kernel removes a closed client’s planes: checked with SIGKILL); a new display-kms driver on libdrm that owns the mode 53 (a second driver with its own input, bands and statistics, which must restore the console itself); the decoder or the ISP giving the picture at the drawn size over /dev/fb0 48 (a copy out of the uncached buffer and the raster stay); an EGLImage texture in display-gl 45 (on VideoCore IV the UI is fastest in software, D111). Rejected too: an alpha channel in the rasterizer, a “nothing translucent over the film” rule (museum-table’s timeline is translucent: its film would never have left the CPU), libdrm. Measured on the Pi 3B+: museum-table’s film 38.3 to 3.45 ms of CPU a picture, no pixel of it read by the CPU. Revisit when a film must stay off the CPU while turned, when a second driver needs layers, or when a compositor is the display. | | D116 | The device agent: pairing, sessions, discovery (ZN-729.01, owner 2026-10-12: a daemon found “in a secure way: Bonjour, an encrypted protocol, a code”, an adb over the network) | Pairing is TLS 1.2 with EC J-PAKE (the 6-digit code as its password), sessions are TLS 1.3 with a pre-shared key and an ephemeral ECDHE exchange, both from the mbedTLS already vendored (MBEDTLS_KEY_EXCHANGE_ECJPAKE_ENABLED turned on in its config); the pairing gives each workstation an identity and a 32-byte key of its own. Discovery is DNS-SD over mDNS (_zinc._tcp) with mjansson/mdns 1.4.3 (public domain, one header) vendored in third_party/mdns. One TCP connection per operation, nothing multiplexed. Why a PAKE: a short code used any other way (a MAC over the channel binding, a fingerprint to compare) is broken off line by whoever sits between the two machines in the time of one handshake; with J-PAKE a wrong code is one failed guess, and the agent allows three per code. It is the design of adb’s wireless pairing (SPAKE2, then TLS) and of HomeKit and Matter (a PAKE once, long-term keys after). Why keys and not certificates: both ends must prove themselves, a 32-byte secret per pair does it with mbedtls_ssl_conf_psk alone (no X.509 to write, no key pair to store), and forgetting a workstation is deleting its line. Scores (fit x3, performance x3, size x2, maintainability x2, licence x2, portability x1, effort x1, 1-5). Pairing and sessions: mbedTLS J-PAKE then PSK 5,5,5,4,5,5,4 = 67; Noise with a PAKE written over monocypher 4,5,5,2,5,5,2 = 58 (a protocol of our own: RULES 3 says no); mutual TLS with self-signed certificates and a fingerprint compared by eye 2,5,5,3,5,5,3 = 55 (a device without a screen cannot confirm, a short fingerprint is forged); OpenSSH on the device and libssh2 in the client 2,4,3,3,4,3,3 = 44 (keys and a server to set up by hand: what tools/device needs today). Discovery: mjansson/mdns 5,5,5,4,5,5,4 = 67; the ZINC1 UDP beacon of display-remote 3,5,5,4,5,5,5 = 62 (no standard tool sees it; the owner asked for Bonjour); the system libraries (dns_sd.h, Avahi) 4,5,4,3,4,2,2 = 53 (Avahi is missing on many boards and from a zig cross build). Measured on the loopback interface (M1 Pro, 2026-10-12): a session with one request 35 ms, a shell command 43 ms; the agent and its client are 227 KB of code in zinc. mbedTLS has no thread support in our build, so the agent’s threads share one lock around its calls. tls::read now goes on after a TLS 1.3 session ticket instead of returning with the bytes behind it unread (found here; it concerned every TLS 1.3 client of host/tls.cpp). Details: docs/reports/zinc-next-agent.md. | Revisit when sessions need to encrypt in parallel (build mbedTLS with MBEDTLS_THREADING_PTHREAD), when a device needs IPv6, or if mbedTLS drops EC J-PAKE (then SPAKE2+ through PSA psa_pake). | | D115 | The device agent: updates, the dev loop, the inspector, Atelier (ZN-729.03 to .06; decided by the agents that worked them, recorded at integration) | Updates: an app whose zinc.json has update.publicKey is replaced only by a .zapp that verifies with the installed app’s key and has a higher version: pushed, with the detached signature of zinc sign sent in the install request; from its channel, with the signed manifest of zinc publish. The first install needs no signature. The version replaced stays as previous.zapp (a hard link) and comes back when the update ends with an error within 5 s of its first run (ZINC_AGENT_TRIAL_MS). A paired workstation already has the user’s rights, so the signature guards an app against a wrong or stale build, not against its owner; no cryptography was written (zn::tc::verifyBytes, fetchManifest, downloadUpdate). The service is per user, never root: a launchd agent at login, a systemd user unit with lingering, KillMode=process so apps outlive the agent. Dev loop: zinc dev --device keeps the loop of zinc dev on the workstation and makes zinc pack the check; each save sends the members whose SHA-256 changed and the agent restarts the program. The agent refuses a workstation with another engine version (the bytecode is compiled there). The dev program is not an app: no pid file, it ends with its session. Inspector: the agent picks a free port, gives it to every app it starts as ZINC_DEVTOOLS_PORT and keeps it as the second line of the pid file (the log cannot say it: it is cut back past 4 MiB). shot speaks the DevTools protocol through a few lines of Zinc run by the engine and monitor pipes the followed log into zinc monitor: no WebSocket or telemetry code was added in C++. top is a stream of info. Atelier (ZN-729.06) drives the CLI through zinc:process and holds no protocol code: Run on a device is zinc device <name> deploy, its follower ending at the log’s [exited n] line; Debug is Run plus zinc device inspect, not zinc dev --device (a dev session is not an app, so there is nothing to forward). Measured on the loopback interface (M1 Pro, load average 10, other trees building): a saved source runs on the device 211 to 250 ms after the save. Details: docs/reports/zinc-next-agent.md. | Revisit the trial of 5 s after a measure on a Pi 3 with a large .zapp; the unsigned first install if --no-shell devices must refuse an owner’s own replacement; the pack’s target when a workstation drives a device of another architecture. | | D117 | Frames that tear in bands on the Pi’s screen (ZN-735, owner 2026-10-12: fast animations show the picture cut in bands) | On a display with KMS, display-fbdev writes every frame into the buffer that is not shown and one atomic commit shows it at the refresh, with or without a video picture; ZINC_FBDEV_FLIP=0 gives back the write into /dev/fb0. The cause: a frame was converted straight into the buffer on the screen, 16 rows a band on four threads, and the wait meant to time that write to the vertical blank returns at once on the DSI screen of a Pi (the kernel’s framebuffer emulation waits for another CRTC, ZN-731), so the scan-out read bands of the new frame beside bands of the old. The cure was already in the driver for frames with a video picture (D114, zero-copy video); it is now the path of every frame, scaled surfaces included. Measured on the Pi 3B+ at 1400 MHz, hero’s navigation, one binary, two pairs: 39.9 and 40.0 frames a second into /dev/fb0, 38.5 and 37.9 flipped (3.5 to 5% less: the display’s buffers are 32 bits a pixel where /dev/fb0 is 16, convert 1.44 to 2.07 ms); hero’s tour 59.3 to 59.8, pocket-hero 59.7 to 60.0. A partial update stays partial (pocket-hero: 1.3% of the surface damaged, 2.5% written). A correct picture at 4% fewer frames was taken over a torn one. Proven by the driver’s own count (260 commits, 0 frames into the buffer on the screen); that the bands are gone is for the owner’s eyes: the task is in Review. | Revisit with RGB565 buffers while no picture is shown (the 4%), a third buffer so a frame’s conversion does not wait for the refresh before it, and a display without a free plane (it still tears: panning /dev/fb0). |

| D118 | Leap Motion as a Zinc plugin (ZN-738, owner 2026-10-13) | Load the installed LeapC SDK dynamically behind the ordinary native-module ABI; copy the latest hand snapshot with nonblocking polling, and put projection/gestures in the Zinc app. Direct optional SDK: scores (fit, performance, size, maintainability, licence, portability, effort) 5,5,5,4,4,3,5 = 64; directly linked SDK 5,5,4,4,4,3,4 = 61 (a missing installation prevents app startup); a subprocess/socket bridge 4,3,4,3,4,4,2 = 49 (extra process and protocol). The supplied build/leap_clion is an example executable; its CMake points at the official installed SDK. No proprietary headers or binaries are copied into Zinc. Simulation is explicit; missing/stale tracking clears hands. Event lifetime follows Ultraleap LeapC. | Revisit for another tracking backend or the cross-platform pass. | | D119 | The GPU layer for low-end and console GPUs (owner, 2026-10-16; ZN-741, ZN-627) | Keep our own renderer: the command lists replayed by one back end per GPU API, the GLES 2 replay as the base for every GLES 2 device (Pi 3, HP TouchPad, Nexus 7 2012), choices made by a capability record and a per-GPU-family tuning table, never by platform name; contexts come from one provider per platform (SDL3 on desktop, Android and iOS; KMS/EGL; webOS SDL 1.2). The layer is written so it can leave the engine as a project of its own: its own directory and CMake target, a C ABI, no dependency on the runtime beyond the command-list header, its own tests on fake GL strings. Not bgfx (its shader tool chain and size, scored in docs/reports/parity/04-library-choices.md §2.10), not sokol_gfx (no GLES 2), not ANGLE (size), not SDL_GPU (no GL). Why separable: the owner plans back ends for connected platforms that no library covers together, CHIP (Mali-400, GLES 2), PS Vita (GXM), 3DS (PICA200 via citro3d), Wii (GX), Wii U (GX2), Switch (deko3d or NVN); PocketJS draws its 10-op list with one small back end per such GPU (docs/reports/hardware/pocketjs-pocket3d.md), the model followed here. Design: docs/reports/zinc-next-mobile-core.md §4.1, with the Tegra 3 measures (no fragment highp, depth 16, no packed depth-stencil, 2048 textures). | Revisit when a second engine wants the layer (then extract it), or when one back end per API costs more than a shared library would (count the lines per back end). | | D120 | The engine as a library for app shells (ZN-620, coordinator 2026-10-16) | One C ABI in SDL3’s main-callbacks shape for every shell: zn_app_init runs the top level up to the loop, zn_app_iterate is the vsync tick (one frame of __gfxStep or one turn of __loopStep), zn_app_event queues SDL events for the window HAL, zn_app_quit ends the run; zn_app_main = init then iterate until done, so desktop behaviour and goldens are unchanged. iOS owns its run loop and SDL3 recommends the callbacks there and on the web; Android works either way, so one form for all (Flutter’s embedder model; docs/reports/zinc-next-mobile-core.md §3). App mode reaches the prelude as bit 1 of host.loopReal (no new runtime row); the step is found by name (kAppStepFunction, a root of the dead-function pass). The library (zn_app) has its own copies of rt and of the host built with ZN_APP_LIBRARY rather than reusing zn_rt (it pulls zbc, ir and the frontend through CMake) and zn_host_gfx (process spawning, dlopen of macOS frameworks); nm finds no dlopen, fork or exec (tests/t0/app_library.sh). AOT programs only for now. | Revisit when a second consumer needs rt without the compiler (move zbc.cpp into its own archive), or when the player (interpreter + .zapp) lands. | | D121 | The heap of a phone app (ZN-622, 2026-10-16) | The Android shell sets ZINC_HEAP to the profile’s heap (128 MiB for android, baked by the export as ZN_APP_HEAP_MB), capped at a quarter of SDL_GetSystemRAM(); a ZINC_HEAP given at launch overrides it; the same 128 MiB is the program’s mimalloc budget. The 512 MiB desktop region cannot be reserved on a 1 GB 32-bit device (Nexus 7 2012). Left: mimalloc prints an “unable to allocate aligned OS memory” warning for the budget at every start (later.md). | Revisit when a program declares more, or on iOS (jetsam limits). | | D122 | Hot compile mode for Fast Refresh (ZN-742.01) | One switch, ZINC_HOT=1; outside it the ZBC is byte-identical. The loader handles import.meta.hot: outside hot mode the statements that need it become empty nodes (prelude scans do not see them); in hot mode it becomes zinc:hot’s hotModule(path) through an implicit import, so the hooks stay real module functions with keys. Each module gets an @init:<path> function and main runs the prelude, the inits, then __runLoop(); every top-level variable is a global so module state outlives its init. Keys: <path>::<name>, <path>::thunk:<name>, <path>#<offset> for a lambda (plus in <generic instance> and .n for async desugaring), ::<name> for structural classes. A prelude instead of an import would hide the hooks from the key scheme; without the suffixes keys collided on the desktop-app. | Revisit if a duplicate key shows up in a real app; ZN-742.02 refuses a swap on a shared key. | | D123 | Fast Refresh swap (ZN-742.02) | The zinc dev child recompiles in process inside the frame-end hook; the swap waits for pauseLoop (the loop ends, the window stays) and runs on an empty stack in the same Machine. Live objects are mapped to the new classes by key, field names and types; closures of changed files become zombie classes whose methods trap and which must be dead once the inits ran. A change to a library module or the prelude, a new native export, different baked resources or an undisposed top-level effect restarts with a reason; render, renderWindow, onFrame and import.meta.hot calls are allowed at top level. Memory-safe without stable function indices: every refusal is a restart, never a wrong mapping. Measured: 114-126 ms (two-file gfx), 330-400 ms (Solid), 180-300 ms (game-2d), against ~570 ms for a restart of the desktop-app. | Revisit if the compile pause in the frame hook is felt (compile on a thread) or if zombies refuse common edits. | | D122 | One program dims and keeps the screen on, on webOS and Android (owner 2026-10-16, examples/music on the Nexus 7) | The screen kept on and the backlight are device queries of zinc:sys (wakelock, backlight), answered by a simulated device first, then the webOS display driver (zinc-helper), then the host’s own answers (Android: SDL’s screen saver for wake lock, the window’s screenBrightness set on the activity’s UI thread, 0 dark, -1 the system’s). zinc:webos setBacklight and navigator.wakeLock both go through them, so a webOS program runs on Android with no change to its code; setPerformance and openPort answer false off webOS (nothing to raise or open on a phone). Chosen over an android target in zinc:webos and zinc:device: no new plugin dependency on the TouchPad build, one answer per query in the host. Plugins that only use the network require net, not process (socket, osc, sonos, spotify). HTTPS on Android reads the system’s root certificates (/system/etc/security/cacerts, plus the user’s added ones). | Revisit when a third platform needs another service of zinc:webos (then a common module zinc:screen), or when a program needs steady clocks (setSustainedPerformanceMode). | | D124 | Fast Refresh components and boundaries (ZN-742.03) | In hot mode only, calls to the project’s components get a _hotC proxy: when the file that declares one runs again, its instances are disposed and rendered again under the same owner, so parents and siblings keep their state (solid-refresh’s rule). A module that exports only code and types (functions, components, classes, interfaces, type aliases) is a boundary: importers reach it by symbol and live objects move to the new classes by key (D123), so only exported values make importers run again. This departs from React Native, which re-runs the importers of any module exporting more than components (owner report: editing game-2d’s scenes.ts reset the round). The desktop-app’s state.ts, router.ts and main.tsx still restart on their top-level effects: re-running state.ts would reset the state a refresh keeps, an allow-list cannot tell a call that replaces (setColorMode) from one that stacks (menus, listeners), an empty dispose in the template would teach users to silence the check, and main.tsx restarting matches RN’s index.js. | Revisit if users edit state modules often. | | D123 | When an Android app keeps the screen on; kiosk and fullscreen (owner 2026-10-16, examples/music on the Nexus 7) | An Android app lets the screen sleep as a phone app or a web page does; a program keeps it on with navigator.wakeLock (zinc:sys “wakelock”, D122); zinc.json targets.android.kiosk keeps it on, traps the back button and goes immersive full screen, targets.android.fullscreen only the last. Both are baked by zinc export as ZN_APP_ENV for every build, debug or release. SDL keeps the screen on for every app by default (FLAG_KEEP_SCREEN_ON unless SDL_HINT_VIDEO_ALLOW_SCREENSAVER), measured on the Nexus 7 (fl=#1810980), so the shell sets the hint unless the app is a kiosk. Immersive sticky comes from SDLActivity behind SDL_SetWindowFullscreen (the HAL’s existing ZINC_KIOSK path); vsysui=0x1706, 1280x800 with no bars. A kiosk app’s own dimming (examples/music: black and window brightness 0, the next touch wakes) is unchanged. Why: Zinc’s apps are apps, not games; a screen that never sleeps drains a battery on modest devices, and the program that needs it on says so. | Revisit if a game with a gamepad or a video player is seen sleeping; Home and Recents still leave a kiosk (Android’s lock task mode would need a device-owner or pinning flow). | | D124 | Runtime shaders in zinc:gfx (ZN-746, owner 2026-10-16: the lava lamp face of examples/music) | A program’s shader is a GLSL ES 1.00 fragment main() behind a fixed head (v_uv, u_size, u_p[8]), made with shaderProgram(src, scale) and drawn with drawShader as a SHADER command of the command list; the GL replay draws it (desktop GL core, display-gl, and on Android a GPU layer under the software-rastered frame, without waiting for ZN-741); without a GPU the program’s own Zinc code draws the same picture at a lower resolution with ImageFlag.Smooth, chosen with shaderAvailable(). Changing only a shader’s uniforms damages nothing, so the CPU rests while it animates. Scored (RULES §4: fit x3, performance x3, size x2, maintainability x2, licence x2, portability x1, effort x1): GLSL ES 1.00 5,5,5,4,5,3,5 = 74; an SkSL-like language of our own translated to GLSL, Metal and a CPU interpreter 4,4,2,2,5,5,1 = 54; a Zinc callback per pixel on the CPU only 2,1,5,4,5,5,4 = 52. GLSL ES 1.00 is what WebGL 1 and every GLES 2 GPU compile natively, glslang validates it, mediump-safe by construction (D119); a per-pixel GLSL interpreter is ruled out (the 80x60 field plus a smooth stretch is already 22 fps on the Nexus 7’s CPU). zinc:gl (WebGL) and three.js keep their own contexts: SHADER is a 2D fill, not a GL API. Lava face on the Nexus 7: 4.0 fps before, 21.9 with raw pixels and one smooth draw, 61 fps on the GPU layer (program scale 4); tests/t1/shader_gl.sh compares the GPU frame with the CPU path (0% of pixels more than 24 apart). | Revisit for a Metal replay on iOS (SPIR-V from glslang through SPIRV-Cross), or for programs that need several passes. | | D125 | The iOS project (ZN-623, ZN-624, 2026-10-16) | zinc export --target ios writes a CMake project that CMake’s Xcode generator turns into the Xcode project: the engine through add_subdirectory (zn_app with SDL3’s UIKit backend), the AOT C++ of --profile ios, the plugins compiled from their sources, Info.plist from zinc.json, an asset catalog with the icon, assets/ in the bundle; minimum iOS 16.3. Scored (RULES §4): CMake with the Xcode generator 64; SDL’s Xcode project plus an app project 49; a .pbxproj written by zinc 49. SDL documents iOS builds through CMake (README-cmake, -DCMAKE_SYSTEM_NAME=iOS), the Android export already builds the engine through CMake (one engine build description for both phones), a .pbxproj would list every engine source, and SDL’s Xcode project is not vendored. Minimum iOS 16.3: the system C++ library has std::to_chars for doubles from 16.3 (iPhone 8 and later). Cost: the first configure takes about 5.5 minutes on a loaded machine, a build after it 41 s. hello’s output equals zinc run (29 lines, os_log drained at quit); rn-showcase at 60 fps on the iPhone 17 simulator (software raster through SDL_Renderer), start 539 ms; 9.0 MB Debug, 8.2 MB Release. | Revisit when the configure is too slow (a prebuilt libzn_app.a), or for iOS 15 devices (a vendored shortest-double formatter). | | D126 | Scrolling through the GLES 2 replay on a slow GPU (ZN-741, ZN-744, 2026-10-16, Nexus 7) | A vertical scroll keeps the scroll area’s rows rotated in the frame texture and replays only the rectangles diff_move names; on by default for GLES 2, off on the desktop (ZINC_GL_MOVE=1). One capability record and a tuning table by GPU family (runtime/gl_caps.h, ZINC_GL_INFO) choose the lean programs (Tegra 3, Adreno 2xx) and skip the window clear on non-tiling GPUs; shaders place positions relative to each primitive (mediump-safe). Android keeps the software raster by default until a replay pass costs under 1 ms. Scored (RULES §4): rotated rows 66, moving pixels through a scratch texture 63, a tiled layer of the whole content 51, software raster with a GPU compositor 47. Why: on Tegra 3 the GPU replays slower per pixel than four A9 cores rasterise (a 1280x50 strip: 4.5 ms of GPU against ~1 ms of CPU; each pass costs 1-3 ms of CPU before any pixel) and texture uploads run at ~100 MB/s, so GLES 2 wins only by drawing less. Measured (rn-showcase, ten flings, 720 frames): GLES 2 p99 56.8 ms before, 31.6-32.5 ms after (221-285 late); the software raster p99 26.8-29.4 ms (222-263 late); at rest and in tab switches both are level (the screens’ own effects and layout, 30-118 ms, dominate a switch). Tried and dropped: one depth-masked pass over the union of rectangles (GPU 7-19 ms), rotating vertex buffers (no change), a GPU compositor over the software raster (glTexSubImage2D 26-30 ms a frame), EGL preserved swap (refused by Tegra 3). | Revisit on GPUs whose replay beats their CPU (Mali, Adreno 3xx and later), and when a replay pass costs under 1 ms. | | D127 | Plugins on phones (ZN-628, 2026-10-17) | A plugin declares what a phone needs in plugin.json android and ios entries (sources: .java/.kt to Gradle, .m/.mm/.swift to Xcode; gradle dependencies; manifest permissions and fragments; minSdk; Info.plist fragments; entitlements; lifecycle; class), merged by zinc export (Expo’s config plugins, without code); zinc native-gen --android/--ios writes the Java interface + JNI bridge or the ObjC/Swift protocols + ObjC++ bridge from the plugin’s spec.ts (i32, u32, f64, boolean, string, void callbacks, run on the program’s thread). zinc:system keeps one op table with the C++ permission gate and the simulator, plus a thin live backend per OS (Java over JNI, ObjC). The model scored 65 in docs/reports/native-fidelity-and-plugins.md §4.4 (against 56 and 56). One op table rather than moving the module to Java keeps the permission gate and the simulator as one code. Proved: zinc:sqlite on the iOS simulator and the Android emulator (database kept across starts), a Swift and a Kotlin fixture with callbacks from another thread and lifecycle, the share sheet, opener, local notifications with the permission prompt, document and photo pickers, deep links warm and cold (Android). A plugin without an entry for the target is refused at export with its name; the Android manifest lists exactly the declared permissions. App plugin libraries are named zn_app_plugin_<name>; app.urlSchemes becomes intent filters and CFBundleURLTypes. Fixed on the way: the opener’s scope check runs before the live backend (macOS opened URLs outside scopes.opener.allow). | Revisit if a plugin needs imperative project edits, or Promises, arrays or records across the glue. | | D128 | Android CPU cores, steady clocks and the window’s byte order (ZN-750, owner 2026-10-17, Nexus 7) | The engine asks Window.setSustainedPerformanceMode while frames move (from the first damaged frame to one second after the last) and while a program asks for full speed (zinc:webos setPerformance, a zinc:sys performance query), where PowerManager.isSustainedPerformanceModeSupported says the device has it; nothing else: no thread spinning, no hotplug writes (an app cannot, and keeping the raster’s workers runnable between frames lost frames: 365-427 late of 720 against 314-366). The development Nexus 7 keeps its four cores online through nexus7-tune.sh (Tegra’s cpuquiet userspace governor; autosmp is built in but off). The window buffer is RGBX_8888 with a per-stripe red/blue swap from API 26, BGRA_8888 drawn in place below. Cores, rn-showcase ten flings (late of 720): 1 core 474, 2 cores 443, 4 cores 240-297, the ROM’s balanced 303-366; music carousel swipes (late of 390): 327, 203, 184-189, 175-199; with the tune applied 255-302. isSustainedPerformanceModeSupported is false on the Nexus 7 and on the API 37 emulator. Byte order: the API 37 emulator accepts HAL format 5, reports it back and composes it as RGBA (no query tells such a device apart); RGBX everywhere would cost the Nexus 7 5 ms of raster per scrolled frame (p50 13.2 against 8.3 ms). | Revisit with an API 26-36 device measured honouring BGRA (move the line), a raster that writes RGBA itself (no swap), or a device that supports the sustained mode. | | D129 | Phone packages from zinc export (ZN-629, 2026-10-17) | --aab, --apk and --ipa [--simulator] run the platform’s own builders (Gradle, xcodebuild) in the exported project and check the result; --dry-run prints the commands in tools/sign-macos’s form. Signing secrets never enter the project: the Android upload key’s path and alias come from the environment or zinc.json signing.android, its passwords only from the environment, read by Gradle at build time (zinc.json refuses a password); without a key the debug keystore signs, and the export says Play will refuse it. iOS: ExportOptions.plist from zinc.json signing.ios (team, Xcode 15.3’s method names with the old ones mapped, automatic signing or a named profile). The 16 KB check reads the ELF program headers of every packaged 64-bit library itself (plus zipalign -c -P 16 for an APK) rather than trusting the link flags. PrivacyInfo.xcprivacy comes from a table of the engine’s required-reason APIs verified with nm -u (file timestamps C617.1, system boot time 35F9.1, disk space E174.1) plus the plugins’ targets.ios.privacy. Scored (RULES §4): the export runs the builders and checks 63; print the commands only 50; a fastlane lane 41. Proved: an APK and an AAB (bundletool install-apks, release, non-debuggable) on the API 37 emulator, a simulator build on iOS 26.3, each printing what zinc run prints; the dry-runs tested with every Apple and Android tool as a trap. Uploads and a device IPA need the owner’s paid accounts (commands in the ZN-629 notes). | Revisit if Play or Apple add a check zinc can run locally, or for zinc publish --play / --app-store. | | D130 | Platform looks for the kits (ZN-748, owner 2026-10-16) | Four token tables (iOS, Android Material 3, macOS, neutral) in lib/std/platform/tokens.ts, module zinc:ui/platform, read by the existing components: zinc:ui/kit’s Button, Switch, Slider, Tabs and typography take the platform’s sizes and shapes, and its colours only while the app keeps the kit’s built-in theme; the nuxt kit changes only its Switch shape; zinc:react-native’s Switch, Button and ActivityIndicator draw as each platform does. auto (the default) gives iOS, Android, macOS on a Mac, neutral elsewhere; zinc.json ui.platformLook, ZINC_PLATFORM_LOOK, setPlatformLook() or <ThemeProvider look> force one. Behaviour follows ui.platformStyle() and is not themeable: long press opens the kit ContextMenu on a touch screen, haptics where the OS’s own controls give one (iOS switch, segment, long press; Android long press). On a Mac the tint is the user’s accent colour (device('accent')). Scored (RULES §4): token tables read by the existing components 65, colours only 61, two component sets as Flutter’s Material and Cupertino 51. Android means Material 3, not React Native’s older switch. The tables build their text classes at run time (the font baker bakes every literal size of the sources, library files included). Fonts apply only when the app ships them: SF Pro and Android 12+ Roboto are variable fonts the rasterizer cannot draw yet. A deterministic run on a Mac without ZINC_PLATFORM takes neutral, so no existing golden changed; tests/t1/platform_look.sh holds one golden per profile. | Revisit with variable fonts in runtime/ttf.cpp (then the OS’s own UI font), Android 12+ dynamic colour, or a control the tables do not cover. | | D131 | WebRTC stack and media (2026-10-16, zinc-webrtc.md) | libdatachannel with libjuice and usrsctp on the engine’s mbedTLS, no WebSocket (zinc:socket has it); media on: Opus and VP8 everywhere, H.264 only in hardware (VideoToolbox now, V4L2 M2M, MediaCodec and Media Foundation in the cross-platform pass), libsrtp’s own crypto; one plugin for data, audio and video. Revisit for a browser that drops VP8, a libdatachannel tag with mbedTLS 4, or an ESP32 build (libpeer). | | D132 | The QuickJS bundler (2026-10-16, zinc-quickjs-bundler.md) | zinc bundle: the engine’s own transform stage (RN imports, StyleSheet, JSX lowering, type strip, content-cached), its own lint rules (Z0005-Z0007, Q0001), esbuild pinned through the toolchain manager only for linking, optional .qbc bytecode; running a project needs no tool. ESLint (needs Node), oxlint and Biome (too large or JS plugins alpha) rejected. | | D133 | The Windows port (2026-10-17, zinc-windows-port.md) | The pinned zig (x86_64/aarch64-windows-gnu, no Visual Studio); OpenGL through SDL3 with the CPU renderer as fallback; plugins as .dll against an import library of zinc.exe; tests in Git Bash; a signed zip plus install.ps1, then winget; Azure Artifact Signing. Scores are in the report. | | D134 | Linux builds from the Mac (2026-10-17, docs/targets/linux.md) | Dynamic glibc 2.28 (61) over static musl (54, no plugins, X11, KMS or Mesa; kept as a headless option); armhf targets ARMv6 (62) over ARMv7 NEON (57) to cover the Pi 1 and Zero; aarch64 tuned for the Cortex-A53, x86_64 baseline; i686 built but not published; the sysroot is 41 Debian bookworm packages from a fixed snapshot date, checked against SHA-256 pins (tools/linux-sysroot.lock), not vendored headers. Device proof (window, GPU, KMS, touch) is still to do. | | D135 | zinc:video on phones (ZN-751, 2026-10-17) | zinc:video on phones uses the platform decoders behind player.h: Android AMediaExtractor + AMediaCodec into an AImageReader of YUV_420_888 (the layout every decoder must support, strides and crop with each image), with a ByteBuffer fallback (planar, semi-planar, Qualcomm NV12; stride and slice height from the output format; ZINC_VIDEO_BYTEBUFFER=1 forces it); iOS AVAssetReader with a track output in 32BGRA (VideoToolbox decodes and converts, the backend only scales); pictures converted to the runtime image on the decode thread, as the webOS backend does. Rejected: FFmpeg on phones (size, codec licences, and no hardware decode without MediaCodec/VideoToolbox glue anyway), ByteBuffer-only on Android (vendor colour formats and slice-height quirks on old devices), a SurfaceTexture/EGLImage path (the software raster is the phone default; it belongs with the GPU replay). Measured: hero on the API 37 emulator 24 pictures a second, 1 dropped at start, 0.8-5.4 ms per conversion, work p99 11.8 ms; on the iOS 26.3 simulator 0 dropped, 0.1-0.4 ms per copy, work p99 7.2 ms; tests/data/mobile-video prints the same playlist sequence on the Mac, the emulator and the simulator. | Revisit when the GPU replay is the phone default (zero copy), or when a device’s decoder refuses both paths. | | D136 | Native views over the Zinc surface (ZN-749, owner 2026-10-16) | A <NativeView view={host}> (zinc:ui, and zinc:react-native with style) lays a real OS view over the surface, never drawn into it: after each painted frame zinc:ui computes the node’s box, its clip (the surface, then every scroll area and overflow-hidden ancestor up to its layer) and whether it shows (hidden when unmounted, hidden, invisible, transparent, clipped away, or under a zinc:ui layer that overlaps it: menu, popover, toast, a dialog with a backdrop), and calls host.place(x, y, w, h, cx, cy, cw, ch, shown) only when one changes. macOS and iOS: a clipping NSView/UIView added to SDL’s view; Android: a FrameLayout above SDL’s SurfaceView moved with translations on the UI thread (ZincNativeView.java). plugins/webview runs on it everywhere (WKWebView; android.webkit.WebView behind the generated spec of D127, zinc://app/ served as https://appassets.androidplatform.net/, a bridge token per view). Scored 66 against split layers 50 and a texture 40 (docs/reports/native-fidelity-and-plugins.md §4.3). Hiding rather than a snapshot under overlays: snapshots are asynchronous on both platforms and the kits’ overlays are short-lived. Cost: about 5 µs a frame per native view on the zinc:ui side (nothing at rest), the native update 5.5-8.1 µs on macOS, 10-43 µs on the Android emulator. Proved: macOS (tests/t1/webview_hybrid.sh), the Android emulator (a swipe scrolls the web view with the page, clipped at the bar; a tap inside reaches the page), the iOS simulator (60 fps, clipped when scrolled); docs/reports/zinc-next-native-view.md. | Revisit for a snapshot under modal sheets, or if a native view trails a fling visibly on a slow device. | | D137 | The iOS renderer (ZN-627, 2026-10-17) | iOS ships the software raster with a zero-copy present as its default: the window is SDL_WINDOW_METAL with no SDL_Renderer, the raster draws the damaged rows into a shared MTLBuffer, and each frame with damage is one GPU blit into the CAMetalLayer’s drawable (targets/macos/hal_metal.mm); a frame without damage presents nothing. Its GPU path is our own Metal back end of the command lists (D119), built when a device misses frames (60 Hz on an A11, 120 Hz on ProMotion), when programs need SHADER on the GPU on iOS, or when macOS’s deprecated OpenGL replay needs a successor; the plan (split gl_replay.cpp into an API-agnostic batch and the GL back end, mtl_replay.mm, GLSL ES 1.00 to SPIR-V to MSL through SPIRV-Cross) is in the ZN-627 notes. Scored (RULES §4, out of 70): the software raster with the zero-copy present 4,4,5,5,5,3,5 = 62; our own Metal back end 5,5,4,3,5,3,2 = 59; the GLES 2 replay through EAGL (deprecated since iOS 12) 47; ANGLE on Metal (several MB, its own gn build) 41. No Metal spike: the simulator times the Mac’s GPU, which says nothing about an iPhone’s per-pass cost (the lesson of D126). Measured on the iPhone 17 simulator (rn-showcase, 1,200 frames): the whole-frame upload (p50 6.61-7.47, p99 9.96-13.90 ms) is gone, the main thread per drawn frame ~9.3 -> ~3.5 ms, interval p99 18.26-20.73 -> 17.22-18.66 ms, app CPU at rest over 10 s 0.61-0.74 -> 0.16-0.17 s; pixels byte-identical. | Revisit with a real iPhone’s numbers, or one of the triggers above. | | D138 | Screen readers on phones (ZN-630, 2026-10-17) | zinc:ui’s accessibility tree reaches VoiceOver and TalkBack through our own bridge per phone, fed by a text diff that zinc:ui sends through zinc:sys device('a11y') only while a reader listens (Flutter’s model): Android exposes virtual views of SDL’s surface through an AccessibilityNodeProvider (ZincAccessibility.java), iOS UIAccessibilityElements of a container over SDL’s view (src/app/ios_a11y.mm, hitTest nil so touches pass through); focus, activate and scroll come back as system signals (a11yfocus, a11yactivate, a11yscroll). A clickable node folds its descendants on iOS (UIButton’s convention); TalkBack folds itself. With no reader: one boolean per frame, nothing sent. Scored 63 against AccessKit 41 (Rust toolchain, no iOS adapter, a young Android adapter; it stays the candidate for the desktops) and one hidden native view per node 38. Proved: uiautomator dump on the API 37 emulator and XCUITest’s element tree on the iOS 26.3 simulator equal ui.inspect() for rn-showcase’s Home and Forms (empty diffs, tools/a11y-tree); ACTION_CLICK through UiAutomation and an XCUITest tap by label open Forms; tests/t0/ui_a11y_bridge.sh (interpreter and AOT equal). Not proved: VoiceOver and TalkBack speaking and navigating (no VoiceOver on the simulator, no TalkBack gestures from adb). Reader on, 300 nodes scrolled each frame: 54-60 -> 217-219 µs a frame (AOT). docs/reports/zinc-next-a11y-bridges.md. | Revisit when AccessKit gains an iOS adapter, or for macOS and the desktops. | | D139 | Edge-to-edge, safe areas, lifecycle and the keyboard on phones (ZN-625, ZN-626, 2026-10-17) | Phones draw edge-to-edge (Android 15’s rule and iOS’s): the safe area is the bars plus the cutout, not the gesture zones, and the program pads itself (SafeAreaView, useSafeAreaInsets, env(safe-area-inset-*)), as React Native with react-native-safe-area-context does; the engine does not inset the root. Android delivers background by running up to 8 program frames inside SDL’s WILL_ENTER_BACKGROUND event (SDL’s pump blocks in the call that handles the pause). The keyboard: KeyboardAvoidingView pads by the part of its own box the keyboard covers (as React Native), zinc:ui reveals the focused field again after each layout while the keyboard is up (8 px of room), Android 35+ uses adjustResize, iOS resigns the input view when no field has focus. Rejected: the engine insetting the root (React Native apps expect to draw under the bars; kiosks and games want the whole screen). Proved on the API 37 emulator and the iOS 26.3 simulator: rn-showcase survives home and return (same process, text kept) and rotation; tests/golden/ui/type.tsx typed on Gboard and the iOS keyboard equals its golden; the phone pad opens with the field 8 px above it (tests/t1/keyboard_avoid.sh, which fails on the old code); the desktop script verbs background, foreground, safe-area, rotate drive tests/t0/system_signals.sh. New: ui.appState(), React Native AppState, device('orientation') on phones. | Revisit below API 35 (adjustPan) on a real older phone. | | D140 | Zinc Go, a player for phones (ZN-631, owner 2026-10-16: an Expo Go for Zinc) | Programs for the player are compiled on the developer’s machine and sent as hot units (ZBC, stable keys, module graph, baked resources, zstd) over TCP by zinc dev --lan, Expo’s model, rather than shipping the compiler and lib/std on the phone or a process per program. zinc dev --lan prints a QR code and a zinc://<lan-ip>:<port>/<code> link (a wrong code is refused); the player (zn_app_player: the interpreter, src/app/player.cpp, the swap code of src/dev/hot_swap.cpp) swaps a save in between two ticks with the existing Fast Refresh and restarts the program in the same app when a swap is impossible. zinc.json "player": true exports a player whose home screen ships as bytecode; examples/zinc-go is Zinc Go (scan with zinc:camera scanQr() on quirc 1.2 (ISC), type a link, reopen the last). A program needing a plugin the player lacks names it and nothing crashes. A user’s release stays AOT with no player code (D59, nm in app_library). React-model Fast Refresh: the whole tree renders again after a save, hook state kept, memos recomputed, effects not re-run. Scored (RULES §4): hot units from the developer’s machine 66; the compiler on the phone 46; a process per program 45 (iOS cannot restart an app). Units are compiled without the optimiser, as Metro serves unminified dev bundles (770 -> 570 ms a save on rn-showcase). Proved: rn-showcase opened by deep link on the API 37 emulator, a scripted save of Forms.tsx refreshed in 854 ms with a ticked checkbox kept; on the iOS 26.3 simulator in 725 ms; desktop player 61-87 ms; compile 570-640 ms, device 41-51 ms. Not proved: scanning the QR on a real device over Wi-Fi (criterion 4). docs/reports/zinc-next-zinc-go.md. | Revisit if a save of a large project stays above 1 s (a parse cache, or compiling inside zinc dev’s process). | | D141 | Handheld and console targets (ZN-453 to ZN-457, 2026-10-17) | psp, vita, n3ds, ios-legacy and ps2 share one portable core: a prebuilt interpreter core per console (the ZBC program and resources embedded or beside it), with AOT as an option for speed; psp and ps2 use f32 numbers, vita, n3ds and ios-legacy f64; no libuv on the consoles (a small system host in targets/console); one renderer per GPU over device kernels written clean-room (no Pocket3D code copied; software raster first); SDKs pinned by SHA-256 in the toolchain manager (pspdev v20261001, VitaSDK 20261015, ps2dev v2.0.0, devkitARM through a pinned docker image because pkg.devkitpro.org refuses scripts and its installer needs sudo); emulators: the owner’s installs first (PPSSPP, Vita3K with its firmware, PCSX2 with the owner’s BIOS, Azahar), pinned ones for other machines (PPSSPPHeadless built from source, Play! for a PS2 without BIOS); no ROM, BIOS or firmware in the repository. Scored (RULES §4): prebuilt interpreter core 63, AOT only 57, a QuickJS core 44 (the interpreter runs numeric kernels 5.4 to 6.9 times faster than QuickJS; PocketJS holds a 60 fps PSP game with 2.2 ms of QuickJS a frame). Proved: “hello from zinc” with a moving rect and text in PPSSPP, Vita3K, PCSX2 (byte-identical frame in Play!) and Azahar (tests/t2/console_emulators.sh); tools/build-console-core and tools/console-run; docs/targets/handhelds.md. | Revisit when breakout misses 60 fps in PPSSPP at 333 MHz. | | D142 | The Android GPU layer on demand (2026-10-17, examples/music on the Nexus 7) | An Android window is the CPU’s (ZN-743’s zero-copy present) and becomes a GL window with the GPU layer only while frames draw a SHADER; it goes back to the CPU once frames without a shader rest (no damage), or after 60 of them. The GL context, programs and textures are kept; the CPU’s hold on the buffer queue is released with ANativeWindow perform(NATIVE_WINDOW_API_DISCONNECT, NATIVE_WINDOW_API_CPU); if that fails, shaderAvailable() becomes false and the program draws its CPU path. On the GL window a frame is rendered once and only the rectangles drawn over a shader are rendered again for their alpha (stored inverted in the alpha byte, uploaded with no conversion). Bilinear images are sampled by rows (each source row blended once and reused), with a NEON path for opaque pictures on ARM. zinc export --apk --release builds a release APK; a release build reads only ZINC_PRESENT_STATS and ZINC_GL_STATS from zinc.env in /sdcard/Android/data//files/. Why: the music remote ran its whole session on the GL window because the lava face makes its shader at load, so every damaged frame was rendered twice, converted on one core (30 ms) and uploaded (11 ms); the player’s 12x12 stretched backdrop took 77% of the raster (107 ns a pixel, now 53). Measured on the Nexus 7, release APKs: carousel (no shader) p50 183 -> 50 ms, into and out of the lava 183 -> 67-83 ms, radios scroll 67 ms with 467-650 ms hitches -> 33 ms flat, the lava face at rest 60 fps both. Rejected: the shader on the CPU only (lava at 22 fps), a GL window for the whole run, hiding the SurfaceView to switch (a visible flash), two textures uploaded in turn (11-12 ms either way on Tegra 3). | Revisit when an Android device refuses the disconnect, when the ~380 ms switch back (mostly the compositor) is measured with systrace, or with a compositor-moved layer for the carousel. | | D143 | zinc:link: two Zinc programs that find each other, pair with a code and talk encrypted (ZN-756, owner 2026-10-17) | plugins/link speaks zinc agent’s protocol: DNS-SD _zinclink._tcp (TXT id/svc/name/pair/v), a clear line ZNL1 pair\|sess, TLS 1.2 EC J-PAKE with the 6-digit code (5 minutes, 3 tries, one peer), then TLS 1.3 PSK+ECDHE with the paired key; JSON and byte frames, pings after 15 s of silence, reconnects (1 to 30 s) while paired. The DNS-SD and frame code moved from src/agent to src/net (shared, not copied); the plugin compiles tls.cpp in its own namespace (ZN_TLS_NS) so it never clashes with the engine’s copy. Native side on the program’s thread with non-blocking sockets, events pulled every 10 ms (mbedTLS is never entered from two threads). Android discovery: a WifiManager.MulticastLock held from onResume to onPause with the shared C++ responder; keys in $ZINC_HOME/link/<app>.keys, mode 0600. Scored: MulticastLock + the shared responder 69 against NsdManager 50 (a second implementation in Java, and Android 7’s NSD resolves one service at a time); a 0600 plugin file rather than zinc:storage (storage.kv is shared by every program on a desktop and written 0644). Measured Mac <-> Nexus 7: found in 63 ms, a wrong code refused, paired (J-PAKE ~0.4 s on the Tegra 3), JSON and bytes both ways, a restart reopened the session with the stored key, a restart under an open channel reconnected in 4 s. WebRTC across networks is planned (docs/plugins/link.md, “Across networks”: the same frames over a data channel, a signalling room named by a hash of the code then of the key, the J-PAKE/PSK TLS inside the data channel), not built. | Revisit for discovery with the app in the background, the system keychains for keys, or when WebRTC lands. | | D144 | 3D on the consoles: each console’s GPU, existing code, Pocket3D’s cooking (owner, 2026-10-17) | zinc:3d draws on each console’s GPU through the SDKs and proven code of docs/reports/games/console-2d-3d-frameworks.md, never a new software 3D: PSP sceGu/sceGum hardware T&L with VFPU matrices (ZN-491 kernel, ZN-476 zinc:3d), Vita GXM with precompiled GXP from Cg (ZN-493, ZN-477; no vitaGL: LGPL-3.0 and libshacccg extracted by the user), 3DS citro3d with picasso programs and stereo from the 3D slider (ZN-495, ZN-478), PS2 gsKit triangles fed by render3d’s CPU front half, then AthenaEnv’s MIT VU1 microprograms (ZN-760). Pocket3D’s method for assets: one source, a cooker driven by a per-console profile run by zinc build --target <console> (quantized chunked meshes with baked light ZN-489, swizzled/tiled/CLUT/ETC1 textures ZN-452, pack viewer ZN-490), budgets are build errors, every cook writes a deterministic receipt. Order: ZN-759’s hardware demos first, then the GPU back ends, audio (ZN-482, no longer waiting on the iPhone 4S), and marble last (ZN-754 depends on them all); then the owner runs everything on the real consoles they own (ZN-754 AC4). The chain is handed to agents by the coordinator as the owner’s request; the cross-platform group of priority.json is unchanged for the rest of m-23. | Revisit if a console’s GPU path fails its frame budget in the emulator, or on the owner’s hardware results. | | D145 | The web target (2026-10-17, zinc-web-export.md, docs/targets/web.md) | The app’s zinc bundle JavaScript runs on the browser’s own engine behind a thin JS host (targets/web/runtime.js); no ZBC, no AOT, no wasm for ordinary apps (scored 77 against 54 interpreter in wasm, 50 AOT to wasm, 47 QuickJS in wasm; the wasm target stays for console-style programs). Two render modes, canvas (default, 60) and DOM (57), Zinc’s classic layout does the layout in both. Hot reload is Vite-style (unbundled modules, forwarded function exports; a module that exports state reloads the page). Plugins declare web in plugin.json as entry, native+impl or wasm (wasm only where native code is needed: sqlite). Browser limits: targets/web/browsers.json (MDN), warnings W0601 to W0603 served by webExplain, zinc:browser at run time (ZnUnsupported). targets.web holds export settings; the web key of zinc.json stays the webenv switch. Only Chrome was run. | | D146 | Semantic DOM, native video and lottie on the web (2026-10-17, zinc-web-export.md “Decision 2b”) | --render dom is a real DOM laid out by the browser’s CSS: atomic generated classes (hashed, deduplicated), CSS custom properties for values that change, Zinc’s classic layout kept underneath only for geometry calls and virtual lists (70 against 56 for the absolute painter, now deleted, 63 for a second React DOM UI, 62 for switching Zinc’s layout off). Tags and roles come from node attributes (as, role, href…) so one source runs on every target; Form is a real <form> only in the page. Video is the browser’s <video> with a visible fallback and export warning W0604; lottie is vendored lottie-web 5.13.0 (MIT) loaded lazily through the new plugin.json web.scripts. Prerender is an optional Chrome capture at export, adopted at hydration. Chrome only so far. | | D147 | npm packages and Vite on the web target (2026-10-17, docs/targets/web.md, web-libraries.md) | esbuild is the single resolver: dev pre-bundles bare imports once into /__deps (Vite’s model, cache .zinc/web-deps/<key> keyed by lockfile, package.json, each package’s package.json and imported names), export and the QuickJS link share webResolveFlags (a C++ resolver was rejected: it ignored exports conditions and the browser field). The package manager is the project’s own (packageManager field, then lockfile, then npm; zinc add npm:, zinc install), Zinc never installs one; Node is needed only to install (P0001, P0002, W0605). @zinc/vite delegates Zinc’s transform to zinc dev --prepare (the JSX lowering needs the whole module graph) and Vite owns packages, HMR and bundling. 13 libraries tested in dev and export; yarn and Rspack not run. | | D148 | Web plugins: WebGL, three.js, shaders, hardware APIs (2026-10-17, zinc-web-export.md Decision 5) | A WebGL picture reaches the Zinc surface as a gfx image, so it keeps paint order, clip, transform and fade and works in DOM mode (81 against 66 and 55 for a second canvas layer); the zinc:gl rows are generated from the engine’s table (84); the real three.js r186 runs on a page-made WebGL2 canvas behind a thin renderer wrapper (83 against 54 for re-implementing the Zinc subset on WebGL). drawShader renders the GLSL ES 1.00 fragment through WebGL into an image. No Yoga and no wasm for layout on the web (owner): an explicit ui.layout: "rn" falls back to the classic layout with React Native semantics, capabilities.json web.ui.rn: false. physics2d is Box2D 3.1.1 in wasm through a C shim (the one native-code case). Serial, MIDI, gphoto2 (about 300 lines of PTP over WebUSB, 79 against 63 for libgphoto2 in wasm) and OSC (over a WebSocket relay) answer their native spec in JS; the map is vendored MapLibre GL JS (81 against 50 for our own renderer), loaded byte for byte. Hardware ran against mocks only. | | D149 | Zinc Relay: how a Mac’s notifications are read (ZN-757, owner 2026-10-17) | Notification Center’s SQLite database (macOS 15: ~/Library/Group Containers/group.com.apple.usernoted/db2/db; the schema from usernoted’s own strings), read-only in place through zinc:sqlite, polled every second, under Full Disk Access granted once through the relay’s setup assistant. Focus from ~/Library/DoNotDisturb/DB/Assertions.json + ModeConfigurations.json; “Suivre le mode” uses the record’s presented flag. Avatars: the record’s own image, first the communication context (req.uncc, an NSKeyedArchiver of UNNotificationCommunicationContext): its sender’s image is an intents-remote-image-proxy: URL that the Intents image service (private INRemoteImageProxy _retrieveImageDataWithReply:, guarded) returns to an unentitled process (Discord, checked on the owner’s Mac 2026-10-17), and its sender’s Contacts identifier gives the Contacts photo (Messages); then Messages chat.db + Contacts, then Contacts by name, then an initials monogram. Bundle ids are compared without case (Notification Center records Messages as com.apple.mobilesms). Login item through SMAppService, a LaunchAgent as fallback. Private APIs allowed by the owner (not on the App Store): only the Intents image proxy; the record plist is read raw. Rejected: banners through Accessibility (on-screen only, no app id, a private view tree Apple changes); INFocusStatusCenter (needs an entitlement, yes/no only). | Revisit when a real record shows the avatar elsewhere (--inspect), or if a macOS release moves the database. | | D150 | Console hardware as zinc APIs, console audio and asset packs (ZN-759, owner 2026-10-17) | Each console’s hardware is a set of zinc:sys device() queries (D122’s pattern): the console HAL answers first (a new optional hal_device), then a simulated device (zinc run --simulate psp|vita|ps2|n3ds, devices/*.json), then the desktop; zinc:hardware wraps them (battery, network, motion, rear touch, microphone level, stereo 3D, vibration, save location). The pad is zinc:gfx gamepad 0 (D101) with pressure in HalGamepad.value[17] and gamepadRumble; the 3DS bottom screen is the program’s first openWindow; the PS2 memory card is zinc:storage. zinc:audio on the consoles is our own small mixer exposing the same native module (WAV, Ogg through the stb_vorbis plugins/audio vendors; 44.1 kHz PSP, 48 kHz Vita and PS2, 22.05 kHz 3DS), mixed on the program’s thread into a ring. Console cores link zinc pack’s default asset pack and serve zinc:assets and pack images from it. Date computes in f64 and avoids Math.* (32-bit under the psp/ps2 profiles). Rejected: miniaudio on the consoles (atomics the PSP and PS2 lack, no backends for them). | Revisit with asset groups on consoles, or when a console needs streaming audio. | | D151 | The Vita draws zinc:gfx with its GPU (ZN-470/471, owner 2026-10-17: “pourquoi la vita utilise pas son gpu… go le faire !”) | Our own GXM back end (targets/vita/gxm_vita.cpp) with SDL3’s precompiled GXP programs (zlib, from the vendored SDL3): the command lists are replayed at 960x544 native (clears, square rectangles, images from atlases with flips, tint, alpha and filtering, text, rectangular clips cut on the CPU); textures and the glyph atlas in CDRAM, three display buffers flipped at vblank. A frame holding a command the replay cannot draw yet (rounded corners, gradients, borders, shadows, paths, rotated matrices) falls back to the software raster into a texture the GPU shows; ZINC_VITA_GPU=0 keeps the old path. Scored in docs/reports/games/console-2d-3d-frameworks.md 4.3: own GXM 59, vitaGL 50 (LGPL-3.0 and libshacccg.suprx extracted by each user), PVR_PSP2 out (Sony modules without a licence). Measured on the owner’s PS Vita: Meadow Run at 60 fps, no missed vblank in 4,200 frames, work p50 3.2-3.7 ms, replay 0.3-0.44 ms (the software raster at 960x544 costs 28 ms a frame on the Mac’s clock in Vita3K; the owner found it unplayable). | Revisit when a zinc:gl path on the Vita needs shaders of its own (Cg compiled on a console), or when UI kits need the rounded and gradient commands on the GPU (ZN-471). | | D152 | Web target in a real browser (2026-10-17, docs/targets/web-demos.md) | The web is tested with real Chrome and trusted input at Retina scale (tools/web-real-test, --force-device-scale-factor=2, Input.dispatch*), not only headless DPR 1 with synthetic events; the matrix of demos x modes is in web-demos.md. A raw-gfx program in DOM mode gets a lazily mounted full-window canvas (the semantic DOM has no node for it). exit() on the web stops the loop and throws a silent ZnExit. An error repeating within one second is counted, not re-reported. A component unmounted before a queued update never re-renders (shared with the desktop engine: dead flag in lib/std/react.ts). Bugs fixed: blank screen after any resize, doubled typing in canvas text fields, blank DOM for gfx-only programs, audio never unlocked in DOM mode, favicon 404, error flood. | | D153 | Device information (ZN-761, owner 2026-10-18) | The expo-device API ships as the std module zinc:device-info over one JSON record, device('device') of zinc:sys (type, brand, manufacturer, modelName, modelId, designName, osName, osVersion, osBuildId, totalMemory, cpuArchitectures, isDevice); Platform.isPad, isTV and constants come from it. zinc:device is taken by plugins/device (board services) and a plugin wins module resolution: renaming the plugin would break the board demos. Unknown fields are ” or 0, never null (typed Zinc has no null strings here) and never guessed. getDeviceTypeAsync is an already-settled async function (Zinc refuses await on a non-promise). iOS model names come from a data-only C table (src/host/apple_models.inc); an unknown identifier keeps its modelId with an empty name. Phone or tablet: iOS’s userInterfaceIdiom; Android’s UI_MODE_TYPE_TELEVISION, else smallest width 600 dp. | A program needs deviceYearClass, or Apple adds an API that names the model. | | D154 | A UI app’s memory on macOS (ZN-764, owner 2026-10-20: “moins de 100 Mo … surtout les UI”) | The macOS window shows the software raster through Metal by default (hal_metal.mm, iOS’s present since ZN-627: the frame is a shared MTLBuffer the GPU blits into a CAMetalLayer drawable, at most two drawables); OpenGL replay only with ZINC_RENDERER=gl or —simulate (it turns the picture). The desktop GL window frees the splash’s rows and texture once the replay draws. Why: a window’s footprint is its window-sized buffers, 13.7 MB each at 1180x760 density 2; GL held nine (examples/agents 216 MB), Metal three plus WindowServer’s (81 MB); ui/forms 133 -> 45 MB. The raster keeps 60 fps for breakout and hero on the Mac. Lost by default: zinc:gfx shaders on the GPU (music, music-tree draw their CPU picture) and GPU strokes. | A program that needs the replay’s speed at Retina (a full-window animation slower than a frame on the raster), or the IOSurface-contents present (later.md) lands. | | D155 | Loro in Zinc (owner 2026-10-21: “utiliser Loro dans zinc en natif”) | zinc:loro (also loro-crdt) wraps the Rust crate loro behind an extern "C" API built by cargo from plugin.json (targets.<id>.cargo, --locked, cached under ZINC_HOME); its TypeScript mirrors loro-crdt. Containers are addressed by their id string and values cross the C boundary as JSON, numbers as doubles (loro-crdt’s value semantics, a small API). Events are queued in Rust and delivered right after the call that caused them (loro-crdt delivers synchronously). A Loro panic is caught (catch_unwind) and becomes a LoroError. Rust is the user’s cargo, not a pinned toolchain (pinning rustup is later.md). setPeerId takes a number (exact below 2^53), setPeerIdStr any u64: no number \| string parameter in the engine. Cost: +5.0 MB to an AOT program that imports it. | Loro ships a C API of its own, or Zinc gets generic instance methods (the typed containers). | | D156 | The interface of a microcontroller (owner 2026-10-22: “il faut une version ultra light dédié pour ce genre de device à la manière de lvgl quitte d’ailleurs à l’utiliser”) | On a board without PSRAM (ESP32 under mquickjs) the interface is LVGL 9.6 itself, vendored as the plugin zinc:lvgl on the native ABI: one C file serves the computer’s library and the firmware. LVGL keeps its objects, flex layout, scrolling, animations and drawing in C; the program holds i32 handles and polls an event queue (no C calls into JavaScript). It renders 32-row RGB565 areas into two DMA buffers that the firmware sends to the panel (zn_panel_flush, main/zn.h); the display list is set aside while it does. LVGL’s clock is the frame’s dt, so a deterministic run gives the same pixels. On the computer the same program shows LVGL’s picture as one zinc:gfx image (display.ts, display.esp32.ts by platform suffix). Why: zinc:ui did not fit the 96 KB JavaScript heap (“not enough memory”; 690 KB of bytecode for the widgets tour), and the owner found the zinc:gfx tour’s scrolling not smooth and its frame rate low. LVGL’s tour is 60 KB of bytecode, ran at 60 fps on the board (test program, first full screen in about 67 ms, idle turns 70 µs) and draws only what changed. Rejected: our own retained widget layer in C (a second LVGL to write and keep), zinc:ui trimmed for the board (the reconciler and Yoga stay too big). | Revisit when a board with PSRAM (ESP32-S3) can hold zinc:ui, or when LVGL’s API grows past what handles and polling express (custom draw callbacks). | | D157 | zinc:lvgl’s memory on a board without PSRAM, and on the computer that stands for it (2026-10-22, the owner: “est-ce que le simulateur mac peut avoir des contraintes qui simulent le hardware en terme de place et ram ?”) | On the board LVGL takes the heap (LV_STDLIB_CLIB), shared with the firmware; on the computer (the window and the mquickjs check) it draws from LVGL’s own pool of 96 KB, the board’s 138 KB in 64-bit objects, so a screen that does not fit the board fails on the computer too, with “LVGL’s memory pool is full”. A program builds a page when it is shown and deletes it when it is left (clean, which also drops the handlers and queued events of the objects deleted); the firmware’s main task gets a 28 KB stack when zinc:lvgl is in it. Why: a fixed pool on the board crashed at boot (no 110 KB block left once the JavaScript heap was taken), the six pages of esp32-widgets built at once left 0 KB free in esp32sim, one page at a time leaves 37 to 102 KB (Lottie the heaviest), and ThorVG goes 21 KB deep where the default stack is 8 KB (heap corruption, then an assert). The 96 KB pool gives the same share per page as the board’s heap (Lottie: 75% here, 71% there). Rejected: the pool on the board (fragments, and a second limit beside the heap), every page kept built (does not fit), LVGL’s software rotation for a turned display (a second band buffer and a copy per area: the panel’s MADCTL turns it for free). | Revisit when the ratio between the 64-bit pool and the board’s heap drifts (a page of many small objects is cheaper on the computer: Bench 0.86, Home 0.60), or when ThorVG’s new (outside the pool) is routed through lv_malloc. | | D158 | The ESP32 emulator with touch and a live window (2026-10-22, the owner’s question “comment on teste ?” once the board was away) | esp32sim is built from its pinned v0.3.0 sources (GitHub archive, SHA-256 9cadce5d…2533313) with tools/esp32sim/esp32sim-0.3.0-zinc.patch (the ESP32-2432S022 board on the SPI wiring, the CST820 touch at 0x15, the --web 0 port line), by the user’s cargo, once, into ~/.zinc/toolchains/esp32sim-<host>-0.3.0-zinc, as D155 does for Loro; the patch’s SHA-256 is the rebuild key and ZINC_ESP32SIM overrides it. --live shows the emulator in the engine’s own window, fed by esp32sim’s --web WebSocket (RGB565 frames converted in C++), the mouse going back as touches. Why: the release binaries lack our board and its touch controller; the engine’s window inherits ZINC_BACKGROUND, HiDPI and closing, and needs no protocol patch. Rejected: a zinc:gfx viewer program (a compile per run, zinc:socket and its permission, a TypeScript loop over 76,800 pixels a frame), a custom pipe protocol (a bigger patch), a browser page (a window outside Zinc). | Revisit when upstream esp32sim ships the board (drop the patch, pin that release) or models the 8-bit i80 panel. |

Edit this page on GitHub ↗