← assets · API reference · version 0.5.0
7 file(s), 1289 lines. This is the source the published version ships, and it is loft — the library is written in the same language you call it from.
Public items
A worked example is a call site the author chose as the thing to read first. Call sites are every line naming the item, found mechanically over the source with comments excluded — so an item with no worked example is not an item nobody uses.
| Item | Defined | Worked example | Call sites |
|---|---|---|---|
BlobKind enum | 30 | — | 44 196 577 |
DefKind enum | 34 | — | 100 |
Blob struct | 42 | — | 196 202 213 288 540 590 … (29 total) |
PageInfo struct | 54 | — | 158 340 366 423 448 559 … (15 total) |
Cell struct | 68 | — | 159 341 366 424 448 570 … (15 total) |
Seq struct | 82 | — | 160 590 59 17 |
Def struct | 98 | — | 161 590 59 19 21 23 … (7 total) |
Anim struct | 114 | — | 162 590 59 25 27 74 |
Scene struct | 123 | — | 163 590 59 29 38 21 |
Placed struct | 134 | — | 164 590 59 30 32 34 … (10 total) |
Pack struct | 157 | — | 173 174 213 230 244 257 … (12 total) |
new_pack fn | 173 | — | 52 57 88 113 128 142 … (21 total) |
meta_path fn | 183 | — | 215 231 232 43 44 165 … (8 total) |
blobs_path fn | 186 | — | 216 43 44 67 97 106 … (14 total) |
blob_put fn | 196 | — | 577 31 32 33 174 175 … (8 total) |
pack_write fn | 213 | PAK-001 | 55 91 116 131 162 38 … (7 total) |
pack_read fn | 230 | — | 58 143 40 60 |
anim_of fn | 244 | PAK-003 | 46 47 50 51 52 75 … (8 total) |
keys_near fn | 257 | — | 58 65 70 71 83 61 |
prefetch fn | 288 | PAK-002 | 97 106 120 147 43 64 … (7 total) |
PROXY_ALPHA_ON const | 314 | — | 350 392 428 |
PROXY_BANDS const | 323 | — | 449 |
opaque_texels fn | 423 | — | 78 96 114 129 |
cell_proxy fn | 448 | — | 573 63 81 97 115 130 |
proxy_overshoot fn | 462 | — | 82 87 102 132 |
texels fn | 478 | — | 26 91 79 45 |
Tile struct | 494 | — | 541 37 38 54 55 67 … (13 total) |
page_grid fn | 540 | — | 36 53 66 83 98 102 … (7 total) |
layout_descriptor fn | 588 | — | 612 43 51 55 70 71 |
layout_fingerprint fn | 610 | — | 42 43 50 |
src/assets.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// assets — a game's content pack, which IS a loft store.
//
// There is no codec here, and no parse step: the struct definitions below ARE the
// file layout. That is what lets a pack sit on any dumb file server and be read by
// HTTP range, with no server-side code — a browser fetches the pages one lookup
// touches and nothing else.
//
// A pack is TWO stores, split by how each half is READ:
//
// * `Pack` — the metadata. Small, read whole at boot, and a wrapper struct
// holding seven keyed collections, so one file carries all of them.
// * `hash<Blob[bl_key]>` — the bulk bytes: pixels, audio, fonts. Rooted on the
// hash itself, never reached through a field, because a collection reached
// through one records the WRAPPER as the store's type and the paged loaders
// refuse such a store.
//
// Mixing the two would make the boot load pay for the art.
// --- What a pack holds ---
use shapes::*;
/// What a blob's bytes are, so a loader knows what to do with them without
/// sniffing the content. `Rgba` is a texture page's own pixels — premultiplied,
/// row-major, four bytes per texel — already decoded by the packer, so the runtime
/// never learns how the art was made.
pub enum BlobKind { Bytes, Rgba, Png, Ogg, Wav, Ttf, Woff2 }
/// Whether a definition draws or lights. A light is a placed instance like any
/// other, and this is what says which.
pub enum DefKind { Sprite, Light }
/// Raw bytes under a key — a texture page's pixels, audio, a font, a source PNG.
///
/// One blob is the **paging unit**: `store_load_key_text` fetches the pages this
/// record touches and nothing else. So size a blob like an atlas page rather than
/// like one sprite — a store's minimum image is 8 KiB, and a finer key fetches a
/// page's worth either way.
pub struct Blob {
pub bl_key: text,
pub bl_kind: BlobKind = Bytes,
pub bl_bytes: vector<u8>,
}
/// A texture page's SHAPE, and the blob key its pixels live under.
///
/// The dimensions are metadata and the pixels are bulk, so they live in different
/// stores. A loader that needs to know a page is 64x32 — to build UVs, to size a GL
/// texture, to decide what to prefetch — gets that from the boot load without
/// fetching a single texel.
pub struct PageInfo {
pub pi_name: text,
pub pi_w: integer,
pub pi_h: integer,
pub pi_blob: text, // the `Rgba` blob holding this page's pixels
}
/// One sprite's rect within a page, plus the origin a stage places it by.
///
/// `ce_proxy` is the collision proxy DERIVED from this cell's own alpha at pack
/// time (see `cell_proxy`), in cell-local coordinates. It is empty when nothing
/// derived one — a pack built before proxies, or a cell with no opaque texels —
/// and an empty proxy hits nothing, so a consumer that finds one absent falls back
/// to the cell rect rather than colliding with everything.
pub struct Cell {
pub ce_key: text,
pub ce_page: text,
pub ce_x: integer,
pub ce_y: integer,
pub ce_w: integer,
pub ce_h: integer,
pub ce_ox: float = 0.0,
pub ce_oy: float = 0.0,
pub ce_proxy: vector<shapes::Rect>,
}
/// An animation: a run of cells laid out in one page's grid. Mirrors `stage`'s
/// `Sequence`, so a loader hands it straight over.
pub struct Seq {
pub sq_key: text,
pub sq_page: text,
pub sq_first: integer,
pub sq_count: integer,
pub sq_fps: float = 12.0,
pub sq_mode: integer = 0,
pub sq_cols: integer = 1,
pub sq_rows: integer = 1,
}
/// An object definition — GameMaker's *object*, the thing a room places.
///
/// A walk cycle is asset data and not code, so the animation table hangs off the
/// definition (see `anim_of`) rather than living in a game's source. A light
/// carries its radius, colour and power here and is placed exactly like a sprite.
pub struct Def {
pub df_name: text,
pub df_kind: DefKind = Sprite,
pub df_cell: text = "", // the still sprite, when it has no sequence
pub df_w: float = 0.0,
pub df_h: float = 0.0,
pub df_ox: float = 0.0,
pub df_oy: float = 0.0,
pub df_layer: integer = 0,
pub df_radius: float = 0.0, // light only: reach is 0 at exactly this distance
pub df_colour: integer = 0,
pub df_power: float = 0.0,
}
/// One row of a definition's animation table: `(action, facing) -> sequence`.
/// Build `an_key` as `"{def}/{action}/{facing}"`, which is what `anim_of` looks up.
pub struct Anim {
pub an_key: text,
pub an_def: text,
pub an_action: text,
pub an_facing: integer,
pub an_seq: text,
}
/// A room: a named space that placed instances live in.
pub struct Scene {
pub sc_name: text,
pub sc_w: integer,
pub sc_h: integer,
}
/// A placed instance — GameMaker's *room instance*.
///
/// Definitions plus these are what a scene IS; a flat node dump cannot say that two
/// goblins are the same goblin. The fields mirror `stage::Place`, so loading a scene
/// is a copy rather than a translation.
pub struct Placed {
pub pd_scene: text,
pub pd_order: integer,
pub pd_def: text,
pub pd_x: float = 0.0,
pub pd_y: float = 0.0,
pub pd_depth: float = 0.0,
pub pd_layer: integer = 0,
pub pd_rot: float = 0.0,
pub pd_sx: float = 1.0,
pub pd_sy: float = 1.0,
pub pd_alpha: float = 1.0,
pub pd_colour: integer = 0,
pub pd_visible: boolean = true,
}
/// A pack's METADATA half — every collection a game reads at boot.
///
/// Binding through this container is what puts all seven collections in one file: a
/// keyed field shares its container's store, so one persist call writes one file and
/// a reader that declares the same type gets all of it. Read it WHOLE; it is small,
/// every part is needed from the first frame, and being a wrapper struct it cannot be
/// paged anyway. The bytes are the other store's job.
pub struct Pack {
pub pages: hash<PageInfo[pi_name]>,
pub cells: hash<Cell[ce_key]>,
pub seqs: hash<Seq[sq_key]>,
pub defs: hash<Def[df_name]>,
pub anims: hash<Anim[an_key]>,
pub scenes: hash<Scene[sc_name]>,
pub placed: index<Placed[pd_scene, pd_order]>,
}
// --- Writing a pack, and reading one back ---
/// An empty pack — every collection present and none of them holding anything.
///
/// Both a packer and a loader start here: a loader declares the pack it is about to
/// fill, then hands it to `pack_read`.
pub fn new_pack() -> Pack {
Pack { pages: [], cells: [], seqs: [], defs: [], anims: [], scenes: [],
placed: [] }
}
/// Where a pack's metadata half lives, given the name the pack is known by.
///
/// `base` is a local path or an `http(s)://` URL — the two halves are named the same
/// way either side of the wire, which is what lets a game develop against a file and
/// ship against a CDN with only the base changed.
pub fn meta_path(base: text) -> text { "{base}.meta.store" }
/// Where a pack's bulk half lives. Pass this to `prefetch` to page a blob out of it.
pub fn blobs_path(base: text) -> text { "{base}.blobs.store" }
/// Is this base a URL rather than a path on this machine?
fn is_url(base: text) -> boolean {
base.starts_with("http://") || base.starts_with("https://")
|| base.starts_with("file://")
}
/// Put a blob under its key, ready to be written. A blob already under the key is
/// replaced, which is what a packer re-running over changed art wants.
pub fn blob_put(b: &hash<Blob[bl_key]>, key: text, kind: BlobKind,
bytes: const vector<u8>) {
// The old blob is removed first rather than replaced in one assignment: a loft before
// the fix of keyed replacement under a TEXT key kept both records (`len` 2 under one
// key). A removal by key was always exact, so this is right on every loft.
b[key] = null;
b[key] = Blob { bl_key: key, bl_kind: kind, bl_bytes: bytes };
}
/// Write both halves of a pack, answering whether both landed.
///
/// A pack is final by the time it ships, so this copies rather than binds: a copied
/// image lays each collection out in its own order, which is what makes one keyed
/// lookup one RUN of pages instead of a scatter — the difference between a range
/// read that pays for a page and one that pays for the file. Writing needs a
/// desktop; a browser reads packs and does not build them.
// Example: @PAK-001
pub fn pack_write(p: const Pack, blobs: const hash<Blob[bl_key]>,
base: text) -> boolean {
if !store_persist_copy(p, meta_path(base)) { return false }
store_persist_copy(blobs, blobs_path(base))
}
/// Read a pack's metadata half in full, answering whether it arrived.
///
/// Give it a pack from `new_pack`. `base` is a local path or an `http(s)://` /
/// `file://` URL, and that is the only thing that changes between developing against
/// a file and shipping against a CDN. The bytes are NOT read here — ask for those by
/// key, through `prefetch`, at a load or level boundary.
///
/// A URL is fetched from a source you TRUST: the image is structurally validated, so
/// a corrupt file is refused rather than adopted, but its contents are not
/// authenticated. Serve packs from your own origin, or verify the download yourself
/// with `store_load_url` before reading it.
pub fn pack_read(p: Pack, base: text) -> boolean {
if is_url(base) { return store_load_url_trusted(p, meta_path(base)) }
store_load(p, meta_path(base))
}
// --- Asking a pack what a scene needs ---
/// The sequence an action and a facing map to, or `""` when the definition has none.
///
/// This is the animation table's whole point: `anim_of(pack, "goblin", "walk", 2)`
/// answers `"goblin_walk_n"` with nothing in a game's source holding the mapping, and
/// an action the definition does not have answers empty rather than a plausible wrong
/// sequence.
// Example: @PAK-003
pub fn anim_of(p: const Pack, def: text, action: text, facing: integer) -> text {
row = p.anims["{def}/{action}/{facing}"];
if row == null { return "" }
row.an_seq
}
/// Every blob key a scene's instances draw from within `radius` of `(cx, cy)`.
///
/// Ask for this at a load or a level boundary, never inside a frame: a range read is
/// a round trip and a frame is 16 ms, so a frame that DISCOVERS it needs an asset
/// stutters visibly. A negative `radius` means the whole scene — what a small room
/// and a loading screen both want. Duplicates are dropped, because a page shared by
/// forty instances is still one fetch.
pub fn keys_near(p: const Pack, scene: text, cx: float, cy: float,
radius: float) -> vector<text> {
out: vector<text> = [];
for i in p.placed {
if i.pd_scene != scene { continue }
if radius >= 0.0 {
dx = i.pd_x - cx;
dy = i.pd_y - cy;
if dx * dx + dy * dy > radius * radius { continue }
}
d = p.defs[i.pd_def];
if d == null { continue }
if d.df_cell == "" { continue } // a light draws nothing
c = p.cells[d.df_cell];
if c == null { continue }
page = p.pages[c.ce_page];
if page == null { continue }
seen = false;
for k in out { if k == page.pi_blob { seen = true } }
if !seen { out += [page.pi_blob]; }
}
out
}
/// Pull `keys` into `into`, and answer how many actually COST something.
///
/// `source` is `blobs_path(base)`. A key already resident is skipped, so the answer is
/// the number of round trips this call paid for: what a level boundary wants to log,
/// and what a frame must be able to assert is zero. A key the pack does not hold is
/// skipped too — an absent asset costs a lookup, not a record.
// Example: @PAK-002
pub fn prefetch(into: hash<Blob[bl_key]>, source: text,
keys: const vector<text>) -> integer {
// A `file://` base names a file on this machine, and the paged loader takes the
// path — it refuses the URL spelling outright. Dropping the scheme here is what
// lets ONE base drive both halves of a pack; the metadata half reads a `file://`
// URL directly, so without this the same string would work for one and not the
// other.
src = source;
// Byte offsets, because a slice is bytes: `len` counts CHARACTERS and a path
// outside ASCII would be cut short by it.
if src.starts_with("file://") { src = src[7..size(src)]; }
n = 0;
for k in keys {
if into[k] != null { continue }
if store_load_key_text(into, src, k) { n += 1 }
}
n
}
// --- Deriving a collision proxy from the art ---
/// A texel at or below this alpha is not part of the silhouette.
///
/// Not 128. Antialiased art has a fringe of low-alpha texels that visibly belong
/// to the sprite, and a proxy that excludes them stops short of the outline a
/// player sees. 8 is the value @PLN146 F7a measured the corpus with.
pub const PROXY_ALPHA_ON = 8;
/// How many bands a derived proxy is cut into.
///
/// Measured over 36 sprites (@PLN146 F7a): one tight box overshoots the art by
/// +150.6 % on average, a convex hull by +54.9 %, 8 bands by +52.2 % and 16 by
/// +36.7 % — and 16 beats the hull on mean, median AND worst case while needing no
/// shape kind `shapes` does not have. 32 buys another 10 points for twice the
/// boxes; 16 is where the curve flattens.
pub const PROXY_BANDS = 16;
/// The alpha of one texel of an `Rgba`/`Bytes` page, or 0 when it is off the page.
///
/// Off-page reads 0 — TRANSPARENT — rather than null, because the only caller is a
/// containment scan and a texel that is not there cannot be part of a silhouette.
fn texel_alpha(ta_px: const vector<u8>, ta_stride: integer, ta_x: integer,
ta_y: integer) -> integer {
ta_i = (ta_y * ta_stride + ta_x) * 4 + 3;
if ta_i < 0 || ta_i >= len(ta_px) { return 0 }
ta_px[ta_i]?
}
/// The along-axis extent of a cell's opaque mask: `(lo, hi)`, or `(-1, -1)` when
/// nothing in the cell is opaque.
///
/// `swap` reads the mask transposed, which is how one routine serves both axes.
fn opaque_extent(oe_px: const vector<u8>, oe_page: const PageInfo,
oe_c: const Cell, oe_swap: boolean) -> (integer, integer) {
oe_lo = -1;
oe_hi = -1;
oe_vn = if oe_swap { oe_c.ce_w } else { oe_c.ce_h };
oe_un = if oe_swap { oe_c.ce_h } else { oe_c.ce_w };
for v in 0..oe_vn {
for u in 0..oe_un {
oe_ax = oe_c.ce_x + (if oe_swap { v } else { u });
oe_ay = oe_c.ce_y + (if oe_swap { u } else { v });
if texel_alpha(oe_px, oe_page.pi_w, oe_ax, oe_ay) > PROXY_ALPHA_ON {
if oe_lo < 0 { oe_lo = v; }
oe_hi = v;
break;
}
}
}
(oe_lo, oe_hi)
}
/// Band the opaque texels along ONE axis and take each band's tight box.
///
/// `swap` runs the identical decomposition on the other axis by reading the mask
/// transposed and un-transposing the boxes, which is how one routine answers both
/// without a second copy of the arithmetic. Boxes come back in CELL-LOCAL
/// coordinates.
fn band_boxes(bb_px: const vector<u8>, bb_page: const PageInfo, bb_c: const Cell,
bb_k: integer, bb_swap: boolean) -> vector<shapes::Rect> {
bb_out: vector<shapes::Rect> = [];
(bb_lo, bb_hi) = opaque_extent(bb_px, bb_page, bb_c, bb_swap);
if bb_lo < 0 { return bb_out }
bb_span = bb_hi - bb_lo + 1;
bb_un = if bb_swap { bb_c.ce_h } else { bb_c.ce_w };
// One accumulator per band. Parallel vectors rather than a keyed collection:
// the index IS the key, and a hash here would allocate a record per band for a
// number the loop already has.
bmin_u: vector<integer> = [];
bmax_u: vector<integer> = [];
bmin_v: vector<integer> = [];
bmax_v: vector<integer> = [];
for _ in 0..bb_k {
bmin_u += [-1]; bmax_u += [-1]; bmin_v += [-1]; bmax_v += [-1];
}
for v in bb_lo..=bb_hi {
// `/` types nullable (a zero divisor answers null); `bb_span` is `bb_hi - bb_lo + 1`
// behind the `bb_lo < 0` return above, so it is at least 1 and `?? 0` is unreachable.
bb_band = ((v - bb_lo) * bb_k) / bb_span ?? 0;
if bb_band > bb_k - 1 { bb_band = bb_k - 1; }
for u in 0..bb_un {
bb_ax = bb_c.ce_x + (if bb_swap { v } else { u });
bb_ay = bb_c.ce_y + (if bb_swap { u } else { v });
if texel_alpha(bb_px, bb_page.pi_w, bb_ax, bb_ay) <= PROXY_ALPHA_ON { continue }
if bmin_u[bb_band]? < 0 || u < bmin_u[bb_band]? { bmin_u[bb_band] = u; }
if u > bmax_u[bb_band]? { bmax_u[bb_band] = u; }
if bmin_v[bb_band]? < 0 || v < bmin_v[bb_band]? { bmin_v[bb_band] = v; }
if v > bmax_v[bb_band]? { bmax_v[bb_band] = v; }
}
}
for b in 0..bb_k {
if bmin_u[b]? < 0 { continue }
bb_uu = bmin_u[b]? as float;
bb_vv = bmin_v[b]? as float;
bb_uw = (bmax_u[b]? - bmin_u[b]? + 1) as float;
bb_vh = (bmax_v[b]? - bmin_v[b]? + 1) as float;
if bb_swap {
bb_out += [shapes::Rect{rx: bb_vv, ry: bb_uu, rw: bb_vh, rh: bb_uw}];
} else {
bb_out += [shapes::Rect{rx: bb_uu, ry: bb_vv, rw: bb_uw, rh: bb_vh}];
}
}
bb_out
}
/// Total area of a box set — the numerator of the overshoot the packer gates on.
fn boxes_area(ba: const vector<shapes::Rect>) -> float {
ba_t = 0.0;
for r in ba { ba_t += r.rw * r.rh; }
ba_t
}
/// How many texels of this cell are opaque — the thing a proxy stands for, and the
/// denominator of `proxy_overshoot`.
pub fn opaque_texels(ot_px: const vector<u8>, ot_page: const PageInfo,
ot_c: const Cell) -> integer {
ot_n = 0;
for y in 0..ot_c.ce_h {
for x in 0..ot_c.ce_w {
if texel_alpha(ot_px, ot_page.pi_w, ot_c.ce_x + x, ot_c.ce_y + y) > PROXY_ALPHA_ON {
ot_n += 1;
}
}
}
ot_n
}
/// Derive `c`'s collision proxy from the page's own alpha, in cell-local
/// coordinates.
///
/// Banded on the TIGHTER axis: the derivation runs both ways and keeps the smaller
/// total area. 17 of the 36 corpus sprites band better by column than by row, so
/// choosing per sprite rather than fixing an axis takes the median overshoot from
/// +29.2 % to +22.8 % — for the cost of running a pass twice at pack time, which
/// is a cost nothing at runtime pays.
///
/// A cell with no opaque texels derives an EMPTY proxy, and an empty proxy hits
/// nothing. That is the answer a fully transparent cell should give; a single box
/// around it would collide with whatever stood there.
pub fn cell_proxy(c: const Cell, page: const PageInfo, px: const vector<u8>,
bands: integer = PROXY_BANDS) -> vector<shapes::Rect> {
rows = band_boxes(px, page, c, bands, false);
cols = band_boxes(px, page, c, bands, true);
if len(cols) > 0 && boxes_area(cols) < boxes_area(rows) { return cols }
rows
}
/// How much bigger a derived proxy is than the art it stands for.
///
/// The number F7 gates on: `<= 1.0` is at most twice the art. Reported per sprite
/// rather than asserted in aggregate, because one adversarial shape — three thin
/// diagonal arrows — misses the bound at every band count, and a bound nothing can
/// violate is not a bound.
pub fn proxy_overshoot(po_boxes: const vector<shapes::Rect>, po_solid: integer) -> float {
if po_solid <= 0 { return 0.0 }
((boxes_area(po_boxes) - (po_solid as float)) / (po_solid as float))?
}
// --- Building a page out of sprites ---
/// The RGBA bytes of a row-major buffer of `0xAARRGGBB` words.
///
/// Both routes into a `Tile` end here. A `graphics::Canvas` holds exactly these
/// words in `data`, and a PNG decoded by `imaging` answers them one pixel at a time
/// through `Pixel.rgba()` — so a sprite rendered in this process and the same sprite
/// read back off disk become the same texels by the same arithmetic, rather than by
/// two conversions that agree until one of them is edited.
///
/// Alpha is carried STRAIGHT, not premultiplied: see `page_grid`.
pub fn texels(words: const vector<integer>) -> vector<u8> {
tx_out: vector<u8> = [];
for w in words {
tx_out += [((w >> 16) & 255) as u8, ((w >> 8) & 255) as u8,
(w & 255) as u8, ((w >> 24) & 255) as u8];
}
tx_out
}
/// One sprite on its way into a page: the key it will be known by, its size, and
/// its texels.
///
/// `ti_px` is `ti_w * ti_h * 4` bytes of STRAIGHT-alpha RGBA, row-major. Where
/// those bytes came from is deliberately not this package's business — a decoded
/// PNG and a canvas rendered in this process are the same tile here, which is what
/// lets a sprite authored as text skip the file it used to have to become.
pub struct Tile {
pub ti_key: text,
pub ti_w: integer,
pub ti_h: integer,
pub ti_px: vector<u8>,
/// Where the stage places the sprite BY, in cell-local texels — the feet of a
/// character rather than its top-left corner.
pub ti_ox: float = 0.0,
pub ti_oy: float = 0.0,
}
/// `n` zero bytes.
///
/// Built by DOUBLING: a page buffer is megabytes, and appending one element at a
/// time costs three bytecodes each, while appending a vector to itself is one
/// block copy.
fn zero_bytes(n: integer) -> vector<u8> {
zb: vector<u8> = [];
if n <= 0 { return zb }
zb += [0];
while len(zb) < n { zb += zb; }
if len(zb) == n { return zb }
zt = zb[0..n];
zt
}
/// The smallest square-ish column count for `n` tiles.
fn grid_cols(n: integer) -> integer {
gc = 1;
while gc * gc < n { gc += 1; }
gc
}
/// Lay tiles out in a grid page, blit their texels into it, and record one `Cell`
/// per tile — with each cell's collision proxy DERIVED from the alpha as it lands.
///
/// The page is sized from the widest and tallest tile, so every cell shares one
/// grid step; `pad` puts transparent texels between them, which is what a sampler
/// that filters needs to stop a neighbour bleeding in. Answers false — writing
/// nothing — when a tile's byte count disagrees with the size it declares, because
/// a packer that quietly places a truncated sprite is the failure this exists to
/// prevent.
///
/// The pixels land under `blob` as `Bytes`: STRAIGHT alpha, not premultiplied,
/// because that is what a blend of SRC_ALPHA / ONE_MINUS_SRC_ALPHA expects.
/// `BlobKind::Rgba` promises premultiplied texels and these are not.
pub fn page_grid(p: Pack, blobs: &hash<Blob[bl_key]>, page: text, blob: text,
tiles: const vector<Tile>, cols: integer = 0,
pad: integer = 0) -> boolean {
if len(tiles) == 0 { return false }
for t in tiles {
if t.ti_w <= 0 || t.ti_h <= 0 { return false }
if len(t.ti_px) != t.ti_w * t.ti_h * 4 { return false }
}
pg_cols = if cols > 0 { cols } else { grid_cols(len(tiles)) };
pg_rows = ((len(tiles) + pg_cols - 1) / pg_cols)?;
pg_cw = 0;
pg_ch = 0;
for t in tiles {
if t.ti_w > pg_cw { pg_cw = t.ti_w; }
if t.ti_h > pg_ch { pg_ch = t.ti_h; }
}
pg_w = pg_cols * (pg_cw + pad) - pad;
pg_h = pg_rows * (pg_ch + pad) - pad;
pg_px = zero_bytes(pg_w * pg_h * 4);
pg_info = PageInfo { pi_name: page, pi_w: pg_w, pi_h: pg_h, pi_blob: blob };
for i in 0..len(tiles) {
pg_t = tiles[i]?;
pg_x = ((i % pg_cols)?) * (pg_cw + pad);
pg_y = ((i / pg_cols)?) * (pg_ch + pad);
for y in 0..pg_t.ti_h {
pg_src = y * pg_t.ti_w * 4;
pg_dst = ((pg_y + y) * pg_w + pg_x) * 4;
for k in 0..(pg_t.ti_w * 4) { pg_px[pg_dst + k] = pg_t.ti_px[pg_src + k]?; }
}
pg_c = Cell { ce_key: pg_t.ti_key, ce_page: page, ce_x: pg_x, ce_y: pg_y,
ce_w: pg_t.ti_w, ce_h: pg_t.ti_h,
ce_ox: pg_t.ti_ox, ce_oy: pg_t.ti_oy, ce_proxy: [] };
pg_c.ce_proxy = cell_proxy(pg_c, pg_info, pg_px);
p.cells += [pg_c];
}
p.pages += [pg_info];
blob_put(blobs, blob, BlobKind.Bytes, pg_px);
true
}
// --- Checking the layout before anything reads a pack ---
/// The pack's own types, field by field, as the store records them.
///
/// Read this when the fingerprints below disagree: it names the type and the field
/// whose position moved, which the single number cannot. Derived by reflection from
/// the schema itself, so it cannot drift from it.
pub fn layout_descriptor() -> text {
d = "";
for n in ["Blob", "PageInfo", "Cell", "Seq", "Def", "Anim", "Scene", "Placed", "Pack"] {
t = type_named(n);
if t == null { d += "{n} MISSING\n"; continue }
d += "{t.name} size={t.size}\n";
for f in t.fields {
d += " {f.name} {f.type_name} @{f.position} {f.kind} null={f.nullable}\n";
}
}
d
}
/// One number naming this build's pack layout — compare it across targets before a
/// pack is read.
///
/// The struct definition IS the file layout, so a field that sits four bytes further
/// along on one target reads a NEIGHBOUR's value on the other, silently, and the file
/// looks corrupt rather than the layout looking wrong. A game that reads packs built
/// elsewhere should compare this once at boot and refuse rather than draw garbage.
/// (A 32-bit FNV-1a over `layout_descriptor`, masked at every step because an
/// unbounded multiply would reach loft's null.)
pub fn layout_fingerprint() -> integer {
h = 2166136261;
for c in layout_descriptor() {
h = (h ^ (c as integer)) & 4294967295;
h = (h * 16777619) & 4294967295;
}
h
}
tests/layout.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// The pack's layout IS its file format, so this suite is the format's regression
// test: the struct definitions decide where every byte of a written pack lands, and
// a field that moves reads a neighbour's value out of packs already in the wild.
use assets::*;
/// What the layout of `assets` 0.5.0 hashes to.
///
/// A CHANGE HERE IS A FORMAT CHANGE. Packs written by an earlier release are read at
/// the offsets this number stands for, so if this test fails, one of two things
/// happened and each has its own answer:
///
/// * the schema was edited — every existing pack now reads garbage at some field,
/// so `data_compatible_with` in `loft.toml` moves to this release and the
/// CHANGELOG says so;
/// * loft's own layout rules moved under us — the same breakage, arriving without
/// an edit, which is exactly why this is pinned rather than merely computed.
///
/// 0.1.0 hashed to 21440911. It moved at 0.2.0 because `Cell` gained `ce_proxy`
/// (@PLN146 F7) — the FIRST answer above, an edit we made, so `data_compatible_with`
/// moved with it. 0.2.0 to 0.3.1 hashed to 2433656147. It moved at 0.4.0 with no
/// edit here — the SECOND answer: loft's C138 rounds a record's size up to its
/// alignment (`(L-Align)`), so `Blob` went 9 -> 12, `Cell` 60 -> 64, `Def` 73 -> 80,
/// `Scene` 20 -> 24 and `Placed` 98 -> 104 bytes, every field at the position it had.
/// A collection's element stride is its record's size, so a 0.3.x pack is refused by
/// a loft with C138 (it names those five types) rather than misread. 0.4.0 to 0.4.2
/// hashed to 3641078007. It moved at 0.5.0, again with no edit here: loft's C147 makes
/// an `index` a B+tree kept in an arena of its own, so `Placed`, the element of the
/// pack's `placed` index, no longer carries the red-black tree's links (`#left_1` at
/// 88, `#right_1` at 92, `#color_1` at 96). `pd_visible` moved from 97 to 88 and
/// `Placed` went 104 -> 96 bytes; no other record changed. A 0.4.x pack is refused by
/// a loft with C147 (it names `Placed` and its index) rather than misread: `pack_read`
/// answers false. The old numbers stay written down here: a pin nobody can compare
/// against does not say a format changed, only that one is different.
///
/// Measured identical on the interpreter and `--native`.
LAYOUT_0_5_0 = 755486205;
fn test_the_layout_of_this_release_is_the_one_it_promises() {
assert(layout_fingerprint() == LAYOUT_0_5_0,
"the pack layout changed: {layout_fingerprint()} (was {LAYOUT_0_5_0}) — read the note on LAYOUT_0_5_0, and layout_descriptor() for the field that moved");
}
fn test_the_fingerprint_is_taken_over_real_content() {
// A hash of nothing is a stable number too, so the pin above would survive a
// descriptor that had gone empty. 2166136261 is FNV-1a's offset basis: the answer
// for empty input, and what this must not be.
assert(layout_fingerprint() != 2166136261, "the fingerprint consumed something");
assert(size(layout_descriptor()) > 1000, "the descriptor is a description");
}
fn test_every_pack_type_resolves() {
d = layout_descriptor();
// Reflection answering MISSING means a type was renamed out from under the
// descriptor's own list — the fingerprint would change, but not say why.
assert(!d.contains("MISSING"), "every pack type resolved:\n{d}");
for t in ["Blob", "PageInfo", "Cell", "Seq", "Def", "Anim", "Scene", "Placed", "Pack"] {
assert(d.contains("{t} size="), "{t} is described");
}
}
fn test_the_descriptor_carries_positions_not_declaration_order() {
// The load-bearing property: a field's POSITION is what a stored pack is read at,
// and it is not the order the field was written in. `ce_key` is declared first
// and sits at 48, after the six integers and two floats — so a descriptor that
// reported declaration order (or all zeroes) would hash to a stable number and
// gate nothing.
assert(layout_descriptor().contains("ce_key text @48"),
"a text field placed after the fixed-width ones:\n{layout_descriptor()}");
}
tests/pack.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// The pack round-trip: what is written comes back, byte for byte, and a key can be
// read out of the bulk half without reading the rest of it.
use assets::*;
/// A deterministic byte pattern. A run of zeroes would pass a loader that returned
/// nothing, so every fixture byte depends on its own index.
fn pattern(n: integer, salt: integer) -> vector<u8> {
out: vector<u8> = [];
for i in 0..n { out += [((i * 37 + salt * 11 + i / 251) % 251) as u8 ?? 0]; }
out
}
fn same_bytes(a: const vector<u8>, b: const vector<u8>) -> boolean {
if len(a) != len(b) { return false }
for i in 0..len(a) { if a[i] != b[i] { return false } }
true
}
/// A pack with both halves populated: two texture pages, a sprite cut out of each,
/// and an audio blob that is not a page at all.
fn fixture(p: Pack, b: &hash<Blob[bl_key]>) {
p.pages += [PageInfo { pi_name: "mobs", pi_w: 64, pi_h: 32, pi_blob: "page/mobs" }];
p.pages += [PageInfo { pi_name: "items", pi_w: 32, pi_h: 32, pi_blob: "page/items" }];
p.cells += [Cell { ce_key: "goblin", ce_page: "mobs", ce_x: 0, ce_y: 0,
ce_w: 16, ce_h: 16, ce_ox: 8.0, ce_oy: 16.0 }];
p.cells += [Cell { ce_key: "potion", ce_page: "items", ce_x: 0, ce_y: 0,
ce_w: 16, ce_h: 16 }];
blob_put(b, "page/mobs", Rgba, pattern(64 * 32 * 4, 1));
blob_put(b, "page/items", Rgba, pattern(32 * 32 * 4, 2));
blob_put(b, "hit.ogg", Ogg, pattern(2048, 3));
}
/// A base path nothing else in this suite writes to.
fn base_for(name: text) -> text {
d = temp_dir() ?? ".";
"{d}/loft_assets_test_{name}"
}
fn clean(base: text) {
for f in [meta_path(base), blobs_path(base),
"{meta_path(base)}.dschema", "{blobs_path(base)}.dschema"] {
if file(f).exists() { _ = delete(f); }
}
}
fn test_a_written_pack_reads_back_identical() {
base = base_for("roundtrip");
clean(base);
p = new_pack();
b: hash<Blob[bl_key]> = [];
fixture(p, b);
assert(pack_write(p, b, base), "both halves written");
q = new_pack();
assert(pack_read(q, base), "metadata read back");
page = q.pages["mobs"];
assert(page != null, "page mobs present");
assert(page.pi_w == 64 && page.pi_h == 32, "page shape");
cell = q.cells["goblin"];
assert(cell != null, "cell goblin present");
assert(cell.ce_oy == 16.0, "the origin came back, not its default");
qb: hash<Blob[bl_key]> = [];
assert(store_load(qb, blobs_path(base)), "bulk half read back");
out = qb["page/mobs"];
src = b["page/mobs"];
assert(out != null and src != null, "page pixels present");
// A keyed read answers `Blob?`, so the bytes are `vector<u8>?`. Every `?? []` in
// this file sits behind a non-null assert on the line above it: the default is what
// an already-failed test would have read, never a comparison of two empty vectors.
assert(same_bytes(out.bl_bytes ?? [], src.bl_bytes ?? []), "pixels byte-identical");
assert(out.bl_kind is Rgba, "the kind came back too");
// Length alone cannot witness the bytes: two blobs of the same size differ here.
items = qb["page/items"];
clip = qb["hit.ogg"];
assert(items != null and clip != null, "both fixture blobs are resident");
assert(!same_bytes(items.bl_bytes ?? [], clip.bl_bytes ?? []),
"the fixture's blobs are distinguishable at all");
clean(base);
}
fn test_one_key_reads_without_the_rest_of_the_pack() {
base = base_for("paged");
clean(base);
p = new_pack();
b: hash<Blob[bl_key]> = [];
fixture(p, b);
assert(pack_write(p, b, base), "written");
// The whole point of the bulk half: a reader starts EMPTY and ends holding only
// what it asked for. A loader that pulled the file would pass a value check and
// fail this one.
res: hash<Blob[bl_key]> = [];
got = prefetch(res, blobs_path(base), ["hit.ogg"]);
assert(got == 1, "the fetch cost one round trip");
clip = res["hit.ogg"];
assert(clip != null && len(clip.bl_bytes ?? []) == 2048, "the clip arrived whole");
n = 0;
for _r in res { n += 1; }
assert(n == 1, "only the requested key is resident, not the two pages");
// Asking again costs nothing — which is what lets a frame assert zero.
assert(prefetch(res, blobs_path(base), ["hit.ogg"]) == 0, "a resident key is free");
clean(base);
}
fn test_a_key_the_pack_does_not_have_answers_false() {
base = base_for("absent");
clean(base);
p = new_pack();
b: hash<Blob[bl_key]> = [];
fixture(p, b);
assert(pack_write(p, b, base), "written");
// A paged reader that cannot say "absent" is one a caller has to guess about.
res: hash<Blob[bl_key]> = [];
assert(prefetch(res, blobs_path(base), ["no-such-blob"]) == 0, "nothing was fetched");
assert(res["no-such-blob"] == null, "and no empty record was invented");
clean(base);
}
fn test_one_base_drives_both_halves_as_a_url() {
base = base_for("url");
clean(base);
p = new_pack();
b: hash<Blob[bl_key]> = [];
fixture(p, b);
assert(pack_write(p, b, base), "written");
// The plan's whole premise: develop against a file, ship against a CDN, with ONE
// string changed. `file://` is the scheme this suite can drive without a server,
// and it takes the same branch an `http(s)://` base does.
//
// ⚠ The two halves do NOT accept the same spellings underneath: the metadata half
// reads a `file://` URL and the paged half REFUSES it (a path or an http URL, and
// nothing else). A test that only read the metadata would pass while a game's
// art silently loaded nothing.
url = "file://{base}";
q = new_pack();
assert(pack_read(q, url), "the metadata half read through a URL");
assert(q.pages["mobs"] != null, "and carries the same pages");
res: hash<Blob[bl_key]> = [];
got = prefetch(res, blobs_path(url), ["hit.ogg"]);
assert(got == 1, "the bulk half fetched through the SAME base, got {got}");
clip = res["hit.ogg"];
fix = b["hit.ogg"];
assert(clip != null and fix != null, "a record, not a refusal");
assert(same_bytes(clip.bl_bytes ?? [], fix.bl_bytes ?? []), "the same bytes as the path read");
clean(base);
}
fn test_the_two_halves_are_separate_files() {
base = base_for("split");
clean(base);
p = new_pack();
b: hash<Blob[bl_key]> = [];
fixture(p, b);
assert(pack_write(p, b, base), "written");
// The split is the design, and it is observable: the metadata a boot load pays
// for is a different file from the art, so it can stay small beside it.
assert(file(meta_path(base)).exists(), "metadata half on disk");
assert(file(blobs_path(base)).exists(), "bulk half on disk");
assert(meta_path(base) != blobs_path(base), "two names, not one");
clean(base);
}
// A second put under one key replaces the first: one blob, the newer bytes.
fn test_a_blob_put_twice_under_one_key_is_one_blob() {
b: hash<Blob[bl_key]> = [];
blob_put(b, "page/a", Bytes, [1 as u8]);
blob_put(b, "page/a", Png, [2 as u8, 3 as u8]);
blob_put(b, "page/b", Bytes, [9 as u8]);
n = 0;
for _x in b { n += 1; }
assert(len(b) == 2 && n == 2, "two keys, two blobs: len {len(b)}, walk {n}");
got = b["page/a"].bl_bytes ?? [];
assert(len(got) == 2 && b["page/a"].bl_kind == Png, "the newer blob");
}
tests/page.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Building a page out of sprites: where each cell lands, what the texels between
// them are, and what happens to a tile that lies about its size.
//
// Every expected value here is hand-computed from the grid arithmetic rather than
// read off a run — a placer that agrees with itself is exactly the failure a
// self-comparison cannot see.
use assets::*;
/// `w * h` texels of one colour, as straight-alpha RGBA bytes.
fn solid(w: integer, h: integer, r: integer, g: integer, b: integer, a: integer) -> vector<u8> {
out: vector<u8> = [];
for _ in 0..(w * h) { out += [(r & 255) as u8, (g & 255) as u8, (b & 255) as u8, (a & 255) as u8]; }
out
}
fn byte_at(px: const vector<u8>, w: integer, x: integer, y: integer, c: integer) -> integer {
px[(y * w + x) * 4 + c]?
}
fn test_a_word_becomes_four_bytes_in_rgba_order() {
// 0xAARRGGBB in, R G B A out — the order a texture page holds and the order a
// decoded PNG arrives in.
got = texels([0x804090C0, 0xFF000000]);
assert(len(got) == 8, "two words, eight bytes, got {len(got)}");
assert(got[0]? == 0x40 && got[1]? == 0x90 && got[2]? == 0xC0 && got[3]? == 0x80,
"R G B A, got {got[0]?} {got[1]?} {got[2]?} {got[3]?}");
assert(got[7]? == 255, "an opaque black stays opaque");
}
fn test_two_tiles_share_one_page() {
p = new_pack();
blobs: hash<Blob[bl_key]> = [];
ok = page_grid(p, blobs, "atlas", "page/atlas",
[Tile { ti_key: "a", ti_w: 2, ti_h: 2, ti_px: solid(2, 2, 200, 10, 10, 255) },
Tile { ti_key: "b", ti_w: 2, ti_h: 2, ti_px: solid(2, 2, 10, 10, 200, 255) }], 2);
assert(ok, "two well-formed tiles are placed");
info = p.pages["atlas"];
assert(info.pi_w == 4 && info.pi_h == 2, "two 2x2 cells side by side: 4x2, got {info.pi_w}x{info.pi_h}");
assert(p.cells["a"].ce_x == 0 && p.cells["a"].ce_y == 0, "the first cell is at the origin");
assert(p.cells["b"].ce_x == 2 && p.cells["b"].ce_y == 0, "the second is one grid step along");
px = blobs["page/atlas"].bl_bytes?;
assert(len(px) == 4 * 2 * 4, "the page is width*height*4 bytes, got {len(px)}");
assert(byte_at(px, 4, 0, 1, 0) == 200, "a's second ROW is a's, not b's");
assert(byte_at(px, 4, 2, 1, 2) == 200, "and b's second row is b's — the blit is row-major");
}
fn test_padding_puts_transparent_texels_between_cells() {
p = new_pack();
blobs: hash<Blob[bl_key]> = [];
_ = page_grid(p, blobs, "atlas", "page/atlas",
[Tile { ti_key: "a", ti_w: 2, ti_h: 2, ti_px: solid(2, 2, 200, 10, 10, 255) },
Tile { ti_key: "b", ti_w: 2, ti_h: 2, ti_px: solid(2, 2, 10, 10, 200, 255) }], 2, 1);
info = p.pages["atlas"];
assert(info.pi_w == 5, "2*(2+1)-1 = 5 — the gutter is BETWEEN cells, not after the last");
assert(p.cells["b"].ce_x == 3, "b starts past the gutter, got {p.cells[\"b\"].ce_x}");
px = blobs["page/atlas"].bl_bytes?;
assert(byte_at(px, 5, 2, 0, 3) == 0, "the gutter column is transparent");
}
fn test_a_smaller_tile_keeps_its_own_rect() {
p = new_pack();
blobs: hash<Blob[bl_key]> = [];
_ = page_grid(p, blobs, "atlas", "page/atlas",
[Tile { ti_key: "big", ti_w: 2, ti_h: 2, ti_px: solid(2, 2, 200, 10, 10, 255) },
Tile { ti_key: "small", ti_w: 1, ti_h: 1, ti_px: solid(1, 1, 10, 200, 10, 255) }], 2);
info = p.pages["atlas"];
assert(info.pi_w == 4 && info.pi_h == 2, "the grid step is the WIDEST tile, got {info.pi_w}x{info.pi_h}");
small = p.cells["small"];
assert(small.ce_x == 2 && small.ce_w == 1 && small.ce_h == 1,
"the cell is the sprite's own size, not the step's");
px = blobs["page/atlas"].bl_bytes?;
assert(byte_at(px, 4, 2, 0, 1) == 200, "the small tile landed");
assert(byte_at(px, 4, 3, 0, 3) == 0, "and the rest of its cell stayed transparent");
}
fn test_the_grid_squares_itself_when_no_column_count_is_given() {
// Three tiles: the smallest square-ish grid is 2 wide, so the third wraps.
p = new_pack();
blobs: hash<Blob[bl_key]> = [];
_ = page_grid(p, blobs, "atlas", "page/atlas",
[Tile { ti_key: "a", ti_w: 2, ti_h: 2, ti_px: solid(2, 2, 200, 10, 10, 255) },
Tile { ti_key: "b", ti_w: 2, ti_h: 2, ti_px: solid(2, 2, 10, 200, 10, 255) },
Tile { ti_key: "c", ti_w: 2, ti_h: 2, ti_px: solid(2, 2, 10, 10, 200, 255) }]);
info = p.pages["atlas"];
assert(info.pi_w == 4 && info.pi_h == 4, "2x2 cells of 2x2: 4x4, got {info.pi_w}x{info.pi_h}");
assert(p.cells["c"].ce_x == 0 && p.cells["c"].ce_y == 2, "the third tile wraps to the next row");
px = blobs["page/atlas"].bl_bytes?;
assert(byte_at(px, 4, 0, 2, 2) == 200, "and its texels went with it");
assert(byte_at(px, 4, 2, 2, 3) == 0, "the unused fourth cell stays transparent");
}
fn test_a_tile_that_lies_about_its_size_is_refused() {
p = new_pack();
blobs: hash<Blob[bl_key]> = [];
ok = page_grid(p, blobs, "atlas", "page/atlas",
[Tile { ti_key: "t", ti_w: 2, ti_h: 2, ti_px: [1, 2, 3] }], 1);
assert(!ok, "a truncated tile is refused");
assert(len(p.pages) == 0 && len(p.cells) == 0, "and nothing is written");
assert(page_grid(p, blobs, "atlas", "page/atlas", [], 1) == false, "so is an empty list");
}
fn test_a_proxy_is_derived_as_the_cell_lands() {
p = new_pack();
blobs: hash<Blob[bl_key]> = [];
_ = page_grid(p, blobs, "atlas", "page/atlas",
[Tile { ti_key: "solid", ti_w: 4, ti_h: 4, ti_px: solid(4, 4, 200, 10, 10, 255) },
Tile { ti_key: "clear", ti_w: 4, ti_h: 4, ti_px: solid(4, 4, 0, 0, 0, 0) }], 2);
assert(p.cells["solid"] != null && p.cells["clear"] != null, "both tiles have a cell");
solid_c = p.cells["solid"];
clear_c = p.cells["clear"];
assert(len(solid_c.ce_proxy?) > 0, "opaque art derives a proxy with no hand edit");
area = 0.0;
for r in solid_c.ce_proxy? { area += r.rw * r.rh; }
assert(area == 16.0, "a full 4x4 square is covered exactly once, got {area}");
assert(len(clear_c.ce_proxy?) == 0,
"a transparent cell derives NOTHING — a box around it would collide with the air");
}
tests/proxy.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN146 F7 — the collision proxy derived from a sprite's own alpha.
//
// The contract has two halves and they pull against each other: the proxy must
// CONTAIN every opaque texel (so substituting it for the art can over-report a
// hit but never miss one), and its OVERSHOOT must be bounded (or a screen-sized
// box would satisfy containment perfectly and be useless). Every test below is
// one of those two.
use assets::*;
use shapes::*;
// A blank RGBA page, `w * h` texels, every one fully transparent.
fn blank_page(bw: integer, bh: integer) -> vector<u8> {
bp: vector<u8> = [];
// `0 as u8` and not `0 as u8?`: a checked cast is required only where the value
// could overflow the target width at run time, and a literal 0 cannot. The
// nullable form would put an `u8?` into a `vector<u8>`, where it becomes null.
for _ in 0..(bw * bh * 4) { bp += [0 as u8]; }
bp
}
// Paint one texel opaque (or to a given alpha). The alpha is a `u8`, so its range is
// the parameter's type and not a cast at the store: `pu_a as u8?` put a nullable into a
// `vector<u8>`, where an alpha that did not fit became 0 in silence (loft#1660).
fn put(pu_px: &vector<u8>, pu_stride: integer, pu_x: integer, pu_y: integer,
pu_a: u8) {
pu_i = (pu_y * pu_stride + pu_x) * 4;
pu_px[pu_i] = 200 as u8;
pu_px[pu_i + 1] = 100 as u8;
pu_px[pu_i + 2] = 50 as u8;
pu_px[pu_i + 3] = pu_a;
}
fn a_cell(ac_w: integer, ac_h: integer) -> assets::Cell {
assets::Cell{ce_key: "c", ce_page: "p", ce_x: 0, ce_y: 0, ce_w: ac_w, ce_h: ac_h}
}
fn a_page(ap_w: integer, ap_h: integer) -> assets::PageInfo {
assets::PageInfo{pi_name: "p", pi_w: ap_w, pi_h: ap_h, pi_blob: "b"}
}
// Is (x, y) inside any box? The containment check, written here rather than
// reused, so the test does not depend on the predicate it is checking.
fn covered(cv: const vector<shapes::Rect>, cx: integer, cy: integer) -> boolean {
for r in cv {
if (cx as float) >= r.rx && (cx as float) < r.rx + r.rw
&& (cy as float) >= r.ry && (cy as float) < r.ry + r.rh { return true }
}
false
}
// CONTAINMENT is the half that makes substitution safe: a system validated
// against the proxy stays valid when the art changes, because the proxy can only
// ever be bigger than the art. Checked texel by texel on a diagonal — the shape
// a single box is worst at.
fn test_every_opaque_texel_is_inside_the_proxy() {
px = blank_page(32, 32);
for i in 0..32 { put(px, 32, i, i, 255); }
page = a_page(32, 32);
cell = a_cell(32, 32);
boxes = assets::cell_proxy(cell, page, px);
assert(len(boxes) > 1, "a diagonal derives more than one band");
for i in 0..32 {
assert(covered(boxes, i, i), "texel ({i},{i}) is inside the proxy");
}
}
// And the BOUND is what stops containment being satisfied by a screen-sized box.
// A 32x32 diagonal is 32 opaque texels; its single tight box is 1024, which is
// +3100 %. The banded proxy has to come in far under that, and under F7's +100 %.
fn test_the_proxy_is_bounded_where_a_single_box_is_not() {
px = blank_page(32, 32);
for i in 0..32 { put(px, 32, i, i, 255); }
page = a_page(32, 32);
cell = a_cell(32, 32);
solid = assets::opaque_texels(px, page, cell);
assert(solid == 32, "32 opaque texels");
boxes = assets::cell_proxy(cell, page, px);
over = assets::proxy_overshoot(boxes, solid);
assert(over <= 1.0, "the derived proxy is inside F7's +100% bound, at {over}");
// The single box it replaces is not, by a wide margin — that is the gain.
one: vector<shapes::Rect> = [shapes::Rect{rx: 0.0, ry: 0.0, rw: 32.0, rh: 32.0}];
assert(assets::proxy_overshoot(one, solid) > 30.0, "one box is +3100%");
}
// A cell with nothing opaque derives NOTHING, and an empty proxy hits nothing.
// A single box around an empty cell would collide with whatever stood there.
fn test_a_transparent_cell_derives_an_empty_proxy() {
px = blank_page(16, 16);
page = a_page(16, 16);
cell = a_cell(16, 16);
assert(assets::opaque_texels(px, page, cell) == 0, "nothing opaque");
boxes = assets::cell_proxy(cell, page, px);
assert(len(boxes) == 0, "and nothing derived");
p = shapes::Proxy{boxes: boxes};
assert(!p.proxy_hits(shapes::Rect{rx: 0.0, ry: 0.0, rw: 16.0, rh: 16.0}),
"an empty proxy hits nothing");
assert(assets::proxy_overshoot(boxes, 0) == 0.0, "and no art to be a multiple of");
}
// The alpha threshold is a threshold, not `== 255`: antialiased art has a fringe
// of partly-transparent texels that belong to the silhouette. At or below
// PROXY_ALPHA_ON a texel is not art.
fn test_the_alpha_cutoff_decides_what_is_art() {
px = blank_page(8, 8);
put(px, 8, 1, 1, 255); // solid
put(px, 8, 6, 6, 4); // below the cutoff — a stray almost-clear texel
page = a_page(8, 8);
cell = a_cell(8, 8);
assert(assets::opaque_texels(px, page, cell) == 1, "only the solid one counts");
boxes = assets::cell_proxy(cell, page, px);
assert(covered(boxes, 1, 1), "the solid texel is in");
assert(!covered(boxes, 6, 6), "the near-transparent one is not");
}
// The axis is chosen PER SPRITE, not fixed. A wide, short bar bands badly by row
// (one band holds the whole bar) and well by column, so the derivation must run
// both and keep the smaller — 17 of 36 corpus sprites are tighter by column.
fn test_the_tighter_axis_is_chosen_per_sprite() {
// A horizontal staircase: wide, only a few rows tall, with big empty corners.
px = blank_page(32, 32);
for i in 0..32 { put(px, 32, i, 10 + (i / 8), 255); }
page = a_page(32, 32);
cell = a_cell(32, 32);
solid = assets::opaque_texels(px, page, cell);
boxes = assets::cell_proxy(cell, page, px);
for i in 0..32 { assert(covered(boxes, i, 10 + (i / 8)), "contains texel {i}"); }
assert(assets::proxy_overshoot(boxes, solid) <= 1.0,
"the chosen axis keeps it inside the bound");
}
tests/scene.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// What a pack can be ASKED: which sequence an action maps to, and which blobs a
// scene is about to draw from.
use assets::*;
/// A room with two goblins drawn from one page, a potion from another, and a light
/// that draws nothing — so every clause of `keys_near` has something to act on.
fn fixture(p: Pack) {
p.pages += [PageInfo { pi_name: "mobs", pi_w: 64, pi_h: 32, pi_blob: "page/mobs" }];
p.pages += [PageInfo { pi_name: "items", pi_w: 32, pi_h: 32, pi_blob: "page/items" }];
p.cells += [Cell { ce_key: "goblin_0", ce_page: "mobs", ce_x: 0, ce_y: 0,
ce_w: 16, ce_h: 16 }];
p.cells += [Cell { ce_key: "potion", ce_page: "items", ce_x: 0, ce_y: 0,
ce_w: 16, ce_h: 16 }];
p.seqs += [Seq { sq_key: "goblin_walk_s", sq_page: "mobs", sq_first: 0,
sq_count: 4, sq_fps: 8.0, sq_cols: 4, sq_rows: 2 }];
p.defs += [Def { df_name: "goblin", df_kind: Sprite, df_cell: "goblin_0",
df_w: 16.0, df_h: 16.0, df_layer: 1 }];
p.defs += [Def { df_name: "potion", df_kind: Sprite, df_cell: "potion",
df_w: 16.0, df_h: 16.0, df_layer: 1 }];
p.defs += [Def { df_name: "torch", df_kind: Light, df_radius: 96.0,
df_colour: 16755200, df_power: 1.25 }];
p.anims += [Anim { an_key: "goblin/walk/0", an_def: "goblin", an_action: "walk",
an_facing: 0, an_seq: "goblin_walk_s" }];
p.anims += [Anim { an_key: "goblin/walk/2", an_def: "goblin", an_action: "walk",
an_facing: 2, an_seq: "goblin_walk_n" }];
p.scenes += [Scene { sc_name: "cave", sc_w: 640, sc_h: 360 }];
p.placed += [Placed { pd_scene: "cave", pd_order: 0, pd_def: "goblin",
pd_x: 0.0, pd_y: 0.0 }];
p.placed += [Placed { pd_scene: "cave", pd_order: 1, pd_def: "goblin",
pd_x: 10.0, pd_y: 0.0 }];
p.placed += [Placed { pd_scene: "cave", pd_order: 2, pd_def: "potion",
pd_x: 500.0, pd_y: 0.0 }];
p.placed += [Placed { pd_scene: "cave", pd_order: 3, pd_def: "torch",
pd_x: 5.0, pd_y: 0.0 }];
p.scenes += [Scene { sc_name: "hall", sc_w: 320, sc_h: 200 }];
p.placed += [Placed { pd_scene: "hall", pd_order: 0, pd_def: "potion",
pd_x: 0.0, pd_y: 0.0 }];
}
fn test_an_action_and_a_facing_name_a_sequence() {
p = new_pack();
fixture(p);
assert(anim_of(p, "goblin", "walk", 0) == "goblin_walk_s", "facing 0");
assert(anim_of(p, "goblin", "walk", 2) == "goblin_walk_n", "facing 2");
// Absent answers EMPTY, not a plausible wrong sequence — a game that draws the
// south walk for a swim it does not have looks correct and is not.
assert(anim_of(p, "goblin", "swim", 0) == "", "an action it does not have");
assert(anim_of(p, "goblin", "walk", 3) == "", "a facing it does not have");
assert(anim_of(p, "nobody", "walk", 0) == "", "a definition it does not have");
}
fn test_a_scene_names_the_blobs_it_draws_from() {
p = new_pack();
fixture(p);
all = keys_near(p, "cave", 0.0, 0.0, -1.0);
// Two pages, not four instances: a page shared by two goblins is one fetch, and
// the light draws nothing at all.
assert(len(all) == 2, "the whole scene is two pages, got {len(all)}");
assert(all[0] == "page/mobs" && all[1] == "page/items", "in placement order");
// A radius takes only what is within it. The potion is 500 away.
near = keys_near(p, "cave", 0.0, 0.0, 100.0);
assert(len(near) == 1 && near[0] == "page/mobs", "only the near page");
// A scene nobody placed anything in wants nothing, and an unknown scene is not
// an error — it is a scene with no instances.
assert(len(keys_near(p, "hall", 0.0, 0.0, -1.0)) == 1, "the other room");
assert(len(keys_near(p, "nowhere", 0.0, 0.0, -1.0)) == 0, "an unknown scene");
}
fn test_a_light_is_placed_but_never_fetched() {
p = new_pack();
fixture(p);
// Centred ON the light, with a radius that also reaches both goblins (they stand
// 5 away): the light is the NEAREST instance in the answer's window, so its
// absence is about what it IS and not about where it is.
torch = p.defs["torch"];
assert(torch != null && torch.df_kind is Light, "the fixture placed a light");
assert(torch.df_radius == 96.0, "with its own reach");
keys = keys_near(p, "cave", 5.0, 0.0, 6.0);
assert(len(keys) == 1, "the light contributed no blob, got {len(keys)}");
assert(keys[0] == "page/mobs", "and the goblins beside it still did");
}
tests/worked-examples.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Worked examples for `assets` (@PLN141): each `test_*` is a running demonstration of a
// contract the signatures cannot state, cited from the function it explains with
// `// Example: @PAK-00N`.
use assets::*;
fn base_for(name: text) -> text { "{temp_dir() ?? "."}/loft_assets_example_{name}" }
fn clean(base: text) {
for f in [meta_path(base), blobs_path(base), "{meta_path(base)}.dschema", "{blobs_path(base)}.dschema"] {
if file(f).exists() { _ = delete(f); }
}
}
fn small_pack(p: Pack, blobs: &hash<Blob[bl_key]>) {
p.pages += [PageInfo { pi_name: "mobs", pi_w: 4, pi_h: 2, pi_blob: "page/mobs" }];
p.cells += [Cell { ce_key: "goblin", ce_page: "mobs", ce_x: 0, ce_y: 0, ce_w: 2, ce_h: 2 }];
p.defs += [Def { df_name: "goblin", df_cell: "goblin" }];
p.scenes += [Scene { sc_name: "hall", sc_w: 320, sc_h: 200 }];
p.placed += [Placed { pd_scene: "hall", pd_order: 0, pd_def: "goblin", pd_x: 10.0, pd_y: 10.0 }];
p.placed += [Placed { pd_scene: "hall", pd_order: 1, pd_def: "goblin", pd_x: 300.0, pd_y: 150.0 }];
pixels: vector<u8> = [];
for i in 0..4 * 2 * 4 { pixels += [(i * 7 % 251) as u8 ?? 0]; }
blob_put(blobs, "page/mobs", Rgba, pixels);
}
// @PAK-001 — a pack is TWO files read two ways. `pack_read` reads the metadata whole and
// brings back a page's shape without one texel; the bytes come out of the other file one key
// at a time, through `prefetch`. A loader that looks for pixels in the metadata finds names.
fn test_a_pack_is_two_files_read_two_ways() {
base = base_for("halves");
clean(base);
p = new_pack();
blobs: hash<Blob[bl_key]> = [];
small_pack(p, blobs);
assert(pack_write(p, blobs, base), "written");
q = new_pack();
assert(pack_read(q, base), "the metadata, whole");
assert(q.pages["mobs"].pi_w == 4 && q.pages["mobs"].pi_blob == "page/mobs", "the page's shape, and the KEY of its bytes");
res: hash<Blob[bl_key]> = [];
assert(prefetch(res, blobs_path(base), ["page/mobs"]) == 1, "the bytes, by key");
bytes = res["page/mobs"].bl_bytes ?? [];
assert(len(bytes) == 32, "4 x 2 texels x 4 bytes");
clean(base);
}
// @PAK-002 — ask at a boundary, and a frame can assert zero. `keys_near` answers each page
// once however many instances share it; `prefetch` answers the round trips it paid, and skips
// what is resident, so the second call answers 0.
fn test_prefetch_answers_the_round_trips_it_paid() {
base = base_for("boundary");
clean(base);
p = new_pack();
blobs: hash<Blob[bl_key]> = [];
small_pack(p, blobs);
assert(pack_write(p, blobs, base), "written");
q = new_pack();
assert(pack_read(q, base), "read");
keys = keys_near(q, "hall", 0.0, 0.0, -1.0);
assert(len(keys) == 1, "two instances, one shared page, one key");
res: hash<Blob[bl_key]> = [];
assert(prefetch(res, blobs_path(base), keys) == 1, "the boundary pays one fetch");
assert(prefetch(res, blobs_path(base), keys) == 0, "a frame afterwards pays none");
clean(base);
}
// @PAK-003 — an animation is a row of data, and a missing one answers EMPTY. `anim_of` reads
// the `"{def}/{action}/{facing}"` row; an action the definition lacks answers "" rather than a
// plausible wrong sequence, so a game can tell "no such animation" from "this one".
fn test_a_missing_animation_answers_empty() {
p = new_pack();
p.anims += [Anim { an_key: "goblin/walk/2", an_def: "goblin", an_action: "walk", an_facing: 2, an_seq: "goblin_walk_n" }];
assert(anim_of(p, "goblin", "walk", 2) == "goblin_walk_n", "the row answers its sequence");
assert(anim_of(p, "goblin", "walk", 3) == "", "another facing is another row");
assert(anim_of(p, "goblin", "jump", 2) == "", "an action the def lacks answers empty");
}