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 — 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.
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<Msg>] {
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 };
}
}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 <image> is dead markup), the id is always {binding} model data (never a literal), and avatars carry the same attribute with an initials fallback:
<image image="{cover}" width="120" height="80" label="Cover art" />
<avatar image="{avatar}" label="Octocat">OC</avatar>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:
- The local
pathis tried first. A missing file falls through tourl(when one is given); every other local failure is terminal — retrying a different source would mask the real problem. - 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. - 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 — <caches>/images/<sha256[..16]>.<ext> — 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 —
decode_failed,unsupported(a host without a codec),registry_full, andalloc_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 asdecode_failed).too_largenormally means the encoded source exceeded 8 MiB; platform codecs fit decoded pixels to the app's declared target. - 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) andcancelled(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 <image>) 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.
When many small visuals ship together, one slot can hold a texture atlas instead: <image> and <avatar> accept source-x, source-y, source-width, and source-height together, in decoded-image pixel coordinates. Zig views set the equivalent ElementOptions.image_src rectangle. Cropped widgets use nearest sampling so adjacent regions cannot bleed into the tile. Every atlas region references the same registered ImageId, so it consumes one registry slot; use the dimensions reported by the load result because decode-to-fit may change the atlas geometry.
Limits, honestly
The default is 16 slots with a 1 MiB decoded-pixel target per slot. That target is not a refusal: platform codecs decode photo-scale sources down, preserving aspect, until width × height × 4 fits. A wide 1024×256 image already fits and stays that size; a 640×480 image registers at a smaller geometry, and the result's width/height report exactly what views draw. The encoded source has its own flat 8 MiB bound; an over-bound source fails whole with too_large, never as truncated bytes.
Image-centric apps can raise the target in app.zon, up to the hard 8 MiB ceiling:
.images = .{ .max_image_pixel_bytes = 8_388_608 },The value is frozen at startup. Pixel blocks and decode scratch allocate lazily, so declaring a raise costs nothing until an image is used; each used registry slot is a whole-budget block, however, so filling all 16 slots at 8 MiB is a declared 128 MiB high-water. The source bound stays 8 MiB regardless of this setting.
This gives three clear tiers: decode-to-fit is the default for feeds, galleries, avatars, and covers; the manifest raise serves wallpaper and image-forward apps that want more display-scale detail; pixel-exact editing, source-resolution zoom, and gigapixel tiling belong in an app-owned gpu_surface or media-surface producer, not the registry. The framework still bundles no codecs: bytes decode through CGImageSource on Apple platforms, gdk-pixbuf on GTK, WIC on Windows, BitmapFactory on Android, and the embed image service — a host without one answers unsupported.
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'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/<sha256[..16]> 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 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. If the current manifest lowers the image budget after recording, the recorded result dimensions still replay verbatim while best-effort presentation re-decodes to the current budget; any resulting screenshot difference is a verification mismatch, not false journal damage. 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.
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