diff --git a/build.zig b/build.zig index e88a9e37..312f21d8 100644 --- a/build.zig +++ b/build.zig @@ -549,6 +549,12 @@ pub fn build(b: *std.Build) void { .{ .path = "docs/src/app/media-producers/page.mdx", .pattern = "producer: media.MediaSurfaceProducer" }, .{ .path = "src/runtime/media_surface_tests.zig", .pattern = "producer: media.MediaSurfaceProducer" }, }); + addFileContainsCheckStep(b, file_contains_checker, test_step, "test-session-replay-image-codec", "Verify the replay runner installs the host image codec headlessly (the desktop arms cannot link in unit tests; the null-fallback arm is covered in session_tests.zig)", &.{ + .{ .path = "src/app_runner/root.zig", .pattern = "native_sdk.platform.installHeadlessImageCodec(build_options.platform, &null_platform, &replay_platform.services);" }, + .{ .path = "src/platform/macos/root.zig", .pattern = "pub fn installHeadlessImageCodec(services: *platform_mod.PlatformServices) void {\n services.decode_image_fn = decodeImage;\n}" }, + .{ .path = "src/platform/linux/root.zig", .pattern = "pub fn installHeadlessImageCodec(services: *platform_mod.PlatformServices) void {\n services.decode_image_fn = decodeImage;\n}" }, + .{ .path = "src/platform/windows/root.zig", .pattern = "pub fn installHeadlessImageCodec(services: *platform_mod.PlatformServices) void {\n services.decode_image_fn = decodeImage;\n}" }, + }); addFileContainsCheckStep(b, file_contains_checker, test_step, "test-js-view-helper-contracts", "Verify injected view helpers support label-first updates", &.{ .{ .path = "src/platform/macos/appkit_host.m", .pattern = "update:function(options,patch)" }, .{ .path = "src/platform/macos/cef_host.mm", .pattern = "update:function(options,patch)" }, diff --git a/changelog.d/dynamic-image-load.md b/changelog.d/dynamic-image-load.md new file mode 100644 index 00000000..a8f03dbd --- /dev/null +++ b/changelog.d/dynamic-image-load.md @@ -0,0 +1,6 @@ +feature: **`Cmd.imageLoad` — dynamic images, the first full media pipeline**: apps load images at runtime from disk or the network by a model-owned ImageId, the effect executor resolves the audio cascade's source order (local path first, then a verified content-addressed cache entry under `/images/`, then the network with an atomic cache install behind it), decodes through the platform codec into the existing registered-image storage, and exactly ONE result Msg comes back — `loaded` with the decoded width/height, or one honest failure class from the same vocabulary the direct registration API raises (`decode_failed`, `too_large`, `registry_full`, `unsupported`, `alloc_failed` — the host refused the memory the registration needed, resource exhaustion rather than corrupt bytes — the fetch taxonomy, `http_status` with the status carried through). +- **TS tier first-class**: `Cmd.imageLoad(id, { path?, url?, cachePath?, expectedBytes? }, { event })` with a five-field result arm matched by name (`id`/`state`/`width`/`height`/`status` — `id` echoes the requested ImageId so concurrent loads sharing one arm stay distinguishable; the fifteen-member `ImageState` union checked at build time), id expressions welcome (ids are model data), `Cmd.imageCancel(id)` ending a live load loudly (the event arm's "cancelled", freeing the id for a same-id retry; an id with no live load no-ops), `Cmd.imageUnregister(id)` releasing a loaded image's registry slot (the gallery eviction move past the 16-slot registry — synchronous registry surgery like registration itself, no result Msg, misses no-op; a load in flight still registers at its terminal, so cancel first to keep the slot free), opcodes 0x12/0x13/0x14 additive within cmd_format_version 3, and `TsUiApp`'s `image_cache_dir` deriving the content-addressed cache path from the URL so update never builds filesystem paths. +- **Markup `` — the runtime-image leaf (element code 67)**: `image="{binding}"` binds the model-owned u64 ImageId in avatar's grammar (binding-only, required on the leaf, negative model values fail the build with a teaching, never a trap), wired through the validator, both engines, `native check`'s model contract, LSP hover docs, and the docs vocabulary; the `image` attribute's scope broadened from avatar-only to avatar+image. +- **Recorded sessions replay byte-identical, offline**: an image load's ENCODED source bytes are the effect result, journaled at effect-result time into a content-addressed blob store beside the journal (`blobs/` in the session directory — identical bytes twice store one blob), with the journal record carrying hash + length and the dedup probe verifying an existing blob's bytes before trusting its name (a damaged blob repairs in place from the bytes in hand — recording self-heals the store instead of sealing a journal replay must refuse); replay reads the blob, verifies it against its address, re-runs decode + registration, and delivers the recorded result with no file, network, or cache touched, refusing loudly when the blob store is missing or damaged. +- **Journal format, stated plainly**: the image records bump the session-journal format to v7 (the `.image` effect-record kind plus the blob-address fields appended to every effect record); v6 and older journals are refused at the preamble with the standard re-record teaching — a v6 reader would have misparsed the longer records as corruption. +- **Zig tier**: `fx.loadImage(.{ .id, .path, .url, .cache_path, .expected_bytes, .on_result })` with `Effects.imageMsg(...)` routing, a fake-executor seam (`pendingImageLoad*`, `feedImageBytes` running the REAL decode+register path, `feedImageResult`), and `imageCachePath` deriving the cache convention; the encoded source is bounded at 1.25 MiB from every source alike and over-bound sources fail whole with `too_large` — a cut image never decodes, so there is no truncated delivery. diff --git a/docs/public/wasm/component-preview.wasm b/docs/public/wasm/component-preview.wasm index 4c3cb812..f73cc921 100755 Binary files a/docs/public/wasm/component-preview.wasm and b/docs/public/wasm/component-preview.wasm differ diff --git a/docs/src/app/dynamic-images/layout.tsx b/docs/src/app/dynamic-images/layout.tsx new file mode 100644 index 00000000..e03f2fc1 --- /dev/null +++ b/docs/src/app/dynamic-images/layout.tsx @@ -0,0 +1,7 @@ +import { pageMetadata } from "@/lib/page-metadata"; + +export const metadata = pageMetadata("dynamic-images"); + +export default function DynamicImagesLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/docs/src/app/dynamic-images/page.mdx b/docs/src/app/dynamic-images/page.mdx new file mode 100644 index 00000000..b8275a63 --- /dev/null +++ b/docs/src/app/dynamic-images/page.mdx @@ -0,0 +1,122 @@ +import { CodeToggle } from "@/components/code-toggle"; + +# Dynamic Images + +Load images at **runtime** — from disk or the network — decode them through the platform codec, and show them in views by id. `Cmd.imageLoad` is effects-as-data all the way down: your core names a model-owned `ImageId` and a source, the host does the I/O and the decode, and exactly one result Msg comes back — `loaded` with the decoded dimensions, or one honest failure class. Views bind the id; nothing in `update` ever touches a file, a socket, or a codec. + +Static images that ship with the app stay in [`app.zon`'s `.assets.images`](/app-zon) — read once at launch, registered before the first frame. This page is the **dynamic** half: cover art fetched from a CDN, a chart PNG a subprocess rendered, an avatar that arrives after login. + +## Load, then show + +The id is ordinary model data — pick it like an effect key. Issue the load, and write the id into the model **only when the result says `loaded`** (the store-on-success discipline): the view renders its fallback while the load is in flight and keeps it when the load failed, because an id of `0` — the no-image sentinel — draws nothing. + + + +```ts +import { Cmd, asciiBytes } from "@native-sdk/core"; + +// The engine's outcome vocabulary — a named alias your core declares +// (the host matches the members by name, so the order is yours). +export type ImageState = + | "loaded" | "rejected" | "not_found" | "io_failed" | "connect_failed" + | "tls_failed" | "protocol_failed" | "timed_out" | "http_status" + | "cancelled" | "too_large" | "unsupported" | "decode_failed" | "registry_full" + | "alloc_failed"; + +export interface Model { + readonly cover: number; // 0 until the load lands + readonly coverState: ImageState; +} + +export type Msg = + | { readonly kind: "show" } + | { readonly kind: "cover_done"; readonly id: number; readonly state: ImageState; readonly width: number; readonly height: number; readonly status: number }; + +export function initialModel(): Model { + return { cover: 0, coverState: "rejected" }; +} + +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "show": + return [model, Cmd.imageLoad(21, { + path: asciiBytes("art/cover.png"), + url: asciiBytes("https://cdn.example.com/art/cover.png"), + }, { event: "cover_done" })]; + case "cover_done": + if (msg.state === "loaded") return { ...model, cover: msg.id, coverState: msg.state }; + return { ...model, coverState: msg.state }; + } +} +``` + +```zig +const Model = struct { + cover: canvas.ImageId = 0, // 0 until the load lands +}; + +// in update: +.show => fx.loadImage(.{ + .id = 21, + .path = "art/cover.png", + .url = "https://cdn.example.com/art/cover.png", + .on_result = Effects.imageMsg(.cover_done), +}), +.cover_done => |result| if (result.outcome == .loaded) { + model.cover = result.id; +}, +``` + + + +The view binds the same model id. Markup's `image` element is the display-only leaf for exactly this — the binding is required (an unbound `` is dead markup), the id is always `{binding}` model data (never a literal), and avatars carry the same attribute with an initials fallback: + +```html + +OC +``` + +The moment the result lands and `update` stores the id, every view referencing it repaints with the pixels — GPU caches re-upload off the changed content fingerprint, no invalidation calls. Zig views use `ui.image(.{ .image = model.cover, ... })`; both markup engines refuse a negative id binding at build time with a teaching message, never a trap. + +## The source cascade + +The source resolves the way `Cmd.audioPlay` resolves — local first, network last, with a verified cache between: + +1. **The local `path` is tried first.** A missing file falls through to `url` (when one is given); every other local failure is terminal — retrying a different source would mask the real problem. +2. **A verified cache entry loads without the network.** A URL source checks its cache path before fetching; `expectedBytes` (when you know the size, e.g. from a manifest) is the integrity gate — an entry whose size disagrees is discarded and re-fetched. +3. **The network fetch installs the cache behind it.** The bytes are fetched whole (no truncated delivery — a cut image can never decode), written beside the cache path, and atomically renamed into place only after the size verifies, so a partial download never occupies the cache name. + +Prefer **omitting** `cachePath` for URL sources: when the app wiring configures a caches directory (`TsUiApp`'s `image_cache_dir`; the app runner points it at the platform caches directory automatically), the host derives the conventional content-addressed path — `/images/.` — from the URL itself. Your update never builds filesystem paths, replay re-derives the same path by construction, and clearing the cache is deleting one directory the OS already treats as reclaimable. + +## One result, honest classes + +Exactly one event arm dispatches per load — a five-field record matched by field name: `id`, `state`, `width`, `height`, `status`. `id` echoes the requested ImageId, so two loads in flight at once share one arm and still tell their results apart (that is what the example's `cover: msg.id` reads); `status` is the HTTP status for url loads that performed an exchange, and **0 when none occurred** — local paths and cache hits — so a `loaded` with status 0 is honest signal that the pixels came without a network round trip, never a fabricated 200; `loaded` means the pixels are registered and drawable; everything else names what actually happened: + +- **Source classes** — `not_found` (missing local file, no url), `io_failed` (a local read failure), `connect_failed` / `tls_failed` / `protocol_failed` / `timed_out` (the fetch taxonomy, on the fetch machinery's own timeout), `http_status` (a non-2xx answer, with the status carried through — an error page is not an image, so the body is discarded). +- **Decode and registry classes** — the same errors the direct registration API raises: `decode_failed`, `unsupported` (a host without a codec), `too_large`, `registry_full`, and `alloc_failed` (the host refused the memory the registration needed — resource exhaustion, not corrupt bytes: the same source may load once memory frees, so it is never reported as `decode_failed`). +- **Discipline classes** — `rejected` (an invalid id, no source at all, or a duplicate live id: one load per id at a time, the spawn rule — a load in flight is never replaced implicitly) and `cancelled` (`Cmd.imageCancel(id)` ended the load). + +`Cmd.imageCancel(id)` is the load's cancel — image loads are keyed by their numeric id, so the string-keyed `Cmd.cancel` never touches them. Cancel is **loud**, the spawn discipline: the one terminal still arrives, as the load's own event arm with state `cancelled`, and the id is free for a fresh load once it lands (a slow CDN fetch no longer pins its id against a retry). Aimed at an id with no live load it no-ops — whatever it targeted already delivered its terminal. + +## Releasing images: the gallery eviction + +A loaded image occupies one of the registry's **16 slots** until you release it, and `Cmd.imageUnregister(id)` is the release: the pixels are freed, views still referencing the id draw their fallback (avatar initials, nothing for ``) on the next frame, and the slot is open for another load. This is what keeps a gallery bigger than 16 images honest — a screen paging through covers loads the visible ids, and when the 17th image would answer `registry_full`, unregisters an off-screen evictee first (mint fresh ids for new content, effect-key style; never re-key different content onto a live id — the same rule the Zig tier's `fx.unregisterImage` documents). + +Unlike a load, unregister is **synchronous registry surgery, not an effect**: no result Msg follows (registration by `imageLoad` has a terminal because I/O and decode can fail; releasing a slot cannot), and aimed at an id with no registration it no-ops — `imageCancel`'s idle rule. It frees only the **current** registration: a load in flight under the id is untouched, and its terminal still registers the pixels, re-occupying the id. To evict an id whose load is still running, `Cmd.imageCancel(id)` first, then unregister. + +## Limits, honestly + +Decode limits are the registered-image limits, fixed and loud: **16 slots** of **1 MiB decoded pixels** each (512×512 RGBA8 — avatar and cover-art scale, not photo scale), with `Cmd.imageUnregister` releasing a slot when the app is done with an image. The encoded source is bounded at **1.25 MiB** from every source alike, and over-bound sources fail whole with `too_large` — never a silently cropped decode. Loaded pixels live in the existing registered-image storage; there is no separate pool to size. The framework bundles no codecs: bytes decode through CGImageSource on macOS, gdk-pixbuf on GTK, WIC on Windows, and the mobile hosts' embed image service — a host without one answers `unsupported`, never silence. + +For textures **produced** by your own renderer at video rates — a decoder, a camera, mpv — this is the wrong tool: that is the [media surface](/media-producers)'s dynamic texture channel. `imageLoad` is for images that exist as encoded bytes somewhere and should become long-lived registered pixels. + +## Recorded sessions replay byte-identical, offline + +The loaded bytes **are** the effect result, and the session journal treats them that way. When a recorded session performs an image load, the encoded source bytes are written — at effect-result time — into a content-addressed blob store beside the journal (`blobs/` in the session directory), and the journal record carries the hash and length. Loading the same bytes twice stores one blob: content addressing is deduplication — and the deduplicating probe verifies the existing blob's bytes before trusting its name, so a damaged blob is repaired in place on the next same-bytes recording rather than sealing a journal replay would refuse. + +Replay reads the blob, re-runs the same decode and registration with the recorded bytes, and delivers the recorded result — **byte-identical and fully offline**: the original file, the network, and the cache are never consulted, and the fingerprint checkpoints verify the replayed session against the recording frame by frame. A journal whose `blobs/` directory is missing or damaged refuses loudly (the bytes are verified against their address) rather than replaying a different session. The blob record kind is journal format **v7** — older journals are refused at the preamble with the standard re-record teaching, the format's usual honest break. + +```sh +NATIVE_SDK_SESSION_RECORD=session/app.journal native run # records blobs/ beside the journal +NATIVE_SDK_SESSION_REPLAY=session/app.journal native run # replays offline, verifying fingerprints +``` diff --git a/docs/src/app/native-ui/page.mdx b/docs/src/app/native-ui/page.mdx index 711743c6..394186e9 100644 --- a/docs/src/app/native-ui/page.mdx +++ b/docs/src/app/native-ui/page.mdx @@ -497,7 +497,7 @@ macOS-first, like `resizable = false`: GTK and Win32 hosts keep standard chrome Image pixels are runtime-registered resources: the app registers decoded straight-alpha RGBA8 under a caller-chosen `ImageId` (a `u64` kept in the model, effect-key style; 0 is the "no image" sentinel), and `image`, `icon_button`, and `avatar` widgets reference the id. Native SDK bundles no image codecs — encoded bytes (PNG, JPEG, whatever the OS decodes) go through the platform decoder: CGImageSource on macOS and the iOS toolkit host, gdk-pixbuf on GTK, WIC on Windows, BitmapFactory on the Android toolkit host (both mobile hosts register the codec over the embed image service; an embed host without one reports `error.UnsupportedService`). -The fetch-avatar path is one update arm: fetch bytes, decode + register in one call, and write the id into the model only on success — so the avatar renders its initials while the image is loading and keeps them when the fetch or decode failed: +The first-class load path is `fx.loadImage` (`Cmd.imageLoad` on the TS tier): one effect resolving a local-then-URL source cascade with a content-addressed cache, decoding, registering, and delivering exactly one result Msg — see [Dynamic Images](/dynamic-images) for the whole story, including the byte-identical offline replay guarantee. The registration seam underneath is also callable directly — the hand-rolled fetch-avatar path is one update arm: fetch bytes, decode + register in one call, and write the id into the model only on success — so the avatar renders its initials while the image is loading and keeps them when the fetch or decode failed: ```zig .load => fx.fetch(.{ .key = avatar_key, .url = avatar_url, .on_response = Effects.responseMsg(.fetched) }), @@ -516,7 +516,9 @@ ui.image(.{ .image = model.chart_image, .width = 120, .height = 80, .semantics = ``` ```html - + + OC ``` diff --git a/docs/src/app/typescript/page.mdx b/docs/src/app/typescript/page.mdx index aa35c8b9..8d628e00 100644 --- a/docs/src/app/typescript/page.mdx +++ b/docs/src/app/typescript/page.mdx @@ -298,6 +298,10 @@ The runtime interprets the command after the model commits and dispatches any re Cmd.showWindow(label) / Cmd.quitApp() The menu-bar lifecycle verbs: un-hide + activate the labeled window (the tray "Open" consequence, the counterpart to close_policy = "hide"), and the real graceful terminate + + Cmd.imageLoad(id, source, { event }) + imageCancel(id)/imageUnregister(id) + Load an image at runtime under the model-owned numeric ImageId your markup binds; one event result — loaded with the decoded width/height, or a failure class. imageCancel ends a live load loudly (state cancelled) and frees the id; imageUnregister releases a loaded image's registry slot (no result — synchronous, like registration) — see Dynamic Images + Cmd.host(name, ...args) / Cmd.request(name, payload, { key?, ok, err }) App-defined host commands by literal name: fire-and-forget, or routed with exactly one result Msg back diff --git a/docs/src/lib/component-vocab.json b/docs/src/lib/component-vocab.json index 0fa2daa4..4dbda082 100644 --- a/docs/src/lib/component-vocab.json +++ b/docs/src/lib/component-vocab.json @@ -263,6 +263,10 @@ { "name": "media-surface", "doc": "The media surface leaf: composites a texture produced OUTSIDE the widget tree (a video decoder, a camera pipeline, an external renderer) into the layout like any widget — clipped, z-ordered, transformed. surface is one {binding} to the model-owned u64 surface id a Zig-tier producer targets (runtime.acquireMediaSurfaceProducer pushes RGBA8 frames, latest-wins, paced by the presented-frame clock). Until the first frame arrives it shows a deterministic id-derived placeholder — which is also all that goldens, screenshots, and session replay ever show: texture contents are presentation chrome. Display-only (presses fall through); size it like an image (width/height or grow); label it for screen readers." + }, + { + "name": "image", + "doc": "The image leaf: draws a RUNTIME-REGISTERED image by its model-owned u64 ImageId — the id Cmd.imageLoad (TS) or fx.loadImage/fx.registerImageBytes (Zig) registered pixels under. image is one required {binding}; ids are model data, never markup literals, and 0 draws nothing (store the id in the model only when the load reports loaded). Display-only (presses fall through); size it with width/height or grow (no intrinsic size); label it for screen readers." } ], "structure": [ @@ -631,7 +635,7 @@ "avatar": [ { "name": "image", - "doc": "avatar: one {binding} to a u64 ImageId the app registered at runtime (fx.registerImageBytes); 0 renders the initials fallback." + "doc": "avatar and image: one {binding} to a u64 ImageId the app registered at runtime (Cmd.imageLoad, fx.loadImage, fx.registerImageBytes); 0 draws nothing (an avatar falls back to its initials). Required on the image leaf." } ], "media-surface": [ diff --git a/docs/src/lib/docs-navigation.ts b/docs/src/lib/docs-navigation.ts index 57de0e55..fba05f25 100644 --- a/docs/src/lib/docs-navigation.ts +++ b/docs/src/lib/docs-navigation.ts @@ -28,6 +28,7 @@ export const navSections: NavSection[] = [ { name: "TypeScript Cores", href: "/typescript" }, { name: "Where Packages Go", href: "/typescript/packages" }, { name: "Native UI", href: "/native-ui" }, + { name: "Dynamic Images", href: "/dynamic-images" }, { name: "State & Data Flow", href: "/state" }, { name: "Theming", href: "/theming" }, { name: "Fonts", href: "/fonts" }, diff --git a/docs/src/lib/page-titles.ts b/docs/src/lib/page-titles.ts index a5dad202..9fb3c136 100644 --- a/docs/src/lib/page-titles.ts +++ b/docs/src/lib/page-titles.ts @@ -11,6 +11,7 @@ export const PAGE_TITLES: Record = { typescript: "TypeScript Cores", "typescript/packages": "Where Packages Go", "native-ui": "Native UI", + "dynamic-images": "Dynamic Images", state: "State & Data Flow", theming: "Theming", fonts: "Fonts", diff --git a/evals/harness-lib/cmdview.zig b/evals/harness-lib/cmdview.zig index 56e9fffd..9d484815 100644 --- a/evals/harness-lib/cmdview.zig +++ b/evals/harness-lib/cmdview.zig @@ -28,6 +28,9 @@ pub const Op = union(enum) { audio_ctl: struct { key: []const u8, verb: u8, value: f64 }, window_show: struct { label: []const u8 }, quit_app, + image_load: struct { id: f64, event_tag: u8, path: []const u8, url: []const u8, cache_path: []const u8, expected_bytes: f64 }, + image_cancel: struct { id: f64 }, + image_unregister: struct { id: f64 }, pub const Host = struct { name: []const u8, @@ -221,6 +224,34 @@ pub const CmdIter = struct { // quit_app [op] — a bare op byte, no payload (ts_core_host.zig, // 0x11). 0x11 => .quit_app, + // image_load [op][id f64 LE][event_tag u8][path_len u32 LE][path] + // [url_len u32 LE][url][cache_len u32 LE][cache][expected f64 LE] + // — audio_play's source-cascade record shape keyed by the numeric + // ImageId instead of a string key (ts_core_host.zig, 0x12). + 0x12 => blk: { + const id: f64 = @bitCast(std.mem.readInt(u64, b[off..][0..8], .little)); + off += 8; + const event_tag = b[off]; + off += 1; + const path = longBytes(b, &off); + const url = longBytes(b, &off); + const cache = longBytes(b, &off); + const expected: f64 = @bitCast(std.mem.readInt(u64, b[off..][0..8], .little)); + off += 8; + break :blk .{ .image_load = .{ .id = id, .event_tag = event_tag, .path = path, .url = url, .cache_path = cache, .expected_bytes = expected } }; + }, + // image_cancel [op][id f64 LE] (ts_core_host.zig, 0x13). + 0x13 => blk: { + const id: f64 = @bitCast(std.mem.readInt(u64, b[off..][0..8], .little)); + off += 8; + break :blk .{ .image_cancel = .{ .id = id } }; + }, + // image_unregister [op][id f64 LE] (ts_core_host.zig, 0x14). + 0x14 => blk: { + const id: f64 = @bitCast(std.mem.readInt(u64, b[off..][0..8], .little)); + off += 8; + break :blk .{ .image_unregister = .{ .id = id } }; + }, else => std.debug.panic("cmdview: unknown op byte 0x{X:0>2} at offset {d}", .{ op, self.off }), }; self.off = off; @@ -340,3 +371,41 @@ test "window_show and quit_app decode, alone and inside a batch" { try std.testing.expectEqual(@as(u8, 7), third.now.msg_tag); try std.testing.expectEqual(@as(?Op, null), iter.next()); } + +test "the image records decode, alone and inside a batch" { + // image_load: [op 0x12][id f64 LE][event_tag][path][url][cache] + // [expected f64 LE] — the bytes rt.zig's cmdImageLoad pins (the same + // layout packages/core/test/effects.test.ts asserts). + var load_bytes: std.ArrayList(u8) = .empty; + defer load_bytes.deinit(std.testing.allocator); + const a = std.testing.allocator; + try load_bytes.append(a, 0x12); + try load_bytes.appendSlice(a, &@as([8]u8, @bitCast(@as(f64, 7)))); + try load_bytes.append(a, 3); // event_tag + try load_bytes.appendSlice(a, &.{ 13, 0, 0, 0 }); + try load_bytes.appendSlice(a, "art/cover.png"); + try load_bytes.appendSlice(a, &.{ 0, 0, 0, 0 }); // url: empty + try load_bytes.appendSlice(a, &.{ 0, 0, 0, 0 }); // cache: empty + try load_bytes.appendSlice(a, &@as([8]u8, @bitCast(@as(f64, 2048)))); + const load = findOp(load_bytes.items, .image_load) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(f64, 7), load.id); + try std.testing.expectEqual(@as(u8, 3), load.event_tag); + try std.testing.expectEqualStrings("art/cover.png", load.path); + try std.testing.expectEqualStrings("", load.url); + try std.testing.expectEqual(@as(f64, 2048), load.expected_bytes); + + // image_cancel [op 0x13][id f64 LE] and image_unregister + // [op 0x14][id f64 LE] concatenated: both one-field records must + // advance exactly nine bytes each for the second to decode. + var batch: [18]u8 = undefined; + batch[0] = 0x13; + batch[1..9].* = @bitCast(@as(f64, 7)); + batch[9] = 0x14; + batch[10..18].* = @bitCast(@as(f64, 15)); + var iter = CmdIter.init(&batch); + const cancelled = iter.next() orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(f64, 7), cancelled.image_cancel.id); + const evicted = iter.next() orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(f64, 15), evicted.image_unregister.id); + try std.testing.expectEqual(@as(?Op, null), iter.next()); +} diff --git a/packages/core/rt/rt.zig b/packages/core/rt/rt.zig index cf06e863..1cfff652 100644 --- a/packages/core/rt/rt.zig +++ b/packages/core/rt/rt.zig @@ -1054,13 +1054,21 @@ pub fn Kernel(comptime opts: Options) type { // audio_ctl [op 0x0F][key_len u8][key bytes][verb u8][value f64 LE] // window_show [op 0x10][label_len u8][label bytes] // quit_app [op 0x11] + // image_load [op 0x12][id f64 LE][event_tag u8] + // [path_len u32 LE][path][url_len u32 LE][url] + // [cache_len u32 LE][cache_path][expected_bytes f64 LE] + // image_cancel [op 0x13][id f64 LE] + // image_unregister [op 0x14][id f64 LE] // // v2 is additive over v1: the 0x01-0x03 records are byte-identical to v1, // so a v1 effect log replays under a v2 reader unchanged. The named-op // records 0x07-0x0C are additive within v2 the same way, and so are the // streaming records 0x0D-0x0F: no existing record's bytes change, new // opcodes only. v3 appends the window-verb records 0x10-0x11 under the - // same rule: every v2 record is byte-identical, new opcodes only. + // same rule, and the image records 0x12-0x14 are additive within v3 + // the same way: every existing record is byte-identical, new opcodes + // only (a host predating an opcode refuses it loudly, naming this + // version). // // `method` is the closed HTTP verb set in declaration order of `CmdFetchMethod` // (GET 0, POST 1, PUT 2, DELETE 3, PATCH 4, HEAD 5). `timeout_ms` 0 means @@ -1171,6 +1179,54 @@ pub fn Kernel(comptime opts: Options) type { // magnitudes, all zeros outside `.spectrum` events). // Failure is never silent: an unplayable source is one // `.failed` event, a refused command one `.rejected`. + // image_load a routed one-shot image load, keyed by the app's own + // numeric ImageId (`id`, an f64-carried positive integer + // — the SAME model-owned id markup binds via + // / ): + // resolve the source cascade (local `path` first, then a + // verified cache entry at `cache_path`, then `url` — + // audio_play's resolution order; `expected_bytes` is the + // cache integrity gate, 0 = unknown), decode through the + // platform codec, register the pixels under the id, and + // dispatch exactly ONE `event_tag` arm — a five-field + // record built by name: `id` (a number, the requested + // ImageId echoed verbatim so concurrent loads sharing + // one arm stay distinguishable; an id the wire cannot + // carry exactly echoes 0), `state` (an enum whose + // members are exactly the fifteen image outcome names, + // matched by member NAME), width and height (numbers, + // the decoded dimensions on "loaded"), and status + // (number, the HTTP status for url loads that + // performed an exchange; 0 when none occurred — + // local paths, cache hits — so a cached "loaded" + // is distinguishable from a network one). One load + // per id at a time: a duplicate live id dispatches + // "rejected" (the spawn discipline — a load in flight + // is never replaced implicitly). + // image_cancel end the in-flight load under `id`, if any, LOUDLY: + // the load's one terminal arrives as its own event arm + // with state "cancelled" (ending an in-flight load is + // an observable event, spawn's cancel discipline), and + // the id is free for a fresh load once that terminal + // lands. Aimed at an id with no live load — or one the + // wire cannot carry exactly — it is a no-op, the same + // idle no-op audio_ctl keeps: whatever it aimed at is + // already gone. Image loads are keyed by their numeric + // id, so the string-keyed `cancel` record never + // touches them. + // image_unregister + // free the registry slot under `id`: the pixels are + // released, views referencing the id draw their + // fallback on the next frame, and the slot is open + // for another load. Registration's own discipline in + // reverse — synchronous registry surgery, NOT an + // effect: no Msg follows, and an id with no + // registration (or one the wire cannot carry + // exactly) is a no-op, image_cancel's idle rule. It + // frees only the CURRENT registration: a load in + // flight under the id is untouched and its terminal + // still registers — cancel the load first to keep + // the slot free. // audio_ctl drive the open stream's playback in place, by verb // (`CmdAudioVerb` declaration order): pause 0, resume // 1, stop 2, seek 3 (`value` = position ms), volume 4 @@ -1248,6 +1304,9 @@ pub fn Kernel(comptime opts: Options) type { audio_ctl = 0x0F, window_show = 0x10, quit_app = 0x11, + image_load = 0x12, + image_cancel = 0x13, + image_unregister = 0x14, }; /// The spawn record's "no line routing" sentinel: a `line_tag` of @@ -1550,6 +1609,43 @@ pub fn Kernel(comptime opts: Options) type { return out; } + pub fn cmdImageLoad( + id: f64, + event_tag: u8, + path: []const u8, + url: []const u8, + cache_path: []const u8, + expected_bytes: f64, + ) Cmd { + std.debug.assert(path.len <= std.math.maxInt(u32)); + std.debug.assert(url.len <= std.math.maxInt(u32)); + std.debug.assert(cache_path.len <= std.math.maxInt(u32)); + const out = frameAlloc(u8, 1 + 8 + 1 + 4 + path.len + 4 + url.len + 4 + cache_path.len + 8); + out[0] = @intFromEnum(CmdOp.image_load); + std.mem.writeInt(u64, out[1..][0..8], @bitCast(id), .little); + out[9] = event_tag; + var off: usize = 10; + off = writeLongBytes(out, off, path); + off = writeLongBytes(out, off, url); + off = writeLongBytes(out, off, cache_path); + std.mem.writeInt(u64, out[off..][0..8], @bitCast(expected_bytes), .little); + return out; + } + + pub fn cmdImageCancel(id: f64) Cmd { + const out = frameAlloc(u8, 1 + 8); + out[0] = @intFromEnum(CmdOp.image_cancel); + std.mem.writeInt(u64, out[1..][0..8], @bitCast(id), .little); + return out; + } + + pub fn cmdImageUnregister(id: f64) Cmd { + const out = frameAlloc(u8, 1 + 8); + out[0] = @intFromEnum(CmdOp.image_unregister); + std.mem.writeInt(u64, out[1..][0..8], @bitCast(id), .little); + return out; + } + pub fn cmdBatch(cmds: []const Cmd) Cmd { var total: usize = 0; for (cmds) |c| total += c.len; diff --git a/packages/core/sdk/core.ts b/packages/core/sdk/core.ts index 149e0e12..7c3fd7a0 100644 --- a/packages/core/sdk/core.ts +++ b/packages/core/sdk/core.ts @@ -103,6 +103,35 @@ // forget control verbs whose consequences arrive // on the event stream; aimed at a key with no // open stream they no-op. +// Cmd.imageLoad(id, { path?, url?, cachePath?, expectedBytes? }, { event }) +// load an image at runtime by its model-owned +// numeric ImageId (the id markup binds: +// , avatar likewise): the +// host resolves the source cascade (local path +// first, then a verified cache entry, then the +// network), decodes through the platform codec, +// registers the pixels under the id, and +// dispatches exactly ONE `event` arm (the +// five-field record below, the requested id +// echoed) — state "loaded" with the decoded +// width/height, or one failure class. One load +// per id at a time: a duplicate live id +// dispatches state "rejected". +// Cmd.imageCancel(id) end the in-flight load under the id, if any +// — loud, the spawn discipline: the load's +// event arm delivers state "cancelled" and the +// id frees for a fresh load once it lands. An +// id with no live load no-ops. +// Cmd.imageUnregister(id) free the registry slot under the id: the +// pixels are released, views referencing the +// id draw their fallback, and the slot is +// open for another load. Synchronous registry +// surgery like registration itself — not an +// effect, no Msg follows; an id with no +// registration no-ops. A load IN FLIGHT under +// the id is untouched: its terminal still +// registers — cancel the load first to keep +// the slot free. // // The window verbs (fire-and-forget, no result Msg — the window's own // frame event carries the state): @@ -263,12 +292,87 @@ export type AudioEventArm = { }; /// The Msg arms an audio event stream may target: arms whose payload is -/// exactly the six AudioEventArm fields. +/// exactly the six AudioEventArm fields. The `state` check runs BOTH +/// directions: the `&` constraint holds the arm's states to AudioState, +/// and the tuple-wrapped reverse check holds AudioState to the arm's +/// states — a narrower union would silently drop event states the host +/// emits, so it is refused here, not discovered at runtime. export type AudioEventKind = M extends Msgish ? [Exclude] extends [keyof AudioEventArm] ? [keyof AudioEventArm] extends [Exclude] ? M extends Msgish & AudioEventArm - ? M["kind"] + ? [AudioState] extends [M["state"]] + ? M["kind"] + : never + : never + : never + : never + : never; + +/// The image load result states, mirroring the engine's outcome vocabulary: +/// "loaded" means the pixels are registered under the requested id (width and +/// height carry what the codec decoded); every other state is the failure +/// class — "rejected" (a refused command: invalid id, no source, a duplicate +/// live id), "not_found" (missing local file, no url), "io_failed" (a local +/// read failure), "connect_failed"/"tls_failed"/"protocol_failed"/"timed_out" +/// (the fetch taxonomy), "http_status" (a non-2xx answer; `status` carries +/// it), "cancelled", "too_large" (source or decoded pixels over budget), +/// "unsupported" (no platform codec), "decode_failed", "registry_full", and +/// "alloc_failed" (the host refused the memory the registration needed — +/// resource exhaustion, not corrupt bytes: the same source may load once +/// memory frees). +export type ImageState = + | "loaded" + | "rejected" + | "not_found" + | "io_failed" + | "connect_failed" + | "tls_failed" + | "protocol_failed" + | "timed_out" + | "http_status" + | "cancelled" + | "too_large" + | "unsupported" + | "decode_failed" + | "registry_full" + | "alloc_failed"; + +/// The payload shape of an image result arm — five fields, matched by NAME +/// (the AudioEventArm convention). `id` is the requested ImageId echoed +/// verbatim, so two concurrent loads sharing one event arm stay +/// distinguishable in `update` (an id the wire cannot carry exactly — 0, +/// negatives, fractions, 2^53 and past — echoes 0, the no-image sentinel: +/// there is no honest integer to echo); `state` must be a named +/// string-literal-union alias carrying exactly the fifteen ImageState +/// members (any declaration order — the host matches members by name); +/// `width`/`height` are the decoded pixel dimensions ("loaded" only, 0 +/// otherwise); `status` is the HTTP status for url loads that performed an +/// exchange, 0 when none occurred (local paths, cache hits) — 0 is signal, +/// not a missing value: a cache hit is a real "loaded" with no exchange +/// behind it, so apps can distinguish a network load from a cached one. +export type ImageEventArm = { + readonly id: number; + readonly state: ImageState; + readonly width: number; + readonly height: number; + readonly status: number; +}; + +/// The Msg arms an image load result may target: arms whose payload is +/// exactly the five ImageEventArm fields. The `state` check runs BOTH +/// directions (the AudioEventKind convention): the `&` constraint holds +/// the arm's states to ImageState, and the tuple-wrapped reverse check +/// holds ImageState to the arm's states — a narrower union would +/// silently drop result states the host emits, so it is refused here, +/// not discovered at runtime. +export type ImageEventKind = M extends Msgish + ? [Exclude] extends [keyof ImageEventArm] + ? [keyof ImageEventArm] extends [Exclude] + ? M extends Msgish & ImageEventArm + ? [ImageState] extends [M["state"]] + ? M["kind"] + : never : never : never : never @@ -350,6 +454,26 @@ export interface AudioRoute { readonly event: AudioEventKind; } +/// A `Cmd.imageLoad` source: the audio cascade's shape exactly. The local +/// `path` is tried first; a missing file falls through to `url` (fetched +/// whole, installed at `cachePath` when given and verified against +/// `expectedBytes` — 0/omitted means unknown size, existence alone qualifies +/// a cache entry; omit `cachePath` and the host derives the conventional +/// content-addressed path when a caches directory is configured). At least +/// one of path/url must be present. +export interface ImageSource { + readonly path?: Uint8Array; + readonly url?: Uint8Array; + readonly cachePath?: Uint8Array; + readonly expectedBytes?: number; +} + +/// `Cmd.imageLoad` routing: the ONE terminal result dispatches the `event` +/// arm (the five-field ImageEventArm record, matched by field name). +export interface ImageRoute { + readonly event: ImageEventKind; +} + /// The closed HTTP verb set of `Cmd.fetch` (wire value = declaration order). export type FetchMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD"; @@ -448,6 +572,17 @@ export type Cmd = } | { readonly op: "window_show"; readonly label: string } | { readonly op: "quit_app" } + | { + readonly op: "image_load"; + readonly id: number; + readonly eventKind: string; + readonly path: Uint8Array; + readonly url: Uint8Array; + readonly cachePath: Uint8Array; + readonly expectedBytes: number; + } + | { readonly op: "image_cancel"; readonly id: number } + | { readonly op: "image_unregister"; readonly id: number } | { readonly op: "batch"; readonly cmds: readonly Cmd[] }; /// The wire encoding of a host record payload, byte-identical to what the @@ -694,6 +829,58 @@ export const Cmd = { return { op: "quit_app" }; }, + /// Load an image at runtime under the model-owned numeric ImageId your + /// markup binds (``, ``): + /// resolve the source cascade (local path first, then a verified cache + /// entry, then the network), decode through the platform codec, register + /// the pixels under `id`, and dispatch exactly ONE `event` arm — state + /// "loaded" with the decoded width/height, or one failure class, always + /// echoing the requested id so concurrent loads sharing the arm stay + /// distinguishable. Failure is never silent, and views referencing the + /// id repaint on the next + /// frame. One load per id at a time: a duplicate live id dispatches state + /// "rejected" (finish or re-key instead — ids are model data). Ids are + /// positive integers below 2^53 outside the reserved bit-63 namespace; + /// 0 is the no-image sentinel and dispatches "rejected". + imageLoad(id: number, source: ImageSource, route: ImageRoute): Cmd { + return { + op: "image_load", + id, + eventKind: route.event, + path: source.path ?? new Uint8Array(0), + url: source.url ?? new Uint8Array(0), + cachePath: source.cachePath ?? new Uint8Array(0), + expectedBytes: source.expectedBytes ?? 0, + }; + }, + + /// End the in-flight image load under `id`, if any — LOUDLY: the load's + /// one terminal arrives as its own `event` arm with state "cancelled" + /// (ending an in-flight load is an observable event, the spawn cancel + /// discipline), and the id is free for a fresh load once that terminal + /// lands. Aimed at an id with no live load it no-ops (the load it aimed + /// at already delivered its terminal). Image loads are keyed by their + /// numeric id, so the string-keyed `Cmd.cancel` never touches them — + /// this is their cancel, the way `Cmd.audioStop` is audio's. + imageCancel(id: number): Cmd { + return { op: "image_cancel", id }; + }, + + /// Free the registry slot under `id`: the pixels are released, views + /// referencing the id draw their fallback (avatar initials) on the next + /// frame, and the slot — one of the registry's 16 — is open for another + /// load (the gallery eviction move: unregister the evictee, load the + /// newcomer under a fresh id). Like registration itself this is + /// synchronous registry surgery, not an effect: no Msg follows, and an + /// id with no registration no-ops (whatever it aimed at is already + /// gone, `Cmd.imageCancel`'s idle rule). It frees only the CURRENT + /// registration — a load IN FLIGHT under the id is untouched and its + /// terminal still registers the pixels; to keep the slot free, end the + /// load with `Cmd.imageCancel(id)` first. + imageUnregister(id: number): Cmd { + return { op: "image_unregister", id }; + }, + /// Several commands from one dispatch, performed in order. batch(cmds: readonly Cmd[]): Cmd { return { op: "batch", cmds }; diff --git a/packages/core/src/emitter.ts b/packages/core/src/emitter.ts index 766067da..86bf15c7 100644 --- a/packages/core/src/emitter.ts +++ b/packages/core/src/emitter.ts @@ -140,6 +140,7 @@ const MAX_SPAWN_ARGV = 16; const MAX_SPAWN_ARGV_BYTES = 2048; const MAX_SPAWN_STDIN_BYTES = 4096; const MAX_AUDIO_PATH_BYTES = 1024; +const MAX_IMAGE_PATH_BYTES = 1024; /// The closed `Cmd.fetch` verb set, wire value = position. const FETCH_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"]; @@ -149,6 +150,15 @@ const FETCH_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"]; /// the app's own). const AUDIO_STATES = ["loaded", "position", "completed", "failed", "rejected", "spectrum"]; +/// The image load result states an event arm's `state` union must carry — +/// the engine's outcome vocabulary, matched by member NAME (declaration +/// order is the app's own). +const IMAGE_STATES = [ + "loaded", "rejected", "not_found", "io_failed", "connect_failed", "tls_failed", + "protocol_failed", "timed_out", "http_status", "cancelled", "too_large", + "unsupported", "decode_failed", "registry_full", "alloc_failed", +]; + /// Names the emitted module's own fixtures occupy (header helpers + commit /// machinery): module-level claims and function locals unique around them. const emittedFixtureNames = [ @@ -2161,6 +2171,37 @@ export class Emitter { if (method === "audioPlay") { return this.emitAudioPlayCmd(e, ctx); } + if (method === "imageLoad") { + return this.emitImageLoadCmd(e, ctx); + } + if (method === "imageCancel") { + const idArg = e.arguments[0]; + if (!idArg) this.fail(e, "`Cmd.imageCancel` id (the model-owned numeric ImageId)", "NS1027"); + // The same literal gate as imageLoad: an id no load could ever + // park under has nothing to cancel — stop the build instead of + // shipping a certain runtime no-op. Dynamic ids stay the host's + // (an unknown id is the documented no-op). + const idLiteral = this.numberLiteralValue(idArg); + if (idLiteral !== null && !(Number.isSafeInteger(idLiteral) && idLiteral >= 1)) { + this.fail(idArg, `\`Cmd.imageCancel\` id ${idLiteral} is not a positive integer ImageId below 2^53`, "NS1030"); + } + const id = this.emitExpr(idArg, ctx, { k: "f64" }).code; + return `rt.cmdImageCancel(${id})`; + } + if (method === "imageUnregister") { + const idArg = e.arguments[0]; + if (!idArg) this.fail(e, "`Cmd.imageUnregister` id (the model-owned numeric ImageId)", "NS1027"); + // The same literal gate as imageLoad/imageCancel: an id no load + // could ever register under has nothing to unregister — stop the + // build instead of shipping a certain runtime no-op. Dynamic ids + // stay the host's (an unregistered id is the documented no-op). + const idLiteral = this.numberLiteralValue(idArg); + if (idLiteral !== null && !(Number.isSafeInteger(idLiteral) && idLiteral >= 1)) { + this.fail(idArg, `\`Cmd.imageUnregister\` id ${idLiteral} is not a positive integer ImageId below 2^53`, "NS1030"); + } + const id = this.emitExpr(idArg, ctx, { k: "f64" }).code; + return `rt.cmdImageUnregister(${id})`; + } if (method in AUDIO_VERBS) { return this.emitAudioCtlCmd(e, method, ctx); } @@ -2188,7 +2229,7 @@ export class Emitter { } this.fail( e, - `Cmd.${method} (the v3 command set is none, persist, now, host, request, cancel, readFile, writeFile, fetch, clipboardWrite, clipboardRead, delay, spawn, audioPlay, audioPause, audioResume, audioStop, audioSeek, audioSetVolume, showWindow, quitApp, batch)`, + `Cmd.${method} (the v3 command set is none, persist, now, host, request, cancel, readFile, writeFile, fetch, clipboardWrite, clipboardRead, delay, spawn, audioPlay, audioPause, audioResume, audioStop, audioSeek, audioSetVolume, showWindow, quitApp, imageLoad, imageCancel, imageUnregister, batch)`, ); } this.fail(expr, "command expression (Cmd values are built inline from the Cmd.* factories)"); @@ -2465,6 +2506,144 @@ export class Emitter { return `rt.cmdAudioPlay("${escapeZigString(keyArg.text)}", ${tag}, ${audio_path}, ${url}, ${cache_path}, ${expected})`; } + /// `Cmd.imageLoad(id, source, route)`: the app's numeric ImageId (any + /// number expression — ids are model data), an inline + /// `{ path?, url?, cachePath?, expectedBytes? }` source (at least one of + /// path/url), and the `{ event }` routing whose arm carries the five + /// SDK-fixed image result fields. + private emitImageLoadCmd(e: ts.CallExpression, ctx: Ctx): string { + const idArg = e.arguments[0]; + if (!idArg) this.fail(e, "`Cmd.imageLoad` id (the model-owned numeric ImageId)", "NS1027"); + // A compile-time-known id the engine is certain to refuse stops the + // build; dynamic ids stay the host's to validate through the + // "rejected" result state. The bound is strictly BELOW 2^53 + // (Number.isSafeInteger for a positive value): 2^53 itself is the + // first f64 that aliases a neighbor (2^53 + 1), so the host rejects + // it too rather than guess which integer the app meant. + const idLiteral = this.numberLiteralValue(idArg); + if (idLiteral !== null && !(Number.isSafeInteger(idLiteral) && idLiteral >= 1)) { + this.fail(idArg, `\`Cmd.imageLoad\` id ${idLiteral} is not a positive integer ImageId below 2^53`, "NS1030"); + } + const id = this.emitExpr(idArg, ctx, { k: "f64" }).code; + + let source = e.arguments[1]; + while (source && (ts.isParenthesizedExpression(source) || ts.isAsExpression(source) || ts.isSatisfiesExpression(source))) source = source.expression; + if (!source || !ts.isObjectLiteralExpression(source)) { + this.fail( + e.arguments[1] ?? e, + `\`Cmd.imageLoad\` takes an inline \`{ path?, url?, cachePath?, expectedBytes? }\` source object`, + "NS1029", + ); + } + let image_path = '""'; + let url = '""'; + let cache_path = '""'; + let expected = "0"; + let has_source = false; + for (const p of source.properties) { + if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) { + this.fail(p, `\`Cmd.imageLoad\` source member \`${p.getText()}\` is not a plain property`, "NS1029"); + } + const name = p.name.text; + if (name === "path") { + image_path = this.effectBytesArg(e, p.initializer, "Cmd.imageLoad path", MAX_IMAGE_PATH_BYTES, ctx); + has_source = true; + } else if (name === "url") { + url = this.effectBytesArg(e, p.initializer, "Cmd.imageLoad url", MAX_URL_BYTES, ctx); + has_source = true; + } else if (name === "cachePath") { + cache_path = this.effectBytesArg(e, p.initializer, "Cmd.imageLoad cachePath", MAX_IMAGE_PATH_BYTES, ctx); + } else if (name === "expectedBytes") { + // A byte count is a whole number: file sizes have no fractional + // bytes, and a fractional value would truncate on the host into + // a size the app never declared — cache verification against + // the wrong size re-downloads on every launch. The bound is the + // id gate's (Number.isSafeInteger): past 2^53 the f64 wire + // cannot carry the count exactly. Dynamic values stay the + // host's, which maps unrepresentable counts to "unknown size". + const literal = this.numberLiteralValue(p.initializer); + if (literal !== null && !(Number.isSafeInteger(literal) && literal >= 0)) { + this.fail(p.initializer, `\`Cmd.imageLoad\` expectedBytes ${literal} is not a whole-number byte count below 2^53`, "NS1030"); + } + expected = this.emitExpr(p.initializer, ctx, { k: "f64" }).code; + } else { + this.fail(p, `\`Cmd.imageLoad\` source member \`${name}\``, "NS1029"); + } + } + if (!has_source) { + this.fail(source, `\`Cmd.imageLoad\` source without a \`path\` or a \`url\` — nothing could load`, "NS1029"); + } + + let route = e.arguments[2]; + while (route && (ts.isParenthesizedExpression(route) || ts.isAsExpression(route) || ts.isSatisfiesExpression(route))) route = route.expression; + if (!route || !ts.isObjectLiteralExpression(route)) { + this.fail(e.arguments[2] ?? e, `\`Cmd.imageLoad\` routing is an inline \`{ event }\` object`, "NS1027"); + } + let event: ts.StringLiteral | null = null; + for (const p of route.properties) { + if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) { + this.fail(p, `\`Cmd.imageLoad\` routing member \`${p.getText()}\` is not a plain property`, "NS1027"); + } + let v: ts.Expression = p.initializer; + while (ts.isParenthesizedExpression(v) || ts.isAsExpression(v) || ts.isSatisfiesExpression(v)) v = v.expression; + if (p.name.text !== "event") { + this.fail(p, `\`Cmd.imageLoad\` routing member \`${p.name.text}\``, "NS1027"); + } + if (!ts.isStringLiteral(v)) { + this.fail(p.initializer, `\`Cmd.imageLoad\` event arm is not a string literal`, "NS1027"); + } + event = v; + } + if (!event) this.fail(route, `\`Cmd.imageLoad\` routing without an \`event\` arm`, "NS1027"); + const tag = this.imageEventArmTag(event, ctx); + return `rt.cmdImageLoad(${id}, ${tag}, ${image_path}, ${url}, ${cache_path}, ${expected})`; + } + + /// Resolve the image result arm: exactly the five SDK-fixed fields, + /// matched by NAME — id (a number: the requested ImageId echoed + /// verbatim), state (a named literal-union alias carrying exactly the + /// fifteen image states, any order), width/height/status (numbers). + private imageEventArmTag(arg: ts.StringLiteral, ctx: Ctx): string { + const unionName = ctx.cmdReturn!.msgUnion; + const info = this.table.unions.get(unionName); + if (!info) this.fail(arg, `unknown union ${unionName}`); + const arm = info.arms.find((a) => a.tag === arg.text); + if (!arm) { + this.fail(arg, `routing target \`${arg.text}\` is not an arm of ${unionName}`, "NS1027"); + } + const shape = + "the five image result fields — id: number (the echoed ImageId), state (a named alias of exactly " + + IMAGE_STATES.map((s) => `"${s}"`).join(" | ") + + "), width: number, height: number, status: number"; + const fieldsByName = new Map(arm.fields.map((f) => [f.tsName, f])); + const isNumber = (k: string): boolean => k === "number" || k === "i64" || k === "f64"; + const id = fieldsByName.get("id"); + const state = fieldsByName.get("state"); + const width = fieldsByName.get("width"); + const height = fieldsByName.get("height"); + const status = fieldsByName.get("status"); + const stateOk = + state !== undefined && + state.type.k === "enum" && + state.type.members.length === IMAGE_STATES.length && + IMAGE_STATES.every((s) => state.type.k === "enum" && state.type.members.includes(s)); + const matches = + arm.fields.length === 5 && + stateOk && + id !== undefined && + isNumber(id.type.k) && + width !== undefined && + isNumber(width.type.k) && + height !== undefined && + isNumber(height.type.k) && + status !== undefined && + isNumber(status.type.k); + if (!matches) { + this.fail(arg, `routing target \`${arg.text}\` does not carry ${shape}`, "NS1027"); + } + return `@intFromEnum(std.meta.Tag(${unionName}).${zigId(arg.text)})`; + } + /// Resolve the audio event arm: exactly the six SDK-fixed fields, matched /// by NAME — state (a named literal-union alias carrying exactly the six /// audio states, any order), positionMs/durationMs (numbers), playing/ diff --git a/packages/core/test/conformance.test.ts b/packages/core/test/conformance.test.ts index 62706378..4f837897 100644 --- a/packages/core/test/conformance.test.ts +++ b/packages/core/test/conformance.test.ts @@ -3476,6 +3476,27 @@ const streamTail = ` } `; +// The image-load fixture: the fifteen-state union and the five-field +// result arm imageLoad routes (id echoes the requested ImageId). +const imageMsg = ` +export type ImageState = + | "loaded" | "rejected" | "not_found" | "io_failed" | "connect_failed" + | "tls_failed" | "protocol_failed" | "timed_out" | "http_status" + | "cancelled" | "too_large" | "unsupported" | "decode_failed" | "registry_full" + | "alloc_failed"; +export interface Model { readonly w: number; readonly errs: number; } +export type Msg = + | { readonly kind: "go"; readonly which: number } + | { readonly kind: "image_done"; readonly id: number; readonly state: ImageState; readonly width: number; readonly height: number; readonly status: number }; +export function initialModel(): Model { return { w: 0, errs: 0 }; } +`; + +const imageTail = ` + case "image_done": return msg.state === "loaded" ? { ...model, w: msg.width } : { ...model, errs: model.errs + 1 }; + } +} +`; + // Slice E: grammar-completeness round — the new statement/operator/ // declaration mappings in REALISTIC combinations (the minimal per-production // pins live in grammar_matrix.test.ts), plus the new teaching gates. @@ -3911,6 +3932,229 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd] { switch (msg.kind) { case "go": return [model, Cmd.audioSeek("player", -250)]; ${streamTail} +`, + }, + { + name: "imageLoad emits in its documented shapes (path, url+cache, model-expression ids)", + src: ` +import { Cmd, asciiBytes } from "@native-sdk/core"; +${imageMsg} +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "go": + if (msg.which === 0) return [model, Cmd.imageLoad(7, { path: asciiBytes("art/cover.png") }, { event: "image_done" })]; + if (msg.which === 1) return [model, Cmd.imageLoad(model.w + 1, { url: asciiBytes("https://cdn.test/a.png"), cachePath: asciiBytes("cache/a.png"), expectedBytes: 2048 }, { event: "image_done" })]; + return [model, Cmd.imageLoad(9, { path: asciiBytes("art/b.png"), url: asciiBytes("https://cdn.test/b.png") }, { event: "image_done" })]; +${imageTail} +`, + }, + { + name: "an image result arm whose state union misses a member is taught", + gate: "NS1027", + src: ` +import { Cmd, asciiBytes, type ImageEventKind } from "@native-sdk/core"; +export type NarrowState = "loaded" | "rejected" | "decode_failed"; +export interface Model { readonly w: number; readonly errs: number; } +export type Msg = + | { readonly kind: "go"; readonly which: number } + | { readonly kind: "image_done"; readonly id: number; readonly state: NarrowState; readonly width: number; readonly height: number; readonly status: number }; +export function initialModel(): Model { return { w: 0, errs: 0 }; } +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "go": return [model, Cmd.imageLoad(7, { path: asciiBytes("a.png") }, { event: "image_done" as ImageEventKind })]; + case "image_done": return { ...model, w: msg.width }; + } +} +`, + }, + { + name: "an image result arm with a wrong field shape is taught", + gate: "NS1027", + src: ` +import { Cmd, asciiBytes, type ImageEventKind } from "@native-sdk/core"; +${imageMsg} +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "go": return [model, Cmd.imageLoad(7, { path: asciiBytes("a.png") }, { event: "go" as ImageEventKind })]; +${imageTail} +`, + }, + { + name: "an image source without a path or url is taught (nothing could load)", + gate: "NS1029", + src: ` +import { Cmd, asciiBytes } from "@native-sdk/core"; +${imageMsg} +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "go": return [model, Cmd.imageLoad(7, { cachePath: asciiBytes("cache/a.png") }, { event: "image_done" })]; +${imageTail} +`, + }, + { + name: "an image id literal the registry must refuse stops at compile time", + gate: "NS1030", + src: ` +import { Cmd, asciiBytes } from "@native-sdk/core"; +${imageMsg} +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "go": return [model, Cmd.imageLoad(0, { path: asciiBytes("a.png") }, { event: "image_done" })]; +${imageTail} +`, + }, + { + // The id bound is exclusive at 2^53: 2^53 - 1 is the last integer + // every tier carries exactly, so it builds. + name: "the top image id literal (2^53 - 1) builds", + src: ` +import { Cmd, asciiBytes } from "@native-sdk/core"; +${imageMsg} +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "go": return [model, Cmd.imageLoad(9007199254740991, { path: asciiBytes("a.png") }, { event: "image_done" })]; +${imageTail} +`, + }, + { + // 2^53 aliases 2^53 + 1 in f64 — the first id the wire cannot carry + // exactly, so the literal stops the build. + name: "an image id literal of 2^53 stops at compile time", + gate: "NS1030", + src: ` +import { Cmd, asciiBytes } from "@native-sdk/core"; +${imageMsg} +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "go": return [model, Cmd.imageLoad(9007199254740992, { path: asciiBytes("a.png") }, { event: "image_done" })]; +${imageTail} +`, + }, + { + // Byte counts are whole numbers: a fractional literal would + // truncate on the host into a size the app never declared, so the + // cache would verify every download against the wrong size and + // re-fetch on every launch. The literal stops the build. + name: "a fractional image expectedBytes literal stops at compile time", + gate: "NS1030", + src: ` +import { Cmd, asciiBytes } from "@native-sdk/core"; +${imageMsg} +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "go": return [model, Cmd.imageLoad(7, { url: asciiBytes("https://cdn.test/a.png"), cachePath: asciiBytes("cache/a.png"), expectedBytes: 1.5 }, { event: "image_done" })]; +${imageTail} +`, + }, + { + name: "a whole-number image expectedBytes literal builds", + src: ` +import { Cmd, asciiBytes } from "@native-sdk/core"; +${imageMsg} +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "go": return [model, Cmd.imageLoad(7, { url: asciiBytes("https://cdn.test/a.png"), cachePath: asciiBytes("cache/a.png"), expectedBytes: 4096 }, { event: "image_done" })]; +${imageTail} +`, + }, + { + name: "imageCancel emits: the numeric-id cancel with literal and model-expression ids", + src: ` +import { Cmd, asciiBytes } from "@native-sdk/core"; +${imageMsg} +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "go": + if (msg.which === 0) return [model, Cmd.imageCancel(7)]; + return [model, Cmd.imageCancel(model.w + 1)]; +${imageTail} +`, + }, + { + // The same literal gate as imageLoad: an id no load could ever park + // under has nothing to cancel. + name: "an imageCancel id literal the registry must refuse stops at compile time", + gate: "NS1030", + src: ` +import { Cmd, asciiBytes } from "@native-sdk/core"; +${imageMsg} +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "go": return [model, Cmd.imageCancel(0)]; +${imageTail} +`, + }, + { + name: "the top imageCancel id literal (2^53 - 1) builds", + src: ` +import { Cmd, asciiBytes } from "@native-sdk/core"; +${imageMsg} +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "go": return [model, Cmd.imageCancel(9007199254740991)]; +${imageTail} +`, + }, + { + name: "an imageCancel id literal of 2^53 stops at compile time", + gate: "NS1030", + src: ` +import { Cmd, asciiBytes } from "@native-sdk/core"; +${imageMsg} +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "go": return [model, Cmd.imageCancel(9007199254740992)]; +${imageTail} +`, + }, + { + name: "imageUnregister emits: the numeric-id registry release with literal and model-expression ids", + src: ` +import { Cmd, asciiBytes } from "@native-sdk/core"; +${imageMsg} +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "go": + if (msg.which === 0) return [model, Cmd.imageUnregister(7)]; + return [model, Cmd.imageUnregister(model.w + 1)]; +${imageTail} +`, + }, + { + // The same literal gate as imageLoad/imageCancel: an id no load + // could ever register under has nothing to unregister. + name: "an imageUnregister id literal the registry must refuse stops at compile time", + gate: "NS1030", + src: ` +import { Cmd, asciiBytes } from "@native-sdk/core"; +${imageMsg} +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "go": return [model, Cmd.imageUnregister(0)]; +${imageTail} +`, + }, + { + name: "the top imageUnregister id literal (2^53 - 1) builds", + src: ` +import { Cmd, asciiBytes } from "@native-sdk/core"; +${imageMsg} +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "go": return [model, Cmd.imageUnregister(9007199254740991)]; +${imageTail} +`, + }, + { + name: "an imageUnregister id literal of 2^53 stops at compile time", + gate: "NS1030", + src: ` +import { Cmd, asciiBytes } from "@native-sdk/core"; +${imageMsg} +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "go": return [model, Cmd.imageUnregister(9007199254740992)]; +${imageTail} `, }, ]; @@ -5251,3 +5495,78 @@ export function read(bytes: Uint8Array): number { assert.ok(d.message.includes("must be an integer"), "names the conflict"); assert.ok(d.message.toLowerCase().includes("machine type"), "says why"); }); + +// The EventKind validators hold their documented exact-union `state` rule in +// STOCK tsc, not just in the transpiler's own shape check: `M extends Msgish +// & ImageEventArm` alone lets a NARROWER state union through (every narrower +// literal extends the full union), so the validators carry a tuple-wrapped +// reverse check — [ImageState] extends [M["state"]] — and a missing-member +// arm resolves to `never`, refusing the un-cast route at type-check time. + +test("a narrower image state union fails ImageEventKind in tsc itself", () => { + const result = transpile(` +import { Cmd, asciiBytes } from "@native-sdk/core"; +export type NarrowState = "loaded" | "rejected" | "decode_failed"; +export interface Model { readonly w: number; readonly errs: number; } +export type Msg = + | { readonly kind: "go"; readonly which: number } + | { readonly kind: "image_done"; readonly id: number; readonly state: NarrowState; readonly width: number; readonly height: number; readonly status: number }; +export function initialModel(): Model { return { w: 0, errs: 0 }; } +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "go": return [model, Cmd.imageLoad(7, { path: asciiBytes("a.png") }, { event: "image_done" })]; + case "image_done": return { ...model, w: msg.width }; + } +} +`); + assert.ok(result.typeErrors.length > 0, "tsc must refuse the narrower state union (ImageEventKind resolves it to never)"); + assert.ok(result.typeErrors.some((e) => e.includes("never")), `the refusal is the never-resolution\n${result.typeErrors.join("\n")}`); +}); + +test("the exact fifteen-member image state union still satisfies ImageEventKind in tsc", () => { + const result = transpile(` +import { Cmd, asciiBytes } from "@native-sdk/core"; +${imageMsg} +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "go": return [model, Cmd.imageLoad(7, { path: asciiBytes("a.png") }, { event: "image_done" })]; +${imageTail} +`); + assert.equal(result.typeErrors.length, 0, `tsc errors\n${result.typeErrors.join("\n")}`); + assert.equal(result.ok, true, "the exact union must keep transpiling"); +}); + +test("a narrower audio state union fails AudioEventKind in tsc itself", () => { + const result = transpile(` +import { Cmd, asciiBytes } from "@native-sdk/core"; +export type NarrowState = "loaded" | "position" | "completed" | "failed" | "rejected"; +export interface Model { readonly pos: number; readonly errs: number; } +export type Msg = + | { readonly kind: "go" } + | { readonly kind: "audio_evt"; readonly state: NarrowState; readonly positionMs: number; readonly durationMs: number; readonly playing: boolean; readonly buffering: boolean; readonly bands: Uint8Array } + | { readonly kind: "failed"; readonly why: Uint8Array }; +export function initialModel(): Model { return { pos: 0, errs: 0 }; } +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "go": return [model, Cmd.audioPlay("player", { path: asciiBytes("a.mp3") }, { event: "audio_evt" })]; + case "audio_evt": return { ...model, pos: msg.positionMs }; + case "failed": return { ...model, errs: model.errs + 1 }; + } +} +`); + assert.ok(result.typeErrors.length > 0, "tsc must refuse the narrower state union (AudioEventKind resolves it to never)"); + assert.ok(result.typeErrors.some((e) => e.includes("never")), `the refusal is the never-resolution\n${result.typeErrors.join("\n")}`); +}); + +test("the exact six-member audio state union still satisfies AudioEventKind in tsc", () => { + const result = transpile(` +import { Cmd, asciiBytes } from "@native-sdk/core"; +${streamMsg} +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "go": return [model, Cmd.audioPlay("player", { path: asciiBytes("a.mp3") }, { event: "audio_evt" })]; +${streamTail} +`); + assert.equal(result.typeErrors.length, 0, `tsc errors\n${result.typeErrors.join("\n")}`); + assert.equal(result.ok, true, "the exact union must keep transpiling"); +}); diff --git a/packages/core/test/effects.test.ts b/packages/core/test/effects.test.ts index 667ac2aa..64f0927f 100644 --- a/packages/core/test/effects.test.ts +++ b/packages/core/test/effects.test.ts @@ -724,3 +724,152 @@ test("streaming ops: wire bytes through the real dispatch cycle", { skip: !hasZi fs.rmSync(work, { recursive: true, force: true }); } }); + +// ------------------------------------------------------------------- images + +// Cmd.imageLoad end to end: the numeric-id record against the exact wire +// layout rt.zig documents, the five-field result arm (the echoed id +// included) round-tripping as a plain Msg, the source variants (local +// path, url with cache), and the numeric-id image_cancel and +// image_unregister records. +const coreImages = ` +import { Cmd, asciiBytes } from "@native-sdk/core"; + +export type ImageState = + | "loaded" | "rejected" | "not_found" | "io_failed" | "connect_failed" + | "tls_failed" | "protocol_failed" | "timed_out" | "http_status" + | "cancelled" | "too_large" | "unsupported" | "decode_failed" | "registry_full" + | "alloc_failed"; + +export interface Model { readonly cover: number; readonly w: number; readonly h: number; readonly errs: number; } + +export type Msg = + | { readonly kind: "load" } + | { readonly kind: "load_url" } + | { readonly kind: "drop" } + | { readonly kind: "evict" } + | { readonly kind: "image_done"; readonly id: number; readonly state: ImageState; readonly width: number; readonly height: number; readonly status: number }; + +export function initialModel(): Model { + return { cover: 0, w: 0, h: 0, errs: 0 }; +} + +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "load": return [model, Cmd.imageLoad(7, { path: asciiBytes("art/cover.png") }, { event: "image_done" })]; + case "load_url": return [model, Cmd.imageLoad(model.cover + 8, { url: asciiBytes("https://c.test/a.png"), cachePath: asciiBytes("cache/a.png"), expectedBytes: 2048 }, { event: "image_done" })]; + case "drop": return [model, Cmd.imageCancel(model.cover)]; + case "evict": return [model, Cmd.imageUnregister(model.cover)]; + case "image_done": + if (msg.state === "loaded") return { ...model, cover: msg.id, w: msg.width, h: msg.height }; + return { ...model, errs: model.errs + 1 }; + } +} +`; + +const harnessImages = ` +const std = @import("std"); +const core = @import("core.zig"); +const rt = core.rt; + +var g_model: *const core.Model = undefined; + +fn dispatch(msg: core.Msg, log: *std.ArrayList(u8)) []const u8 { + const r = core.update(g_model, msg); + g_model = core.commitModelRoot(r.model); + const start = log.items.len; + log.appendSlice(std.testing.allocator, r.cmd) catch @panic("oom"); + rt.frameReset(); + return log.items[start..]; +} + +fn tagOf(comptime arm: []const u8) u8 { + return @intFromEnum(@field(std.meta.Tag(core.Msg), arm)); +} + +fn expectLong(bytes: []const u8, at: *usize, expected: []const u8) !void { + const len = std.mem.readInt(u32, bytes[at.*..][0..4], .little); + try std.testing.expectEqual(@as(u32, @intCast(expected.len)), len); + try std.testing.expectEqualStrings(expected, bytes[at.* + 4 ..][0..expected.len]); + at.* += 4 + expected.len; +} + +test "image_load wire records match rt.zig's documented layout" { + var log: std.ArrayList(u8) = .empty; + defer log.deinit(std.testing.allocator); + + rt.resetAll(); + g_model = core.commitModelRoot(core.initialModel()); + rt.frameReset(); + + // image_load, local path: [0x12][id f64 LE][event_tag][path][url] + // [cache][expected f64 LE]. + const load = dispatch(.load, &log); + try std.testing.expectEqual(@as(u8, 0x12), load[0]); + try std.testing.expectEqual(@as(f64, 7), @as(f64, @bitCast(std.mem.readInt(u64, load[1..9], .little)))); + try std.testing.expectEqual(tagOf("image_done"), load[9]); + var at: usize = 10; + try expectLong(load, &at, "art/cover.png"); + try expectLong(load, &at, ""); + try expectLong(load, &at, ""); + try std.testing.expectEqual(@as(f64, 0), @as(f64, @bitCast(std.mem.readInt(u64, load[at..][0..8], .little)))); + try std.testing.expectEqual(load.len, at + 8); + + // The result arm round-trips as a plain Msg (the five-field record, + // state matched by member name; the echoed id is what the model + // adopts on "loaded"). + _ = dispatch(.{ .image_done = .{ .id = 7, .state = .loaded, .width = 640, .height = 480, .status = 200 } }, &log); + try std.testing.expectEqual(@as(@TypeOf(g_model.w), 640), g_model.w); + try std.testing.expectEqual(@as(@TypeOf(g_model.h), 480), g_model.h); + try std.testing.expectEqual(@as(@TypeOf(g_model.cover), 7), g_model.cover); + + // image_load, url with cache + expected size; the id is a model + // EXPRESSION (ids are model data) — 7 + 8 = 15 on the wire. + const stream = dispatch(.load_url, &log); + try std.testing.expectEqual(@as(u8, 0x12), stream[0]); + try std.testing.expectEqual(@as(f64, 15), @as(f64, @bitCast(std.mem.readInt(u64, stream[1..9], .little)))); + at = 10; + try expectLong(stream, &at, ""); + try expectLong(stream, &at, "https://c.test/a.png"); + try expectLong(stream, &at, "cache/a.png"); + try std.testing.expectEqual(@as(f64, 2048), @as(f64, @bitCast(std.mem.readInt(u64, stream[at..][0..8], .little)))); + + // A failure class routes the same arm; the model counts it. + _ = dispatch(.{ .image_done = .{ .id = 15, .state = .decode_failed, .width = 0, .height = 0, .status = 0 } }, &log); + try std.testing.expectEqual(@as(@TypeOf(g_model.errs), 1), g_model.errs); + + // image_cancel: [0x13][id f64 LE] — the id is a model expression + // (the adopted cover, 7). + const drop = dispatch(.drop, &log); + try std.testing.expectEqual(@as(u8, 0x13), drop[0]); + try std.testing.expectEqual(@as(f64, 7), @as(f64, @bitCast(std.mem.readInt(u64, drop[1..9], .little)))); + try std.testing.expectEqual(@as(usize, 9), drop.len); + + // image_unregister: [0x14][id f64 LE] — same one-field body, its own + // opcode (registry surgery, not a load verb). + const evict = dispatch(.evict, &log); + try std.testing.expectEqual(@as(u8, 0x14), evict[0]); + try std.testing.expectEqual(@as(f64, 7), @as(f64, @bitCast(std.mem.readInt(u64, evict[1..9], .little)))); + try std.testing.expectEqual(@as(usize, 9), evict.len); +} +`; + +test("image loads: wire bytes through the real dispatch cycle", { skip: !hasZig, timeout: 300_000 }, () => { + const result = transpile(coreImages); + const details = result.diagnostics.map((d) => `${d.id} ${d.message}`).join("\n"); + assert.equal(result.ok, true, `transpile failed\n${result.typeErrors.join("\n")}\n${details}`); + const work = fs.mkdtempSync(path.join(os.tmpdir(), "native-core-effects-images-")); + try { + fs.copyFileSync(path.join(pkg, "rt", "rt.zig"), path.join(work, "rt.zig")); + fs.writeFileSync(path.join(work, "core.zig"), result.zig!); + fs.writeFileSync(path.join(work, "harness.zig"), harnessImages); + try { + execFileSync("zig", ["test", "harness.zig"], { cwd: work, encoding: "utf8", stdio: "pipe" }); + } catch (e) { + const err = e as { stderr?: string; stdout?: string }; + assert.fail(`image-load harness failed:\n${err.stderr ?? ""}${err.stdout ?? ""}`); + } + } finally { + fs.rmSync(work, { recursive: true, force: true }); + } +}); diff --git a/packages/core/test/runfidelity.test.ts b/packages/core/test/runfidelity.test.ts index a24b1adc..808ff1f8 100644 --- a/packages/core/test/runfidelity.test.ts +++ b/packages/core/test/runfidelity.test.ts @@ -3146,6 +3146,117 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd] { row("g1", model.code); row("g2", model.out); } +`, + }, + { + name: "image loads under the virtual host: the numeric-id record, one terminal per load, failure classes", + src: ` +import { Cmd, asciiBytes } from "@native-sdk/core"; +export type ImageState = + | "loaded" | "rejected" | "not_found" | "io_failed" | "connect_failed" + | "tls_failed" | "protocol_failed" | "timed_out" | "http_status" + | "cancelled" | "too_large" | "unsupported" | "decode_failed" | "registry_full" + | "alloc_failed"; +export interface Model { + readonly cover: number; readonly w: number; readonly h: number; + readonly errs: number; readonly lastStatus: number; readonly state: ImageState; +} +export type Msg = + | { readonly kind: "load" } + | { readonly kind: "load_url" } + | { readonly kind: "image_done"; readonly id: number; readonly state: ImageState; readonly width: number; readonly height: number; readonly status: number }; +export function initialModel(): Model { + return { cover: 0, w: 0, h: 0, errs: 0, lastStatus: 0, state: "rejected" }; +} +export function update(model: Model, msg: Msg): Model | [Model, Cmd] { + switch (msg.kind) { + case "load": return [model, Cmd.imageLoad(21, { path: asciiBytes("art/cover.png") }, { event: "image_done" })]; + case "load_url": return [model, Cmd.imageLoad(model.cover + 1, { url: asciiBytes("https://c.test/a.png"), cachePath: asciiBytes("cache/a.png"), expectedBytes: 4096 }, { event: "image_done" })]; + case "image_done": + if (msg.state === "loaded") return { ...model, cover: msg.id, w: msg.width, h: msg.height, state: msg.state, lastStatus: msg.status }; + return { ...model, errs: model.errs + 1, state: msg.state, lastStatus: msg.status }; + } +} +`, + node: ` +{ + const kinds = ["load", "load_url", "image_done"]; + const te = new TextEncoder(); + // Mirrors of the image_load wire record (rt.zig). + const long = (b) => [b.length & 255, (b.length >> 8) & 255, (b.length >> 16) & 255, (b.length >>> 24) & 255, ...b]; + const f64le = (v) => { const dv = new DataView(new ArrayBuffer(8)); dv.setFloat64(0, v, true); const out = []; for (let i = 0; i < 8; i++) out.push(dv.getUint8(i)); return out; }; + const enc = (cmd) => { + if (cmd.op === "none") return []; + if (cmd.op === "image_load") { + return [0x12, ...f64le(cmd.id), kinds.indexOf(cmd.eventKind), ...long(Array.from(cmd.path)), ...long(Array.from(cmd.url)), ...long(Array.from(cmd.cachePath)), ...f64le(cmd.expectedBytes)]; + } + return cmd.cmds.flatMap(enc); + }; + const step = (model, msg) => { const r = mod.update(model, msg); return Array.isArray(r) ? [r[0], Uint8Array.from(enc(r[1]))] : [r, new Uint8Array(0)]; }; + // The virtual host reads the record's routing tag off the wire: + // [op][id f64][event_tag]. + const imageTag = (bytes) => bytes[9]; + let model = mod.initialModel(); + let cmd; + + [model, cmd] = step(model, { kind: "load" }); + line("i0", cmd); + const evTag = imageTag(cmd); + const done = (id, state, width, height, status) => ({ kind: kinds[evTag], id, state, width, height, status }); + [model, cmd] = step(model, done(21, "loaded", 640, 480, 0)); + line("i1", model.w); + line("i2", model.h); + line("i3", model.state); + + // The id expression rides the wire; a url load fails with its class + // and the status carried through. + [model, cmd] = step(model, { kind: "load_url" }); + line("i4", cmd); + [model, cmd] = step(model, done(22, "http_status", 0, 0, 404)); + line("i5", model.errs); + line("i6", model.lastStatus); + line("i7", model.state); + [model, cmd] = step(model, { kind: "load_url" }); + [model, cmd] = step(model, done(22, "decode_failed", 0, 0, 200)); + line("i8", model.errs); + line("i9", model.state); +} +`, + zig: ` + { + const Host = struct { + fn imageTag(bytes: []const u8) u8 { + return bytes[9]; + } + }; + const image_tag: u8 = @intFromEnum(std.meta.Tag(m.Msg).image_done); + var model = m.initialModel(); + + var r = m.update(model, .load); + model = r.model; + row("i0", r.cmd); + const ev_tag = Host.imageTag(r.cmd); + r = m.update(model, if (ev_tag == image_tag) m.Msg{ .image_done = .{ .id = 21, .state = .loaded, .width = 640, .height = 480, .status = 0 } } else m.Msg{ .image_done = .{ .id = 0, .state = .rejected, .width = 0, .height = 0, .status = 0 } }); + model = r.model; + row("i1", model.w); + row("i2", model.h); + row("i3", model.state); + + r = m.update(model, .load_url); + model = r.model; + row("i4", r.cmd); + r = m.update(model, .{ .image_done = .{ .id = 22, .state = .http_status, .width = 0, .height = 0, .status = 404 } }); + model = r.model; + row("i5", model.errs); + row("i6", model.lastStatus); + row("i7", model.state); + r = m.update(model, .load_url); + model = r.model; + r = m.update(model, .{ .image_done = .{ .id = 22, .state = .decode_failed, .width = 0, .height = 0, .status = 200 } }); + model = r.model; + row("i8", model.errs); + row("i9", model.state); + } `, }, { diff --git a/skill-data/native-ui/SKILL.md b/skill-data/native-ui/SKILL.md index 5a628e81..002b5efa 100644 --- a/skill-data/native-ui/SKILL.md +++ b/skill-data/native-ui/SKILL.md @@ -181,6 +181,7 @@ Automation drives the native path honestly: snapshots list every widget's declar | `skeleton`, `spinner` | loading leaves | size `skeleton` with `width`/`height` | | `icon` | vector icon leaf | `name` picks the icon: a bare literal is a curated built-in stroke icon (compile-checked; 49 names: search, plus, x, x-circle, check, check-circle, chevron-up/down/left/right, arrow-up/down/right, menu, panel-left, panel-right, settings, terminal, wrench, trash, edit, copy, external-link, play, pause, skip-back/forward, shuffle, repeat, music, volume, info, alert, download, save, folder, folder-open, file-text, sun, moon, eye, clock, git-pull-request, git-merge, git-branch, circle-dot, archive, refresh-cw, send); `app:` reaches an icon the app registered at boot with `canvas.icons.registerAppIcons` (declare the table as `pub const app_icons` on the app root so `native check` verifies the name against the model contract), and one `{binding}` defers the choice to model data - an unknown resolved name draws the missing-icon fallback (a slashed circle) with a Debug warning naming the value, never a silent gap; tint with `foreground`, size with `width`/`height` | | `media-surface` | media surface leaf | composites a texture produced OUTSIDE the widget tree (video decoder, camera, an external renderer like mpv) into the layout like any widget — clipped, z-ordered, rounded. `surface="{binding}"` (required) binds the model-owned u64 surface id a Zig-tier producer targets (`runtime.acquireMediaSurfaceProducer` pushes RGBA8 frames, latest-wins, paced by the presented-frame clock; 0 = unbound, draws nothing; usable ids are nonzero values below the reserved bit 63). No intrinsic size — give it `width`/`height` or `grow`; display-only (presses fall through); `label` it (pictorial content). Texture contents are presentation chrome: goldens, reference screenshots, and session replay show the deterministic id-derived placeholder, never producer frames | +| `image` | runtime image leaf | draws a RUNTIME-REGISTERED image by its model-owned u64 ImageId — the id `Cmd.imageLoad` (TS) or `fx.loadImage`/`fx.registerImageBytes` (Zig) registered pixels under. `image="{binding}"` (required) binds a model field/fn; ids are model data, never markup literals, and 0 draws nothing (store the id only when the load reports loaded — see the Images section). No intrinsic size — give it `width`/`height` or `grow`; display-only (presses fall through); `label` it (pictorial content) | | `markdown` | rendered markdown subtree | leaf; `source` is one `{binding}` — see "Markdown in markup" | | `stepper` > `step` | composite stage track | `active="{index}"` (required) derives each step's completed/active/pending state; steps are text leaves (no attributes) joined by connectors; stepper also takes `key`, `global-key`, `label` | | `timeline` > `timeline-item` | composite ledger list | items only inside a timeline (for/if fine); items are leaves — `title` (required), `description`, `meta`, `indicator`, `variant`, `connector="false"` on the last item, `selected`; `on-press` makes the whole item pressable with a trailing chevron | @@ -188,7 +189,7 @@ Automation drives the native path honestly: snapshots list every widget's declar | `context-menu` | consumed by its parent | right-click menu on its DIRECT parent (a hit target or an element with `on-press`/`on-hold`); metadata, never a flow child. Children: `menu-item`s (`on-press` required, `disabled` optional, no `icon`) and bare `separator`s, with `if`/`else`/`for` around them. Attribute-less; presents natively where the host has a menu presenter, as an anchored surface elsewhere — see "Context menus" | | `input-group` > `textarea` + `input-group-actions` | composite grouped input | the composer shape: ONE bordered field wrapping exactly one `textarea` (first — document order is focus order) plus an optional `input-group-actions` row of controls inside the same border. The group wears the focus ring for its focused descendant and the textarea's own chrome dissolves automatically, so the whole group reads as one field; the textarea keeps its full behavior (`text`, `placeholder`, `on-input`, `on-submit`, `autofocus`). Group takes `label`, `width`, `height`, `min-width`, `grow`, `key`, `global-key`; the actions row takes `gap` and holds ordinary elements (`if`/`else`/`for` work — swap send for stop while streaming) — put a `` between leading and trailing controls (`Ui.inputGroup`/`Ui.inputGroupActions` are the Zig-view equivalents) | -Not markup-expressible (deliberately — write these as Zig view functions with `canvas.Ui`): `image` (needs `ImageId` pixel references, runtime-registered — see the Images section), `icon_button` (`