← stage · API reference · version 0.18.10
20 file(s), 6286 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 |
|---|---|---|---|
StageNode struct | 68 | — | 183 339 |
Layer struct | 139 | — | 198 1895 1903 1905 1907 1909 |
View struct | 152 | — | 197 314 2389 2412 2414 2423 … (7 total) |
StageLight struct | 171 | — | 193 2521 2561 244 251 139 |
Stage struct | 182 | — | 308 309 324 336 365 467 … (153 total) |
LOOP const | 217 | — | 2019 18 40 56 60 131 … (23 total) |
ONCE const | 218 | — | 2023 79 |
PINGPONG const | 219 | — | 2027 94 108 |
Sequence struct | 228 | — | 202 1977 2018 |
Mask struct | 242 | — | 184 1092 |
Place struct | 258 | — | 336 12 13 11 120 25 … (139 total) |
Bounds struct | 293 | — | 562 578 1066 1084 |
P2 struct | 301 | — | 583 584 586 593 594 1066 … (16 total) |
stage_new fn | 308 | — | 20 36 51 64 77 94 … (173 total) |
node_count fn | 324 | — | 103 204 210 51 |
node_add fn | 336 | STG-002 | 21 37 38 39 52 65 … (170 total) |
compose fn | 365 | STG-003 | 186 22 40 53 66 79 … (144 total) |
depth_cue fn | 467 | — | 135 150 164 178 |
cue_of fn | 479 | — | 380 |
haze_of fn | 490 | — | 124 154 166 168 169 |
order fn | 553 | — | 113 186 20 59 73 78 … (14 total) |
bounds_of fn | 562 | STG-007 | 438 98 102 112 140 152 … (12 total) |
world_point fn | 583 | — | 567 568 569 570 596 648 … (18 total) |
world_origin fn | 593 | STG-001 | 2592 99 103 137 142 150 … (15 total) |
UiRect struct | 612 | — | 621 657 104 106 |
DrawRect struct | 620 | — | 261 640 641 656 676 103 … (8 total) |
DrawText struct | 627 | — | none in this package |
draw_list fn | 640 | — | 124 38 58 83 95 |
render fn | 676 | GFX-001 | 124 127 38 58 83 110 |
opaque fn | 684 | — | 679 31 127 75 76 62 … (8 total) |
Batch struct | 704 | — | 736 737 758 1066 222 223 |
INSTANCE_STRIDE const | 720 | — | 919 979 980 981 982 983 … (20 total) |
OFF_AFFINE const | 723 | — | 980 981 93 94 95 96 … (23 total) |
OFF_CELL const | 724 | — | 982 141 101 102 66 67 |
OFF_RGBA const | 730 | — | 983 104 105 106 107 216 |
OFF_SWAY const | 731 | — | 984 |
batches fn | 736 | STG-004 | 968 28 43 61 75 103 … (13 total) |
pack_instances fn | 770 | — | 911 23 25 135 137 76 … (20 total) |
Painter struct | 884 | — | 897 903 910 963 1016 1033 |
painter_new fn | 897 | — | none in this package |
gl_render fn | 910 | — | none in this package |
scissor_of fn | 1066 | — | 988 233 237 242 247 253 … (7 total) |
add_mask fn | 1091 | — | 269 59 126 |
to_local fn | 1101 | — | 2499 48 50 |
hits fn | 1116 | — | 1193 1285 72 |
pick fn | 1154 | STG-006 | 1160 1185 1256 1315 100 107 … (26 total) |
press fn | 1159 | — | 185 82 87 96 215 218 … (19 total) |
release fn | 1180 | STG-005 | 84 86 98 52 56 192 … (8 total) |
released_on fn | 1192 | — | 86 87 114 115 |
capture fn | 1199 | — | 83 85 97 162 203 |
POINTER_MOUSE const | 1229 | — | 208 318 1245 |
POINTER_TOUCH const | 1230 | — | 208 1245 1296 123 |
UiState enum | 1236 | — | 1289 1290 1291 1292 1295 1296 … (20 total) |
set_pointer fn | 1244 | — | 123 |
pointer fn | 1250 | — | 209 203 |
pointer_move fn | 1253 | — | 43 97 160 163 175 182 |
hovered fn | 1261 | — | none in this package |
set_enabled fn | 1264 | — | 232 172 181 |
enabled fn | 1272 | — | 173 |
ui_state fn | 1289 | — | 74 75 98 101 127 128 … (14 total) |
click fn | 1308 | — | 107 63 217 255 45 99 … (10 total) |
focusable fn | 1399 | — | 1169 1422 1445 161 |
set_focusable fn | 1410 | — | 160 161 177 213 230 231 |
focused fn | 1417 | — | 163 165 167 169 171 179 … (14 total) |
set_focus fn | 1421 | — | 22 192 238 |
focus_next fn | 1430 | — | 1583 164 166 168 234 236 |
focus_prev fn | 1433 | — | 1583 170 178 |
set_field fn | 1451 | — | 21 190 191 212 |
is_field fn | 1519 | — | none in this package |
field_text fn | 1523 | — | 32 36 40 44 48 68 … (18 total) |
field_caret fn | 1528 | — | 33 49 60 65 69 78 … (13 total) |
field_mark fn | 1533 | — | 96 144 |
field_selection fn | 1538 | — | 90 95 108 131 134 249 … (7 total) |
field_place_caret fn | 1546 | — | 247 251 254 256 |
clipboard fn | 1555 | — | 111 121 |
set_clipboard fn | 1557 | — | none in this package |
text_event fn | 1563 | — | 31 39 43 71 76 99 … (8 total) |
key_event fn | 1581 | — | 35 38 42 46 47 62 … (29 total) |
KEY_CUT const | 1655 | — | 1600 119 |
KEY_COPY const | 1656 | — | 1596 110 |
KEY_PASTE const | 1657 | — | 1607 115 |
render_stage fn | 1682 | — | 82 85 118 121 14 28 … (26 total) |
render_view fn | 1693 | — | 1683 39 46 58 72 85 … (27 total) |
set_camera fn | 1868 | — | 31 52 62 77 96 111 … (11 total) |
camera_x fn | 1872 | — | 44 |
camera_y fn | 1874 | — | 44 |
set_parallax fn | 1879 | — | 182 49 50 94 109 |
parallax_of fn | 1887 | — | 2447 184 185 196 63 |
cam_offset fn | 1914 | — | 53 54 64 |
uploads fn | 1920 | — | 199 90 |
set_time fn | 1939 | STG-008 | 24 41 54 56 68 80 … (13 total) |
time_of fn | 1942 | — | none in this package |
sway_of fn | 1946 | — | 1800 42 43 44 55 57 … (10 total) |
add_sequence fn | 1968 | — | 272 18 40 56 60 79 … (12 total) |
advance fn | 1989 | — | 104 2198 22 24 45 67 … (28 total) |
frame_of fn | 2004 | — | 784 21 23 25 46 47 … (31 total) |
restart fn | 2040 | — | 153 |
Prop enum | 2082 | — | 2094 2099 2121 2127 2150 2162 … (40 total) |
prop_of fn | 2099 | — | 2157 65 68 98 104 124 … (16 total) |
set_prop fn | 2121 | — | 60 78 81 100 150 161 |
tween_prop fn | 2150 | — | 51 121 126 135 136 145 … (11 total) |
prop_tweening fn | 2162 | — | 147 |
cancel_prop_tween fn | 2170 | — | 2154 173 |
cancel_node_tweens fn | 2179 | — | none in this package |
tween_count fn | 2188 | — | 122 127 137 174 203 |
TOP_DOWN const | 2222 | — | 203 317 2323 52 93 186 … (7 total) |
SIDE_ON const | 2225 | — | 203 2338 111 134 170 194 … (10 total) |
set_projection fn | 2229 | — | 52 93 111 134 170 186 … (11 total) |
projection fn | 2232 | — | 254 |
sequence_count fn | 2235 | — | 61 277 |
add_named_sequence fn | 2240 | — | 53 112 113 135 171 172 … (14 total) |
facing_name fn | 2262 | — | 2292 39 |
face fn | 2320 | — | 57 95 99 116 120 139 … (17 total) |
set_action fn | 2335 | — | 115 137 174 189 197 214 … (9 total) |
facing_of fn | 2343 | — | 97 256 |
action_of fn | 2350 | — | none in this package |
rotation_of fn | 2356 | — | 98 101 153 201 203 289 |
mirrored fn | 2363 | — | 145 152 158 177 202 204 … (10 total) |
view_count fn | 2383 | — | 39 71 |
add_view fn | 2387 | — | 65 82 104 126 154 177 … (9 total) |
set_view_rect fn | 2396 | — | 55 63 103 129 153 176 |
set_view_camera fn | 2406 | — | 1868 43 56 64 66 83 … (14 total) |
view_camera_x fn | 2431 | — | 1872 41 |
view_camera_y fn | 2436 | — | 1874 41 |
view_offset fn | 2445 | — | 977 1040 1799 1915 2498 2704 |
sync_instances fn | 2457 | — | 916 177 179 181 183 185 … (9 total) |
pick_in fn | 2469 | — | 1155 158 159 160 161 182 |
in_view_rect fn | 2482 | — | 2470 |
hits_in fn | 2493 | — | 1117 2474 180 |
add_light fn | 2519 | — | 62 79 99 112 113 129 … (21 total) |
move_light fn | 2534 | — | 182 |
light_count fn | 2542 | — | 36 |
set_ambient fn | 2547 | — | 46 61 78 98 111 128 … (21 total) |
ambient fn | 2553 | — | 107 201 275 35 185 |
light_reach fn | 2561 | — | 2672 246 247 248 249 250 … (8 total) |
lit_colour fn | 2583 | — | 791 1778 37 49 50 68 … (24 total) |
LIGHT_FALLOFF const | 2633 | — | 1043 2572 258 |
HUD_NONE const | 2636 | — | 313 937 946 950 1698 1700 … (8 total) |
Lit struct | 2639 | — | 2666 2681 |
set_light_map fn | 2647 | — | 59 73 93 115 138 159 … (10 total) |
light_map fn | 2653 | — | 45 |
set_hud_layer fn | 2656 | — | 158 171 |
hud_layer fn | 2659 | — | none in this package |
light_rgb_at fn | 2666 | — | 2593 2687 2720 |
light_level_at fn | 2686 | — | 120 126 128 146 |
composite_light fn | 2702 | — | 1699 |
composite_shader_source fn | 2739 | — | 252 |
set_layer_fog fn | 2755 | — | 43 55 69 82 183 |
layer_fog_colour fn | 2765 | — | 188 |
layer_fog_density fn | 2770 | — | 188 190 198 |
set_layer_blur fn | 2780 | — | 102 116 139 158 181 |
layer_blur fn | 2787 | — | 1733 186 187 197 |
blur_region fn | 2797 | — | 1748 212 217 |
src/stage.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// stage — a retained 2-D scene: the tree a game mutates instead of a frame loop
// it draws. @PLN144 arc A.
//
// ⚠ THE TREE IS FLAT. Nodes live in one `vector<StageNode>` and a child names its
// parent by INDEX, never by reference. Two reasons, and the second is why it
// stays that way even though a pointer tree reads more naturally:
//
// * a parent holding its children while a child points back is a dependency
// cycle in loft's ownership model, and
// * a flat array IS the instance buffer the batched renderer wants, so the
// shape that avoids the cycle is also the shape `A3` needs.
//
// The invariant that makes composition one forward pass: **a parent always has a
// lower index than its children**. `node_add` enforces it rather than trusting
// it, because a single out-of-order insert would make `compose` read a parent's
// world transform before it was written — and produce a *plausible* wrong answer
// rather than an error.
//
// ── Using it ─────────────────────────────────────────────────────────────────
//
// Build the tree once, then mutate it. A parent composes into its children, so
// moving the cart moves whatever rides on it:
//
// st = stage::stage_new();
// cart = st.node_add(-1, stage::Place { pl_x: 100.0, pl_y: 80.0,
// pl_w: 40.0, pl_h: 20.0 });
// crate = st.node_add(cart, stage::Place { pl_x: 8.0, pl_y: -12.0,
// pl_w: 12.0, pl_h: 12.0 });
//
// st.compose(); // ONE forward pass; every world transform
// st.world_origin(crate); // -> (108, 68)
//
// st.set_camera(30.0, 0.0); // a draw-time offset: the tree is not touched
// st.set_prop(cart, stage::Prop.X, 140.0);
// st.compose();
// st.world_origin(crate); // -> (148, 68) — the crate came along
//
// st.pick(124.0, 74.0); // -> the crate. Picking is SCREEN space, so
// // the camera is already taken back off
//
// Then draw: `render_stage(cv)` onto a `graphics::Canvas`, or `batches` +
// `pack_instances` + `gl_render` for the instanced path. `compose` is what makes
// a frame current — every reader above answers about the last one.
//
// The rest of the surface is that loop at more resolutions: P2 the camera, P4
// animation, C2 tweened properties, D1/D2 widget states and an editable field,
// P6 several views over the one scene, L1-L3 light and atmosphere. Each section
// header below says what its own group is for.
use graphics::*;
use tween::*;
// A node's local placement, plus the world affine `compose` derives for it.
//
// ⚠ NAMED `StageNode` AND NOT `Node`, because a type name is GLOBAL across the
// dependency graph and `mesh3d` — which `graphics` pulls in — already publishes a
// `Node` for its 3-D scene. The compiler refuses the collision outright rather
// than resolving it silently, which is the good outcome; the cost of finding it
// after publishing would have been a rename nobody can make.
//
// The affine is 2x3 and is applied as
// x' = a*lx + c*ly + tx
// y' = b*lx + d*ly + ty
// which is the layout the instanced draw path consumes unchanged.
pub struct StageNode {
nd_parent: integer, // index of the parent, or -1 for a root
nd_x: float, // where the ORIGIN lands, in the parent's space
nd_y: float,
nd_rot: float, // radians; +ve turns +x toward +y
nd_sx: float,
nd_sy: float,
// ⚠ THE SPRITE OCCUPIES LOCAL [0, w] x [0, h]. The origin is a POINT INSIDE
// that box which lands on (x, y) — it is not the box's corner. Put it at
// (w/2, h) and the sprite stands up from its feet, centred on where it was
// placed. The two readings coincide only at (0, 0), which is why every test
// written before the depth cue could not tell them apart.
nd_ox: float,
nd_oy: float,
nd_w: float,
nd_h: float,
pub nd_colour: integer,
nd_visible: boolean,
nd_atlas: integer,
nd_u0: float,
nd_v0: float,
nd_u1: float,
nd_v1: float,
nd_alpha: float,
nd_mask: integer, // index into `st_masks`, or -1 for solid
nd_clips: boolean, // does this node clip its own subtree?
pub nd_clipped: boolean, // derived: is anything clipping it?
pub nd_cx0: float, // derived: the effective clip, world axis-aligned
pub nd_cy0: float,
pub nd_cx1: float,
pub nd_cy1: float,
nd_key: text, // the mob kind, e.g. "orc" — resolution starts here
nd_action: text, // what it is doing, e.g. "walk"
nd_facing: float, // where it points, in radians; kept in BOTH models
nd_mirror: boolean, // side-on: is it drawn flipped side to side?
nd_seq: integer, // index into `st_seqs`, or -1 for a still sprite
nd_elapsed: integer, // MICROSECONDS into the sequence — see `advance`
nd_cols: integer, // the atlas grid this node's cells live in
nd_rows: integer,
nd_sway: float, // ambient sway amplitude in pixels; 0 is still
pub nd_phase: float, // derived from POSITION, so neighbours are out of step
pub nd_cue: float, // derived: 0 at the near plane, 1 at the far one
nd_layer: integer, // ordering BAND — outranks depth entirely
nd_depth: float, // distance INTO the screen; larger draws first
nd_disabled: boolean, // D1: refuses to fire, and reads `Disabled`
nd_focusable: boolean, // D2: can hold the keyboard, and joins the tab order
// Derived world affine — read after `compose`.
//
// ⚠ THE DECLARED DEFAULT IS THE IDENTITY, NOT ZERO, and that is load-bearing.
// `compose` reads a node's PARENT out of the flat vector, which types as
// `StageNode?`, and the honest discharge of that read is the transform an
// ABSENT parent contributes — which is the identity, exactly what the
// `nd_parent < 0` branch two lines up already applies to a root. With the
// zero record it would be the transform that collapses every child to a
// point: silent, geometric, and visible only as a picture that is not there.
pub nd_a: float = 1.0,
pub nd_b: float = 0.0,
pub nd_c: float = 0.0,
pub nd_d: float = 1.0,
pub nd_tx: float = 0.0,
pub nd_ty: float = 0.0,
}
// Everything one ordering band carries: how much of the camera it feels, what
// atmosphere stands in front of it, and how far out of focus it is.
//
// ⚠ ONE RECORD, NOT THREE PARALLEL VECTORS. Parallax lived in a bare
// `vector<float>` and fog and blur were about to join it as siblings — which is
// exactly the shape that put a sequence's name on another sequence's cells in
// P5: every list stays well-formed on its own and only the correspondence goes
// wrong, silently. A layer's facts travel together.
pub struct Layer {
ly_parallax: float, // 1.0 is the world plane; below lags, above leads
ly_fog: integer, // packed 0xRRGGBB the layer fades toward
ly_density: float, // 0 is clear, 1 is entirely the fog colour
ly_blur: integer, // radius in pixels; 0 is sharp
}
// A way of LOOKING at the stage: a camera and the screen rect it paints into.
//
// ⚠ A VIEW IS PRESENTATION, NEVER WORLD. The scene cannot tell how many are
// looking at it — which is the split this phase exists to enforce: the world is
// deterministic and shared, the presentation is local and free. Window size,
// camera and ambient motion may differ per viewer and must be allowed to.
pub struct View {
vw_cam_x: float,
vw_cam_y: float,
vw_x: integer, // the screen rect this view paints into
vw_y: integer,
vw_w: integer, // 0 or less means unbounded — the whole target
vw_h: integer,
}
// A point light: where it stands, how far it reaches, and what colour it casts.
//
// ⚠ IT LIVES IN WORLD SPACE, like everything else the app places. A light is
// not attached to a node, so moving a torch-bearer means moving the light too —
// deliberate, because a light parented to a sprite would inherit that sprite's
// rotation and scale, and neither means anything to a point light.
// ⚠ `StageLight`, not `Light`: a TYPE name is global across every package in the
// build, and `mesh3d` — pulled in by `graphics` — already publishes a `Light`.
// The compiler refusing is the good outcome; after publishing, this rename is one
// nobody can make. Same reason the node is `StageNode`.
pub struct StageLight {
pub lt_x: float,
pub lt_y: float,
pub lt_r: float, // radius; the reach is 0 at exactly this distance
pub lt_colour: integer, // packed 0xRRGGBB
pub lt_power: float, // intensity multiplier; 1.0 is full at the centre
}
// A 2D scene and everything that drives it: its nodes and masks, the draw order
// `compose` derives, the views, lights and depth cue it paints with, and its pointer,
// focus and tween state. `stage_new` makes an empty one; every other call changes it.
pub struct Stage {
pub st_nodes: vector<StageNode>,
st_masks: vector<Mask>,
st_capture: integer, // the node holding the press, or -1
pub st_order: vector<integer>, // draw order, derived by `compose`
st_cue: boolean, // is the depth cue on?
st_cue_near: float,
st_cue_far: float,
st_cue_scale: float, // scale at the FAR end; 1.0 at the near end
st_cue_haze: float, // haze at the FAR end, 0..1
st_haze_colour: integer, // what distance fades toward
st_lights: vector<StageLight>, // L1: sampled per sprite, folded into the tint
st_ambient: float, // 1.0 is fully lit, which is the unlit default
st_light_map: boolean, // L2: light per PIXEL instead of per sprite
st_hud_layer: integer, // layers at or above this draw AFTER, unlit
st_views: vector<View>, // at least one; view 0 is the unsplit camera
st_layers: vector<Layer>, // per-layer facts; a missing layer is the plain one
st_uploads: integer, // instance-buffer uploads — the gate reads this
st_dirty: boolean, // does the instance buffer need rewriting?
st_time: float, // ambient clock — a uniform, never a rewrite
st_seqs: vector<Sequence>, // animation sequences, referenced by index
st_proj: integer, // TOP_DOWN or SIDE_ON — it picks the facing model
st_tweens: vector<NodeTween>, // C2: at most one per (node, property)
// D1: where the pointer is, what kind it is, and what it is over.
st_ptr_x: float,
st_ptr_y: float,
st_pointer: integer, // POINTER_MOUSE or POINTER_TOUCH
st_hover: integer, // node under the pointer, or -1
// D2: the keyboard, the fields it edits, and a clipboard of our own.
st_focus: integer, // node holding the keyboard, or -1
st_fields: vector<NodeField>,
st_clip: text,
}
// LOOP wraps, ONCE stops on the last frame, PINGPONG reverses at both ends.
pub const LOOP: integer = 0;
pub const ONCE: integer = 1;
pub const PINGPONG: integer = 2;
// A run of atlas cells played at a rate.
//
// ⚠ THE NAME LIVES HERE, not in a list beside `st_seqs`. A parallel vector that
// only the naming registrar appended to drifted the moment a caller mixed
// `add_sequence` with `add_named_sequence`, and every later name then pointed at
// somebody else's cells — silently, because both lists were individually
// well-formed. One home per fact; the test that caught it is kept.
pub struct Sequence {
q_first: integer, // first cell in the atlas
q_count: integer,
q_fps: float,
q_mode: integer,
q_name: text, // "" for an unnamed sequence
}
// An alpha mask: per-texel coverage, row-major, 0..255.
//
// A sprite is mostly holes, so a rect test answers the wrong question — you can
// SEE through a tree, therefore you must be able to CLICK through it. A node
// without a mask is solid, which is the right default for a panel or a tile.
pub struct Mask {
m_w: integer,
m_h: integer,
m_alpha: vector<integer>,
}
// Where to put a node, and how. A struct rather than nine parameters because the
// compiler said so — `too-many-parameters` fires at eight, and a caller's burden
// is fixed by a struct where a reader's is fixed by an extracted function.
//
// ⚠ THE DEFAULTS ARE THE POINT, not convenience. Scale defaults to 1.0 and not to
// the zero an omitted field would otherwise take: a scale of 0 is a *meaningful*
// value of the field's domain — it collapses the node to nothing — so an author
// who writes `Place { pl_x: 10.0, pl_y: 20.0 }` and gets an invisible node has
// been handed a legal answer to a question they did not ask. That is exactly the
// case `omitted-field-zero` exists to warn about, answered here at the declaration.
pub struct Place {
pub pl_w: float = 0.0, // size in the node's own units; 0 x 0 draws nothing
pub pl_h: float = 0.0,
pub pl_colour: integer = 0, // packed 0xRRGGBB, as `DrawRect` carries
pub pl_visible: boolean = true,
pub pl_atlas: integer = 0, // which texture page; a run may only merge within one
pub pl_u0: float = 0.0, // the sprite's rect within that page
pub pl_v0: float = 0.0,
pub pl_u1: float = 1.0,
pub pl_v1: float = 1.0,
pub pl_alpha: float = 1.0,
pub pl_mask: integer = -1, // index from `add_mask`, or -1 for solid
pub pl_clips: boolean = false, // clip this node's subtree to its own bounds
pub pl_key: text = "", // the mob kind; "" opts out of by-name resolution
pub pl_seq: integer = -1, // from `add_sequence`; -1 is a still sprite
pub pl_cols: integer = 1, // the atlas grid the cells are packed in
pub pl_rows: integer = 1,
pub pl_sway: float = 0.0, // ambient sway amplitude, in pixels
pub pl_layer: integer = 0, // background / world / UI are BANDS, not numbers
pub pl_depth: float = 0.0, // distance INTO the screen: LARGER draws first, so
// `depth = -y` puts a lower sprite in front. A 2.5-D
// scene sets it from the footprint — a hex row, or a
// projected height — never from the artwork
pub pl_x: float = 0.0,
pub pl_y: float = 0.0,
pub pl_rot: float = 0.0, // radians; +ve turns +x toward +y
pub pl_sx: float = 1.0,
pub pl_sy: float = 1.0,
pub pl_ox: float = 0.0, // the origin, in the node's own units
pub pl_oy: float = 0.0,
}
// A point, so a caller can ask where something ended up without unpacking six
// floats. `world_point` is the only honest way to check a transform: a matrix
// means nothing except through what it does to a point.
pub struct Bounds {
pub b_x0: float,
pub b_y0: float,
pub b_x1: float,
pub b_y1: float,
}
// A point in world coordinates, as `world_point` and `world_origin` answer it.
pub struct P2 {
pub p_x: float,
pub p_y: float,
}
// An empty stage: no nodes, one view, the camera at the origin and ambient
// light full. Everything else here is a mutation of this.
pub fn stage_new() -> Stage {
Stage { st_nodes: [], st_masks: [], st_capture: -1, st_order: [],
st_cue: false, st_cue_near: 0.0, st_cue_far: 1.0,
st_cue_scale: 1.0, st_cue_haze: 0.0, st_haze_colour: 0x000000,
st_lights: [], st_ambient: 1.0, st_light_map: false,
st_hud_layer: HUD_NONE,
st_views: [View { vw_cam_x: 0.0, vw_cam_y: 0.0, vw_x: 0, vw_y: 0,
vw_w: 0, vw_h: 0 }],
st_layers: [], st_uploads: 0, st_dirty: true,
st_time: 0.0, st_seqs: [], st_proj: TOP_DOWN, st_tweens: [],
st_ptr_x: 0.0, st_ptr_y: 0.0, st_pointer: POINTER_MOUSE, st_hover: -1,
st_focus: -1, st_fields: [], st_clip: "" }
}
// How many nodes the tree holds. Every index this library hands out is below
// it, so it is also the bound for a walk over the scene.
pub fn node_count(self: const Stage) -> integer {
len(self.st_nodes)
}
// Add a node and answer its index. `parent` is -1 for a root.
//
// ⚠ Refuses a forward parent reference by answering -1. `compose` is a single
// forward pass, so a child placed before its parent would read an unwritten world
// transform — silently, and the frame would look almost right.
// Example: @STG-002 — the tree is a flat array and a forward parent reference is
// refused, because `compose` is a single forward pass that would otherwise read an
// unwritten parent and answer plausibly.
pub fn node_add(self: Stage, parent: integer, p: const Place) -> integer {
idx = len(self.st_nodes);
if parent >= idx or parent < -1 { return -1; }
self.st_nodes += [StageNode {
nd_parent: parent, nd_x: p.pl_x, nd_y: p.pl_y, nd_rot: p.pl_rot,
nd_sx: p.pl_sx, nd_sy: p.pl_sy, nd_ox: p.pl_ox, nd_oy: p.pl_oy,
nd_w: p.pl_w, nd_h: p.pl_h, nd_colour: p.pl_colour, nd_visible: p.pl_visible,
nd_atlas: p.pl_atlas, nd_u0: p.pl_u0, nd_v0: p.pl_v0, nd_u1: p.pl_u1,
nd_v1: p.pl_v1, nd_alpha: p.pl_alpha, nd_mask: p.pl_mask,
nd_key: p.pl_key, nd_action: "", nd_facing: 0.0, nd_mirror: false,
nd_seq: p.pl_seq, nd_elapsed: 0, nd_cols: p.pl_cols, nd_rows: p.pl_rows,
nd_sway: p.pl_sway, nd_phase: 0.0,
nd_cue: 0.0, nd_layer: p.pl_layer, nd_depth: p.pl_depth,
nd_clips: p.pl_clips, nd_clipped: false,
nd_cx0: 0.0, nd_cy0: 0.0, nd_cx1: 0.0, nd_cy1: 0.0,
nd_disabled: false, nd_focusable: false,
nd_a: 1.0, nd_b: 0.0, nd_c: 0.0, nd_d: 1.0, nd_tx: 0.0, nd_ty: 0.0,
}];
idx
}
// Derive every node's world affine in one forward pass.
//
// A node's local transform is T(x, y) . R(rot) . S(sx, sy) . T(-ox, -oy), which
// is what makes the ORIGIN the anchor: the local point (ox, oy) maps exactly to
// (x, y), so a sprite whose origin sits at its feet stands up from where it is
// placed. World = parent's world composed with that.
// Example: @STG-003 — deriving is the caller's to trigger; skipping it renders the
// previous state, which reads as input lag rather than as a missing call.
pub fn compose(self: Stage) {
for i in 0..len(self.st_nodes) {
n = self.st_nodes[i];
cs = cos(n.nd_rot);
sn = sin(n.nd_rot);
// The DEPTH CUE, if it is on: a node further away is drawn smaller.
//
// ⚠ It scales about the ORIGIN, which is why the feet stay planted while the
// sprite shrinks — a cue that moved the anchor would slide a mob off the tile
// it is standing on. That falls out of the origin being the anchor rather
// than being arranged for.
// The sway phase comes from WHERE the node is, so two tufts a metre apart
// are out of step without either storing anything. Derived once here, never
// per frame.
self.st_nodes[i].nd_phase = n.nd_x * 0.37 + n.nd_y * 0.11;
cue = cue_of(self, n.nd_depth);
self.st_nodes[i].nd_cue = cue;
k = 1.0 + (self.st_cue_scale - 1.0) * cue;
// ⚠ MIRRORING IS A NEGATED X SCALE, and it rides the transform that already
// exists rather than adding a pass. Because the origin is the anchor, a
// flip about it leaves the footprint and the origin exactly where they
// were — verified, not assumed: P5's gate compares both against the
// unmirrored bounds.
mx = if n.nd_mirror { -1.0 } else { 1.0 };
la = cs * n.nd_sx * k * mx;
lb = sn * n.nd_sx * k * mx;
lc = -sn * n.nd_sy * k;
ld = cs * n.nd_sy * k;
// ... then place the ORIGIN at (x, y): t = (x, y) - A . (ox, oy).
ltx = n.nd_x - (la * n.nd_ox + lc * n.nd_oy);
lty = n.nd_y - (lb * n.nd_ox + ld * n.nd_oy);
if n.nd_parent < 0 {
self.st_nodes[i].nd_a = la;
self.st_nodes[i].nd_b = lb;
self.st_nodes[i].nd_c = lc;
self.st_nodes[i].nd_d = ld;
self.st_nodes[i].nd_tx = ltx;
self.st_nodes[i].nd_ty = lty;
} else {
// `?` is the identity transform (see `nd_a`'s declared default), which
// is what an absent parent contributes — so this branch and the root
// branch above agree by construction rather than by coincidence.
p = self.st_nodes[n.nd_parent]?;
// W = P . L — the parent's linear part acts on the child's, and on
// the child's translation before the parent's is added.
self.st_nodes[i].nd_a = p.nd_a * la + p.nd_c * lb;
self.st_nodes[i].nd_b = p.nd_b * la + p.nd_d * lb;
self.st_nodes[i].nd_c = p.nd_a * lc + p.nd_c * ld;
self.st_nodes[i].nd_d = p.nd_b * lc + p.nd_d * ld;
self.st_nodes[i].nd_tx = p.nd_a * ltx + p.nd_c * lty + p.nd_tx;
self.st_nodes[i].nd_ty = p.nd_b * ltx + p.nd_d * lty + p.nd_ty;
}
// The effective clip: what the parent already imposes, INTERSECTED with
// this node's own bounds when it clips. Parents come first, so the
// parent's is written before a child reads it — the same invariant the
// transform pass rests on, doing a second job.
cl = false;
cx0 = 0.0;
cy0 = 0.0;
cx1 = 0.0;
cy1 = 0.0;
if n.nd_parent >= 0 {
// `?` reads as "clips nothing", which is what no parent means here.
pp = self.st_nodes[n.nd_parent]?;
cl = pp.nd_clipped;
cx0 = pp.nd_cx0;
cy0 = pp.nd_cy0;
cx1 = pp.nd_cx1;
cy1 = pp.nd_cy1;
}
if n.nd_clips {
b = bounds_of(self, i);
if !cl {
cl = true;
cx0 = b.b_x0; cy0 = b.b_y0; cx1 = b.b_x1; cy1 = b.b_y1;
} else {
if b.b_x0 > cx0 { cx0 = b.b_x0; }
if b.b_y0 > cy0 { cy0 = b.b_y0; }
if b.b_x1 < cx1 { cx1 = b.b_x1; }
if b.b_y1 < cy1 { cy1 = b.b_y1; }
}
}
self.st_nodes[i].nd_clipped = cl;
self.st_nodes[i].nd_cx0 = cx0;
self.st_nodes[i].nd_cy0 = cy0;
self.st_nodes[i].nd_cx1 = cx1;
self.st_nodes[i].nd_cy1 = cy1;
}
order_nodes(self);
self.st_dirty = true;
}
// Turn the depth cue on. `near` and `far` are depths; a node at `far` is drawn at
// `far_scale` of its size and hazed `far_haze` toward `haze_colour`, and one at
// `near` is untouched. Off by default, and off means an identical picture.
//
// This is the 2.5-D FEEL: distance reads as smaller and less distinct, which is
// what separates a mob standing behind a wall from one standing in front of it
// even when neither overlaps the other. Real blur belongs to the per-layer pass;
// a haze toward the fog colour costs one lerp and carries most of the effect.
pub fn depth_cue(self: Stage, near: float, far: float, far_scale: float,
far_haze: float, haze_colour: integer) {
self.st_cue = true;
self.st_cue_near = near;
self.st_cue_far = far;
self.st_cue_scale = far_scale;
self.st_cue_haze = far_haze;
self.st_haze_colour = haze_colour;
}
// How far through the cue a depth sits: 0 at the near plane, 1 at the far one,
// clamped outside them so a stray depth cannot invert the effect.
pub fn cue_of(self: const Stage, depth: float) -> float {
if !self.st_cue { return 0.0; }
span = self.st_cue_far - self.st_cue_near;
if span > -0.000001 and span < 0.000001 { return 0.0; }
t = (depth - self.st_cue_near) / span ?? 0.0;
if t < 0.0 { return 0.0; }
if t > 1.0 { return 1.0; }
t
}
// How hazed node `idx` is, 0..1 — what a renderer fades toward the haze colour.
pub fn haze_of(self: const Stage, idx: integer) -> float {
if idx < 0 or idx >= len(self.st_nodes) { return 0.0; }
self.st_nodes[idx].nd_cue * self.st_cue_haze
}
// Derive the draw order: by LAYER first, then depth, then insertion.
//
// ⚠ STABLE, and that is the whole contract of the third key. Two sprites on the
// same footprint — a worker standing where another worker stands — must not swap
// places from frame to frame, and a sort that broke ties arbitrarily would flicker
// exactly there. Bottom-up merge, because it is stable by construction rather
// than by care.
fn order_nodes(self: Stage) {
n = len(self.st_nodes);
src: vector<integer> = [];
for i in 0..n { src += [i]; }
width = 1;
while width < n {
dst: vector<integer> = [];
lo = 0;
while lo < n {
mid = lo + width;
hi = mid + width;
if mid > n { mid = n; }
if hi > n { hi = n; }
a = lo;
b = mid;
while a < mid or b < hi {
take_a = true;
if a >= mid { take_a = false; }
else if b < hi {
ia = src[a] ?? 0;
ib = src[b] ?? 0;
take_a = !after(self, ia, ib);
}
if take_a { dst += [src[a] ?? 0]; a = a + 1; }
else { dst += [src[b] ?? 0]; b = b + 1; }
}
lo = hi;
}
src = dst;
width = width * 2;
}
self.st_order = src;
}
// Does node `x` sort AFTER node `y` — that is, draw on top of it?
//
// Layer ascending, then depth DESCENDING, then insertion. ⚠ The direction of the
// middle key is the one thing here that is easy to get backwards, and I did:
// `depth` is distance INTO the screen, so a LARGER depth is further away and draws
// FIRST. That is what makes `depth = -y` work — a sprite lower on the screen has a
// smaller depth, so it draws later and stands in front — and it is GameMaker's
// convention, which is the vocabulary this model borrows throughout.
fn after(self: const Stage, x: integer, y: integer) -> boolean {
a = self.st_nodes[x];
b = self.st_nodes[y];
if a.nd_layer != b.nd_layer { return a.nd_layer > b.nd_layer; }
if a.nd_depth != b.nd_depth { return a.nd_depth < b.nd_depth; }
x > y
}
// The draw order, derived by `compose`: indices, back to front.
pub fn order(self: const Stage) -> vector<integer> { self.st_order }
// A node's axis-aligned bounds in world space.
// Example: @STG-007 — a clip inherits, intersects, and is axis-aligned.
//
// ⚠ AXIS-ALIGNED, so a ROTATED clipper clips to its bounding box rather than to
// its turned rectangle. A scissor is axis-aligned in hardware and there is no
// honest way to make it otherwise; a rotated clip needs a stencil, which this arc
// does not have. Stated rather than discovered.
pub fn bounds_of(self: const Stage, idx: integer) -> Bounds {
// An index that is not a node reads as the blank node — zero size at the
// origin — and `world_point` already answers (0, 0) for it, so the bounds
// come out empty rather than wrong.
n = self.st_nodes[idx]?;
p0 = world_point(self, idx, 0.0, 0.0);
p1 = world_point(self, idx, n.nd_w, 0.0);
p2 = world_point(self, idx, 0.0, n.nd_h);
p3 = world_point(self, idx, n.nd_w, n.nd_h);
x0 = p0.p_x; x1 = p0.p_x; y0 = p0.p_y; y1 = p0.p_y;
if p1.p_x < x0 { x0 = p1.p_x; } if p1.p_x > x1 { x1 = p1.p_x; }
if p2.p_x < x0 { x0 = p2.p_x; } if p2.p_x > x1 { x1 = p2.p_x; }
if p3.p_x < x0 { x0 = p3.p_x; } if p3.p_x > x1 { x1 = p3.p_x; }
if p1.p_y < y0 { y0 = p1.p_y; } if p1.p_y > y1 { y1 = p1.p_y; }
if p2.p_y < y0 { y0 = p2.p_y; } if p2.p_y > y1 { y1 = p2.p_y; }
if p3.p_y < y0 { y0 = p3.p_y; } if p3.p_y > y1 { y1 = p3.p_y; }
Bounds { b_x0: x0, b_y0: y0, b_x1: x1, b_y1: y1 }
}
// Where a point in node `idx`'s own space lands in world space. Valid after
// `compose`; answers (0, 0) for an index that is not a node.
pub fn world_point(self: const Stage, idx: integer, lx: float, ly: float) -> P2 {
if idx < 0 or idx >= len(self.st_nodes) { return P2 { p_x: 0.0, p_y: 0.0 }; }
n = self.st_nodes[idx];
P2 { p_x: n.nd_a * lx + n.nd_c * ly + n.nd_tx,
p_y: n.nd_b * lx + n.nd_d * ly + n.nd_ty }
}
// Where the node's ORIGIN is — the point A7's depth key sorts on.
// Example: @STG-001 — a node is anchored by its origin, so a sprite whose origin
// sits at its feet stands up from where it was placed.
pub fn world_origin(self: const Stage, idx: integer) -> P2 {
if idx < 0 or idx >= len(self.st_nodes) { return P2 { p_x: 0.0, p_y: 0.0 }; }
n = self.st_nodes[idx];
world_point(self, idx, n.nd_ox, n.nd_oy)
}
// ── A2: the draw list ────────────────────────────────────────────────────────
//
// ⚠ THESE THREE TYPES ARE NOT NEW. Their field names and types are copied
// EXACTLY from `lavition_ui`, which produces `vector<DrawRect>` from
// `panel_draw_list` today and is proven by a 12 k-line editor. Matching a working
// command shape rather than minting a rival one is the whole point: a game's UI
// and an editor's UI have to reach the GPU by one path, or the batcher sees two.
//
// ⚠ AND THEY NEED ONE HOME, WHICH THEY DO NOT YET HAVE. `lavition_ui` is
// unpublished, so its `DrawRect` and this one are two nominal types with one
// shape. Until it is published (@PLN145 `D0`) the types cannot literally be
// shared, and one of the two must eventually give — see the plan.
pub struct UiRect {
pub r_x: integer,
pub r_y: integer,
pub r_w: integer,
pub r_h: integer,
}
// A packed 0xRRGGBB colour, as `lavition_ui` carries it.
pub struct DrawRect {
pub dr_rect: UiRect,
pub dr_colour: integer,
}
// One line of text to draw, in the command shape `lavition_ui` takes: the text, its left
// edge and its TOP, and a packed 0xRRGGBB colour.
pub struct DrawText {
dt_text: text,
dt_x: integer,
dt_y: integer, // TOP of the line, which is what draw-a-texture wants
dt_colour: integer,
}
// Emit the scene as a flat command list, in index order.
//
// Index order IS the draw order for now: `A4` gives depth its own key, and doing
// it here would be a second ordering rule to keep in step with that one. A node
// with no area emits nothing — a zero size is how a pure transform parent says it
// is scaffolding rather than a thing to look at.
pub fn draw_list(self: const Stage) -> vector<DrawRect> {
out: vector<DrawRect> = [];
for i in self.st_order {
n = self.st_nodes[i];
if !n.nd_visible { continue; }
if n.nd_w <= 0.0 or n.nd_h <= 0.0 { continue; }
// The origin is the anchor, so the rect starts there and extends by the
// node's size through its own scale — which is what `nd_a`/`nd_d` carry.
o = world_point(self, i, 0.0, 0.0);
far = world_point(self, i, n.nd_w, n.nd_h);
x0 = o.p_x;
y0 = o.p_y;
w = far.p_x - x0;
h = far.p_y - y0;
if w < 0.0 { x0 = far.p_x; w = -w; }
if h < 0.0 { y0 = far.p_y; h = -h; }
out += [DrawRect {
dr_rect: UiRect { r_x: x0 as integer, r_y: y0 as integer,
r_w: w as integer, r_h: h as integer },
dr_colour: n.nd_colour,
}];
}
out
}
// Paint a command list onto a software canvas.
//
// ⚠ THE ALPHA BYTE IS ADDED HERE, and it has to be. A `DrawRect` carries
// 0xRRGGBB — `lavition_ui` says so, and keeps it a plain integer so it needs no
// graphics dependency — while `graphics::Canvas` reads 0xAARRGGBB and treats a
// missing top byte as FULLY TRANSPARENT. That is graphics' own trap: the colour
// looks right in the source and paints nothing. One `| 0xff000000` at the
// boundary is the whole fix, and it belongs at the boundary rather than in every
// caller.
//
// Example: @GFX-001
pub fn render(list: const vector<DrawRect>, cv: graphics::Canvas) {
for d in list {
cv.fill_rect(d.dr_rect.r_x, d.dr_rect.r_y, d.dr_rect.r_w, d.dr_rect.r_h,
opaque(d.dr_colour));
}
}
// 0xRRGGBB -> 0xAARRGGBB, leaving an already-opaque colour alone.
pub fn opaque(colour: integer) -> integer {
if (colour & 0xff000000) != 0 { return colour; }
colour | 0xff000000
}
// ── A3a: the batcher ─────────────────────────────────────────────────────────
//
// ⚠ MERGE ADJACENT RUNS ONLY — NEVER SORT. 2-D correctness is painter's algebra:
// overlapping translucent draws must composite in call order, so a batcher may
// merge *consecutive* commands that share state and must break on a change. It
// may not gather every command of one atlas together, however much cheaper that
// would be, because that reorders the picture.
//
// The honest cost, and it is not small: a scene that interleaves two atlases
// sprite-by-sprite degenerates to one instance per run. That is a PACKING
// problem, answered where the atlas is built — sprites that draw near each other
// must share a page — and not something the batcher may fix by reordering.
// One run of consecutive instances that share an atlas.
pub struct Batch {
pub b_layer: integer, // one run, one layer — so one camera offset
pub b_atlas: integer,
pub b_first: integer, // index of the first instance in the packed buffer
pub b_count: integer,
pub b_clipped: boolean, // the scissor this run needs, if any
pub b_cx0: float,
pub b_cy0: float,
pub b_cx1: float,
pub b_cy1: float,
}
// Floats per instance in the packed buffer: a 2x3 affine, the sprite's uv rect,
// and a premultiplied-ready rgba. The stride is public because a caller wiring
// `gl_instance_attrib` needs it, and a wrong one fails as garbage geometry rather
// than as an error.
pub const INSTANCE_STRIDE: integer = 16;
// Offsets within one instance, for the same reason.
pub const OFF_AFFINE: integer = 0; // a, b, c, d, tx, ty
pub const OFF_CELL: integer = 6; // frame, cols, rows, spare
//
// ⚠ A FRAME INDEX, NOT A UV RECT. An animating sprite then dirties ONE float per
// frame instead of four, and the shader derives the uv from the grid — which is
// what keeps `A3`'s *upload only what changed* meaningful with a screen full of
// walking mobs. The grid is per-node and static, so it uploads once.
pub const OFF_RGBA: integer = 10; // r, g, b, a
pub const OFF_SWAY: integer = 14; // amplitude, phase — static, so the clock is a uniform
// Split the visible nodes into runs, in scene order.
// Example: @STG-004 — adjacent runs only, never sorted, and what the interleave
// costs: four sprites across two atlases are four draws, cured by packing.
pub fn batches(self: const Stage) -> vector<Batch> {
out: vector<Batch> = [];
idx = 0;
for i in self.st_order {
n = self.st_nodes[i];
if !n.nd_visible or n.nd_w <= 0.0 or n.nd_h <= 0.0 { continue; }
// ⚠ A RUN IS (ATLAS, CLIP), not atlas alone. A scissor change is a state
// change, so it breaks a batch exactly as a texture change does — grouping
// by atlas only would draw the second clip's content under the first's
// scissor.
last = len(out) - 1;
same = false;
if last >= 0 {
b = out[last];
same = b.b_layer == n.nd_layer and b.b_atlas == n.nd_atlas
and b.b_clipped == n.nd_clipped
and b.b_cx0 == n.nd_cx0 and b.b_cy0 == n.nd_cy0
and b.b_cx1 == n.nd_cx1 and b.b_cy1 == n.nd_cy1;
}
if same {
out[last].b_count += 1;
} else {
out += [Batch { b_layer: n.nd_layer, b_atlas: n.nd_atlas, b_first: idx, b_count: 1,
b_clipped: n.nd_clipped, b_cx0: n.nd_cx0,
b_cy0: n.nd_cy0, b_cx1: n.nd_cx1, b_cy1: n.nd_cy1 }];
}
idx = idx + 1;
}
out
}
// Pack every visible node into one instance buffer, in the same order `batches`
// counted. The two walk the scene by the same rule, so a batch's `b_first` and
// `b_count` index this buffer directly.
pub fn pack_instances(self: const Stage) -> vector<single> {
out: vector<single> = [];
for i in self.st_order {
n = self.st_nodes[i];
if !n.nd_visible or n.nd_w <= 0.0 or n.nd_h <= 0.0 { continue; }
// The affine already carries rotation and scale; the node's SIZE folds in
// here so the shader can keep drawing a unit quad.
out += [(n.nd_a * n.nd_w) as single];
out += [(n.nd_b * n.nd_w) as single];
out += [(n.nd_c * n.nd_h) as single];
out += [(n.nd_d * n.nd_h) as single];
o = world_point(self, i, 0.0, 0.0);
out += [o.p_x as single];
out += [o.p_y as single];
out += [frame_of(self, i) as single];
out += [n.nd_cols as single];
out += [n.nd_rows as single];
out += [0.0f];
// ⚠ PREMULTIPLIED. Straight alpha under linear filtering darkens every
// anti-aliased edge, and this stack draws overlapping soft-edged sprites
// all day; the GL blend below is `(ONE, ONE_MINUS_SRC_ALPHA)` to match.
lc = lit_colour(self, i);
out += [((((lc >> 16) & 255) as float / 255.0) * n.nd_alpha) as single];
out += [((((lc >> 8) & 255) as float / 255.0) * n.nd_alpha) as single];
out += [(((lc & 255) as float / 255.0) * n.nd_alpha) as single];
out += [n.nd_alpha as single];
out += [n.nd_sway as single];
out += [n.nd_phase as single];
}
out
}
// ── A3b: submitting the batches ──────────────────────────────────────────────
//
// One `gl_draw_instanced` per run, drawing a unit quad transformed entirely by
// per-instance data. The vertex buffer never changes and never grows: a scene of
// five thousand sprites uploads one quad and one instance buffer.
VERT_2D = `
#version 330 core
layout (location = 0) in vec3 aQuad; // the unit quad, 0..1
layout (location = 1) in vec4 iAffine; // a, b, c, d — size already folded in
layout (location = 2) in vec2 iTrans; // tx, ty — the node's origin, in pixels
layout (location = 3) in vec4 iCell; // frame, cols, rows, spare
layout (location = 4) in vec4 iRGBA;
layout (location = 5) in vec2 iSway;
uniform vec2 uScreen;
uniform vec2 uCam;
uniform float uTime;
out vec4 vColor;
void main() {{
vec2 p = vec2(iAffine.x * aQuad.x + iAffine.z * aQuad.y + iTrans.x,
iAffine.y * aQuad.x + iAffine.w * aQuad.y + iTrans.y) + uCam;
p.x += iSway.x * sin(uTime + iSway.y);
// Pixel space to NDC, y DOWN — the same orientation the software canvas uses,
// so the two paths can be compared pixel for pixel rather than mirrored.
gl_Position = vec4(p.x / uScreen.x * 2.0 - 1.0, 1.0 - p.y / uScreen.y * 2.0, 0.0, 1.0);
vColor = iRGBA;
}}
`;
// The light-map composite: one fullscreen quad multiplying the world pass by the
// light standing at each pixel.
//
// ⚠ THE CURVE AND THE FLOOR ARRIVE AS UNIFORMS, from `LIGHT_FALLOFF` and the
// stage's ambient — never baked into this text. A number written here would be
// a second copy of the curve, and retuning it would silently stop matching what
// `light_reach` answers on the software path and in every gate.
VERT_LIGHT = `
#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec2 aUV;
out vec2 vUV;
void main() {{
vUV = aUV;
gl_Position = vec4(aPos, 0.0, 1.0);
}}
`;
FRAG_LIGHT = `
#version 330 core
in vec2 vUV;
out vec4 FragColor;
uniform sampler2D uScene;
uniform vec2 uScreenL;
uniform vec2 uCamL;
uniform float uFloor;
uniform float uFalloff;
uniform int uCount;
uniform vec4 uLights[32]; // x, y, radius, power
uniform vec3 uLightCol[32];
void main() {{
vec2 w = vUV * uScreenL - uCamL;
vec3 acc = vec3(uFloor);
for (int i = 0; i < uCount; ++i) {{
float r = uLights[i].z;
if (r <= 0.0) continue;
float a = 1.0 - distance(w, uLights[i].xy) / r;
if (a <= 0.0) continue;
a = pow(clamp(a, 0.0, 1.0), uFalloff) * uLights[i].w;
acc += uLightCol[i] * a;
}}
FragColor = vec4(texture(uScene, vUV).rgb * clamp(acc, 0.0, 1.0), 1.0);
}}
`;
FRAG_2D = `
#version 330 core
in vec4 vColor;
out vec4 FragColor;
void main() {{ FragColor = vColor; }}
`;
// The GL objects a stage needs, built once.
pub struct Painter {
pn_shader: integer,
pn_vao: integer,
pn_ivbo: integer,
pn_cap: integer, // instances the buffer currently holds
pn_light: integer, // L2's composite shader; 0 until first needed
pn_fbo: integer, // the offscreen target the world pass draws into
pn_scene: integer, // its colour attachment
pn_fw: integer, // what size that target was made at
pn_fh: integer,
}
// Compile the shader and upload the unit quad. Call once, after a window exists.
pub fn painter_new() -> Painter {
quad: vector<single> = [
0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f,
0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f];
sh = graphics::gl_create_shader(VERT_2D, FRAG_2D);
vao = graphics::gl_upload_vertices(quad, 3);
Painter { pn_shader: sh, pn_vao: vao, pn_ivbo: 0, pn_cap: 0,
pn_light: 0, pn_fbo: 0, pn_scene: 0, pn_fw: 0, pn_fh: 0 }
}
// Draw the scene, one instanced call per run. Answers the number of draw calls
// issued, which is the half of the gate that pixels cannot see: a batch that fell
// back to one draw per sprite would look identical and cost N.
pub fn gl_render(self: Stage, p: Painter, screen_w: float, screen_h: float) -> integer {
floats = pack_instances(self);
if len(floats) == 0 { return 0; }
// ⚠ PACKED ONCE, DRAWN PER VIEW. The upload decision goes through
// `sync_instances` so there is ONE answer to *when does the buffer get
// rewritten* — a second view is draw calls, never another upload.
changed = sync_instances(self);
if p.pn_ivbo == 0 {
p.pn_ivbo = graphics::gl_upload_instance_buffer(floats);
p.pn_cap = len(floats) / INSTANCE_STRIDE ?? 0;
} else {
if changed { graphics::gl_update_buffer(p.pn_ivbo, floats); }
}
// Premultiplied compositing, to match `pack_instances` and the software path.
graphics::gl_enable(graphics::GL_BLEND);
graphics::gl_blend_func(graphics::BLEND_ONE, graphics::BLEND_ONE_MINUS_SRC_ALPHA);
graphics::gl_use_shader(p.pn_shader);
graphics::gl_set_uniform_vec2(p.pn_shader, "uScreen", screen_w, screen_h);
graphics::gl_set_uniform_float(p.pn_shader, "uTime", self.st_time);
calls = 0;
// ⚠ THE SAME THREE PHASES AS THE SOFTWARE PATH — world, composite, HUD — so
// the two can be compared rather than merely believed. With the map off,
// `st_hud_layer` is the no-HUD sentinel and this is one pass over everything.
if self.st_light_map {
ensure_light_target(p, screen_w as integer, screen_h as integer);
graphics::gl_bind_framebuffer(p.pn_fbo);
graphics::gl_clear(0);
calls = calls + gl_draw_band(self, p, screen_w, screen_h, 0 - HUD_NONE,
self.st_hud_layer);
graphics::gl_bind_framebuffer(0);
gl_composite(self, p, screen_w, screen_h);
calls = calls + 1;
graphics::gl_use_shader(p.pn_shader);
graphics::gl_set_uniform_vec2(p.pn_shader, "uScreen", screen_w, screen_h);
graphics::gl_set_uniform_float(p.pn_shader, "uTime", self.st_time);
calls = calls + gl_draw_band(self, p, screen_w, screen_h, self.st_hud_layer,
HUD_NONE);
graphics::gl_disable(graphics::GL_SCISSOR_TEST);
return calls;
}
calls = gl_draw_band(self, p, screen_w, screen_h, 0 - HUD_NONE, HUD_NONE);
graphics::gl_disable(graphics::GL_SCISSOR_TEST);
calls
}
// Draw every run whose layer is in `[lo, hi)`, for every view.
//
// ⚠ `_screen_w` is deliberately unread, and the pair is kept rather than
// trimmed: only the HEIGHT is needed here, to flip the scissor's y (GL measures
// it from the bottom and the screen from the top) — x does not flip. Its
// sibling `gl_composite` uses both, so *(screen_w, screen_h)* is the shape this
// family of helpers takes; dropping one of two symmetric floats would leave a
// bare positional float at three call sites that could take either dimension.
fn gl_draw_band(self: const Stage, p: const Painter, _screen_w: float,
screen_h: float, lo: integer, hi: integer) -> integer {
calls = 0;
for v in 0..len(self.st_views) {
vp = view_at(self, v);
for b in batches(self) {
if b.b_layer < lo or b.b_layer >= hi { continue; }
// ⚠ A RUN STARTS PART-WAY INTO THE BUFFER, and there is no first-instance
// parameter to say so: `gl_draw_instanced` takes a count and nothing else,
// and the base-instance form that would (glDrawArraysInstancedBaseInstance)
// is GL 4.2 and is NOT in WebGL 2. So the offset moves into the ATTRIBUTE
// POINTERS — re-bound per run at `b_first * INSTANCE_STRIDE` — which is the
// portable way and costs four calls per run rather than a wider draw call.
// One uniform pair per run, rather than N rewritten positions.
coff = view_offset(self, v, b.b_layer);
graphics::gl_set_uniform_vec2(p.pn_shader, "uCam", coff.p_x, coff.p_y);
base = b.b_first * INSTANCE_STRIDE;
graphics::gl_instance_attrib(p.pn_vao, p.pn_ivbo, 1, 4, INSTANCE_STRIDE, base + OFF_AFFINE);
graphics::gl_instance_attrib(p.pn_vao, p.pn_ivbo, 2, 2, INSTANCE_STRIDE, base + OFF_AFFINE + 4);
graphics::gl_instance_attrib(p.pn_vao, p.pn_ivbo, 3, 4, INSTANCE_STRIDE, base + OFF_CELL);
graphics::gl_instance_attrib(p.pn_vao, p.pn_ivbo, 4, 4, INSTANCE_STRIDE, base + OFF_RGBA);
graphics::gl_instance_attrib(p.pn_vao, p.pn_ivbo, 5, 2, INSTANCE_STRIDE, base + OFF_SWAY);
// The scissor is the view's rect intersected with the batch's clip —
// computed in one place so a view boundary and a clip boundary cannot
// be enforced by two different rules.
sc = scissor_of(self, v, b, coff);
if sc.b_x1 > sc.b_x0 and sc.b_y1 > sc.b_y0 {
graphics::gl_enable(graphics::GL_SCISSOR_TEST);
// Scissor is measured from the BOTTOM, the screen from the top.
graphics::gl_scissor(sc.b_x0 as integer,
(screen_h - sc.b_y1) as integer,
(sc.b_x1 - sc.b_x0) as integer,
(sc.b_y1 - sc.b_y0) as integer);
} else {
if vp.vw_w > 0 or b.b_clipped {
// Nothing of this run is visible in this view.
graphics::gl_enable(graphics::GL_SCISSOR_TEST);
graphics::gl_scissor(0, 0, 0, 0);
} else {
graphics::gl_disable(graphics::GL_SCISSOR_TEST);
}
}
graphics::gl_draw_instanced(p.pn_vao, 6, b.b_count);
calls = calls + 1;
}
}
calls
}
// Make (or remake) the offscreen colour target the world pass draws into.
//
// ⚠ It takes no `Stage`: an offscreen target is a Painter and a size, and
// nothing about the scene changes it. The parameter was there and unread.
fn ensure_light_target(p: Painter, w: integer, h: integer) {
if p.pn_light == 0 {
p.pn_light = graphics::gl_create_shader(VERT_LIGHT, FRAG_LIGHT);
}
if p.pn_fbo != 0 and p.pn_fw == w and p.pn_fh == h { return; }
p.pn_scene = graphics::gl_create_color_texture(w, h);
if p.pn_fbo == 0 { p.pn_fbo = graphics::gl_create_framebuffer(); }
graphics::gl_framebuffer_texture(p.pn_fbo, 0, p.pn_scene);
p.pn_fw = w;
p.pn_fh = h;
}
// One fullscreen multiply: the world pass times the light standing at each pixel.
//
// ⚠ THE CURVE AND THE FLOOR GO IN AS UNIFORMS, from the same `LIGHT_FALLOFF` and
// ambient the software path and every gate read. Nothing about the falloff is
// written in the shader's text.
fn gl_composite(self: const Stage, p: const Painter, screen_w: float, screen_h: float) {
graphics::gl_disable(graphics::GL_SCISSOR_TEST);
graphics::gl_disable(graphics::GL_BLEND);
graphics::gl_use_shader(p.pn_light);
graphics::gl_bind_texture(p.pn_scene, 0);
graphics::gl_set_uniform_int(p.pn_light, "uScene", 0);
graphics::gl_set_uniform_vec2(p.pn_light, "uScreenL", screen_w, screen_h);
off = view_offset(self, 0, 0);
graphics::gl_set_uniform_vec2(p.pn_light, "uCamL", 0.0 - off.p_x, 0.0 - off.p_y);
graphics::gl_set_uniform_float(p.pn_light, "uFloor", self.st_ambient);
graphics::gl_set_uniform_float(p.pn_light, "uFalloff", LIGHT_FALLOFF);
n = len(self.st_lights);
if n > 32 { n = 32; }
graphics::gl_set_uniform_int(p.pn_light, "uCount", n);
for i in 0..n {
l = self.st_lights[i];
graphics::gl_set_uniform_vec4(p.pn_light, "uLights[{i}]", l.lt_x, l.lt_y,
l.lt_r, l.lt_power);
graphics::gl_set_uniform_vec3(p.pn_light, "uLightCol[{i}]",
((l.lt_colour >> 16) & 255) as float / 255.0,
((l.lt_colour >> 8) & 255) as float / 255.0,
(l.lt_colour & 255) as float / 255.0);
}
graphics::gl_draw_fullscreen_quad();
graphics::gl_enable(graphics::GL_BLEND);
}
// The screen rect a run may paint in: the view's own rect, intersected with the
// batch's clip once that clip is moved by the same offset as its content.
//
// An unbounded rect is spelled as the whole target rather than as a flag, so the
// intersection is arithmetic in every case and the unsplit stage is the
// degenerate cell rather than a second path.
pub fn scissor_of(self: const Stage, view: integer, b: const Batch, coff: const P2) -> Bounds {
vp = view_at(self, view);
x0 = 0.0;
y0 = 0.0;
x1 = 1000000.0;
y1 = 1000000.0;
if vp.vw_w > 0 and vp.vw_h > 0 {
x0 = vp.vw_x as float;
y0 = vp.vw_y as float;
x1 = (vp.vw_x + vp.vw_w) as float;
y1 = (vp.vw_y + vp.vw_h) as float;
}
if b.b_clipped {
if b.b_cx0 + coff.p_x > x0 { x0 = b.b_cx0 + coff.p_x; }
if b.b_cy0 + coff.p_y > y0 { y0 = b.b_cy0 + coff.p_y; }
if b.b_cx1 + coff.p_x < x1 { x1 = b.b_cx1 + coff.p_x; }
if b.b_cy1 + coff.p_y < y1 { y1 = b.b_cy1 + coff.p_y; }
}
Bounds { b_x0: x0, b_y0: y0, b_x1: x1, b_y1: y1 }
}
// ── A4: picking and input routing ────────────────────────────────────────────
// Register an alpha mask and answer its index, for `Place.pl_mask`.
pub fn add_mask(self: Stage, w: integer, h: integer, alpha: const vector<integer>) -> integer {
self.st_masks += [Mask { m_w: w, m_h: h, m_alpha: alpha }];
len(self.st_masks) - 1
}
// Take a screen point into node `idx`'s own space.
//
// The world affine is 2x2 plus a translation, so the inverse is closed form —
// `det = a*d - c*b`, and no general 4x4 inversion is involved. A degenerate node
// (zero scale) has no inverse and answers its own origin rather than an infinity.
pub fn to_local(self: const Stage, idx: integer, sx: float, sy: float) -> P2 {
if idx < 0 or idx >= len(self.st_nodes) { return P2 { p_x: 0.0, p_y: 0.0 }; }
n = self.st_nodes[idx];
det = n.nd_a * n.nd_d - n.nd_c * n.nd_b;
if det > -0.000001 and det < 0.000001 {
return P2 { p_x: n.nd_ox, p_y: n.nd_oy };
}
dx = sx - n.nd_tx;
dy = sy - n.nd_ty;
P2 { p_x: (n.nd_d * dx - n.nd_c * dy) / det ?? 0.0,
p_y: (n.nd_a * dy - n.nd_b * dx) / det ?? 0.0 }
}
// Is this screen point ON node `idx` — inside its rect AND, where it has a mask,
// on a texel that is not a hole?
pub fn hits(self: const Stage, idx: integer, sx: float, sy: float) -> boolean {
hits_in(self, 0, idx, sx, sy)
}
// Is the node's own texel at this LOCAL point solid? Split out of `hits` so the
// per-view test uses the same sampling rather than a second copy of it.
fn solid_at(self: const Stage, idx: integer, u: float, v: float) -> boolean {
n = self.st_nodes[idx];
if n.nd_mask < 0 or n.nd_mask >= len(self.st_masks) { return true; }
// ⚠ Reached through a BOUNDED loop rather than `st_masks[n.nd_mask]`. A
// vector element indexed by an arbitrary integer types as NULLABLE, where a
// loop variable narrows to non-null — so the direct form needs a `??` default,
// and a default `Mask` would mint a store record on every hit test.
solid = true;
for mi in 0..len(self.st_masks) {
if mi != n.nd_mask { continue; }
m = self.st_masks[mi];
if m.m_w <= 0 or m.m_h <= 0 { break; }
mx = ((u / n.nd_w ?? 0.0) * (m.m_w as float)) as integer;
my = ((v / n.nd_h ?? 0.0) * (m.m_h as float)) as integer;
if mx < 0 { mx = 0; }
if my < 0 { my = 0; }
if mx >= m.m_w { mx = m.m_w - 1; }
if my >= m.m_h { my = m.m_h - 1; }
cover: integer = m.m_alpha[my * m.m_w + mx] ?? 0;
solid = cover > 0;
break;
}
solid
}
// The topmost node under this screen point, or -1.
// Example: @STG-006 — picking samples alpha, so a click falls through a hole.
//
// Walks the draw order BACKWARDS, so the node drawn last — the one on top — is
// tested first, and insertion order breaks a tie the same way it does when
// drawing. One order, two consumers: a pick that disagreed with the picture
// would be worse than no pick at all.
pub fn pick(self: const Stage, sx: float, sy: float) -> integer {
pick_in(self, 0, sx, sy)
}
// A press picks, and REMEMBERS.
pub fn press(self: Stage, sx: float, sy: float) -> integer {
hit = pick(self, sx, sy);
self.st_capture = hit;
// D1: the pointer is remembered so `ui_state` can answer without being told
// again where it is.
self.st_ptr_x = sx;
self.st_ptr_y = sy;
self.st_hover = hit;
// D2: a press moves the keyboard — onto the node if it can take it, and OFF
// whatever had it otherwise. Clicking away is how a field is left.
if hit >= 0 and focusable(self, hit) { self.st_focus = hit; } else { self.st_focus = -1; }
hit
}
// A release answers the node that took the PRESS, wherever the pointer is now.
// Example: @STG-005 — why a hit test alone is not input routing.
//
// ⚠ THE CAPTURE IS THE POINT, and it is the half everyone forgets. Without it a
// button fires when you press it and slide away, and never fires when you press it
// and drift a pixel — both of which feel broken and neither of which a rect test
// can explain. A release with nothing captured answers -1.
pub fn release(self: Stage, sx: float, sy: float) -> integer {
held = self.st_capture;
self.st_capture = -1;
self.st_ptr_x = sx;
self.st_ptr_y = sy;
self.st_hover = pick(self, sx, sy);
if held < 0 { return -1; }
held
}
// Did the release land on the node that was pressed? A button fires only when
// this is true; `release` still reports the captured node either way.
pub fn released_on(self: const Stage, idx: integer, sx: float, sy: float) -> boolean {
idx >= 0 and hits(self, idx, sx, sy)
}
// The node `press` is holding, or -1. A drag reads this to know what it is
// dragging — the capture survives the pointer leaving the node, which is what
// makes dragging off a button and back onto it work.
pub fn capture(self: const Stage) -> integer { self.st_capture }
// ── D1: the four states a widget shows, and the one predicate under them ─────
//
// ⚠⚠ **THE INVARIANT: a node reads `Down` exactly when a release at the current
// pointer would FIRE it.** The state and the firing decision are derived from
// the same test — `the press is captured here AND the pointer is still inside` —
// so the picture cannot promise something the click does not do. A kit that
// stored a `pressed` flag beside the capture would have two facts about one
// gesture, and the visible one drifts first.
//
// ⚠ **`press`-then-LEAVE-then-`release` must not fire**, and the capture is what
// makes that expressible: A4 remembers who took the press, so a release
// elsewhere can answer *nobody* rather than *whoever is under the pointer now*.
// The reverse — pressing and drifting a pixel — must still fire, and does.
//
// ⚠⚠ **ON TOUCH THERE IS NO `Over`.** A finger has no position until it
// touches, so a widget whose only affordance is hover is INVISIBLE on a phone.
// The kit cannot fix that for an author, but it can refuse to lie: under
// `POINTER_TOUCH` `ui_state` never answers `Over`, so a layout tested headlessly
// on a touch stream shows the same states the phone will.
//
// ⚠ The pointer path was already injectable — `press` / `release` take
// coordinates and read no device — so a replay harness needs no new seam here.
// (The KEY half is the `input` package's `input_tick_from_state`, which @PLN145
// `D0b` measured and cleared; nothing in this file needs a second one.)
// What a pointing device is. A mouse hovers; a finger does not exist until it
// touches.
pub const POINTER_MOUSE: integer = 0;
pub const POINTER_TOUCH: integer = 1;
// The four states a widget node can be in.
//
// ⚠ `Disabled` outranks the other three: a node that cannot be used must not
// look pressable even while the pointer is on it.
pub enum UiState {
Normal,
Over,
Down,
Disabled,
}
// Say whether the pointer is a mouse or a finger. Mouse is the default.
pub fn set_pointer(self: Stage, kind: integer) {
self.st_pointer = if kind == POINTER_TOUCH { POINTER_TOUCH } else { POINTER_MOUSE };
}
// Which kind is in force. `ui_state` reads it to decide whether `Over` is a
// state this device can show at all — a finger is never merely hovering.
pub fn pointer(self: const Stage) -> integer { self.st_pointer }
// The pointer moved without a button. A touch stream never sends this.
pub fn pointer_move(self: Stage, sx: float, sy: float) {
self.st_ptr_x = sx;
self.st_ptr_y = sy;
self.st_hover = pick(self, sx, sy);
}
// The node the pointer is over, or -1. Answers even under touch, where it is
// the node the finger is ON — `ui_state` is what declines to call that `Over`.
pub fn hovered(self: const Stage) -> integer { self.st_hover }
// Take a node out of use, or put it back.
pub fn set_enabled(self: Stage, idx: integer, on: boolean) {
if idx >= 0 {
if idx < len(self.st_nodes) { self.st_nodes[idx].nd_disabled = !on; }
}
}
// Is this node in use? False for an index out of range too, so a stale index
// reads as unusable rather than as a live widget.
pub fn enabled(self: const Stage, idx: integer) -> boolean {
if idx >= 0 {
if idx < len(self.st_nodes) { return !self.st_nodes[idx].nd_disabled; }
}
false
}
// Would a release right now fire this node? THE predicate — `ui_state` and
// `click` are both it, which is why they cannot disagree.
fn armed_on(self: const Stage, idx: integer) -> boolean {
if idx < 0 or idx >= len(self.st_nodes) { return false; }
if self.st_nodes[idx].nd_disabled { return false; }
if self.st_capture != idx { return false; }
hits(self, idx, self.st_ptr_x, self.st_ptr_y)
}
// What this node looks like right now.
pub fn ui_state(self: const Stage, idx: integer) -> UiState {
if idx < 0 or idx >= len(self.st_nodes) { return UiState.Normal; }
if self.st_nodes[idx].nd_disabled { return UiState.Disabled; }
if armed_on(self, idx) { return UiState.Down; }
// ⚠ A press in flight ANYWHERE suppresses `Over` everywhere: dragging off a
// button and across its neighbour must not light the neighbour up.
if self.st_capture >= 0 { return UiState.Normal; }
if self.st_pointer == POINTER_TOUCH { return UiState.Normal; }
if self.st_hover == idx { return UiState.Over; }
UiState.Normal
}
// The release door for a widget: answers the node that FIRES, or -1.
//
// ⚠ **Use this OR `release`, not both** — either ends the press. `release`
// answers *who took the press* and leaves the fired/not decision to the caller;
// this answers the question a button actually asks. It exists because combining
// `release` with `released_on` by hand is where press-then-leave-then-release
// goes wrong, and that is a mistake a library can simply not offer.
pub fn click(self: Stage, sx: float, sy: float) -> integer {
held = self.st_capture;
self.st_ptr_x = sx;
self.st_ptr_y = sy;
fired = -1;
if armed_on(self, held) { fired = held; }
self.st_capture = -1;
self.st_hover = pick(self, sx, sy);
fired
}
// ── D2: focus, tab order, and an editable text field ────────────────────────
//
// ⚠⚠ **EVERY INDEX HERE IS A CHARACTER INDEX, AND NO BYTE INDEX EVER ENTERS.**
// That is the whole answer to a trap this stack has now met three times:
// `len(text)` counts CHARACTERS while `s[a..b]` slices BYTES, and loft snaps a
// byte cut outward — so a count applied as a range fits FEWER characters than it
// measured, silently, and only in text that is not ASCII. A caret IS a count.
// Storing it as a byte offset would put that trap inside the edit loop, where
// every keystroke walks over it.
//
// ⚠ **ONE `splice` DOES EVERY EDIT.** Insert, backspace, delete, replace the
// selection and paste are all *"replace characters [from, to) with this"*. Five
// hand-written walks over the same string would be five chances to get the
// character counting wrong; there is one, and every editing key routes through
// it.
//
// ⚠⚠ **THE MODIFIER PROBLEM THIS PHASE WAS WARNED ABOUT DOES NOT EXIST.**
// @PLN145 `D0b` measured that `input`'s `Bindings` cannot express a modifier —
// an `ActionBinding` is a name and a list of key codes — and the plan therefore
// asked whether this phase must carry a ctrl rule of its own. It must not:
// `graphics`'s EVENT QUEUE already delivers `gl_event_mods()` per event
// (`MOD_SHIFT | MOD_CTRL | MOD_ALT | MOD_SUPER`). A text field does not want
// ACTIONS — it wants *this keystroke, with these modifiers, in this order* —
// and that is exactly what the queue carries. The two findings do not
// conflict: a binding table is the wrong instrument for an editor, not a
// broken one.
//
// ⚠ `key_event` takes `(key, mods)` as plain integers rather than reading the
// queue itself, so the whole model is backend-independent and replayable. That
// also makes the Android question — NativeActivity delivers `KeyEvent`s and may
// not deliver `EV_TEXT` — a question for `graphics` rather than for this file:
// feed the text in from wherever it comes.
//
// ⚠ The clipboard is OURS, not the system's. `graphics` exposes no clipboard at
// all, so cut/copy/paste round-trips inside the program and says so rather than
// pretending to reach the desktop.
//
// ⚠ **What is NOT here:** no undo, no multi-line, no word-wise motion, and no
// IME *composition* state — `text_event` takes text that is already committed,
// which is what `EV_TEXT` delivers. And **no pixels**: the caret is a character
// index, so turning it into an x-coordinate is `text2d`'s measurement
// (`Metrics.width(take_chars(buf, caret))`) and stays with whoever draws.
// One node's editable text. Carried BESIDE the nodes rather than inside them,
// so a scene full of sprites pays nothing for a feature two of them use.
struct NodeField {
nf_node: integer,
nf_buf: text,
nf_caret: integer, // CHARACTER index, in [0, len(buf)]
nf_mark: integer, // the selection anchor, same units
}
// Replace characters `[from, to)` of `s` with `ins` — THE edit primitive.
//
// ⚠ `from == len(s)` appends, which the loop cannot see because it stops at
// `len - 1`; the tail test is what catches that case.
fn splice(s: text, from: integer, to: integer, ins: text) -> text {
out = "";
k = 0;
for c in s {
if k == from { out += ins; }
if k < from or k >= to { out += "{c}"; }
k = k + 1;
}
if from >= k { out += ins; }
out
}
// Characters `[from, to)` of `s`.
fn chars_between(s: text, from: integer, to: integer) -> text {
out = "";
k = 0;
for c in s {
if k >= from and k < to { out += "{c}"; }
k = k + 1;
}
out
}
// Can this node hold the keyboard?
pub fn focusable(self: const Stage, idx: integer) -> boolean {
if idx >= 0 {
if idx < len(self.st_nodes) {
return self.st_nodes[idx].nd_focusable and !self.st_nodes[idx].nd_disabled;
}
}
false
}
// Put a node into the tab order, or take it out. `focus_next` / `focus_prev`
// walk exactly the nodes this says yes to, in declaration order.
pub fn set_focusable(self: Stage, idx: integer, on: boolean) {
if idx >= 0 {
if idx < len(self.st_nodes) { self.st_nodes[idx].nd_focusable = on; }
}
}
// Which node has the keyboard, or -1.
pub fn focused(self: const Stage) -> integer { self.st_focus }
// Give it the keyboard. A node that cannot take it clears the focus instead, so
// this never leaves the keyboard somewhere unreachable.
pub fn set_focus(self: Stage, idx: integer) {
if focusable(self, idx) { self.st_focus = idx; return; }
self.st_focus = -1;
}
// ⚠ TAB ORDER IS THE DECLARED ORDER — the order the nodes were added, which is
// the order a reader of the building code sees. A separate `tab_index` would be
// a second ordering to keep in step with the first, and the two disagree the
// moment someone inserts a node.
pub fn focus_next(self: Stage) { focus_step(self, 1); }
// One step back through that same order — the same walk reversed, so the two
// directions cannot disagree about what is focusable.
pub fn focus_prev(self: Stage) { focus_step(self, -1); }
fn focus_step(self: Stage, dir: integer) {
n = len(self.st_nodes);
if n <= 0 { return; }
// With nothing focused, forward starts BEFORE the first and backward AFTER
// the last, so both land where a reader expects rather than one of them
// skipping an end.
start = self.st_focus;
if start < 0 { start = if dir > 0 { -1 } else { n }; }
for step in 1..=n {
cand = (((start + dir * step) % n) + n) % n ?? 0;
if focusable(self, cand) { self.st_focus = cand; return; }
}
// Nothing in the scene can take it; leave the focus where it was.
}
// Make this node an editable field holding `s`, and give it the tab order.
pub fn set_field(self: Stage, idx: integer, s: text) {
if idx < 0 or idx >= len(self.st_nodes) { return; }
self.st_nodes[idx].nd_focusable = true;
at = field_at(self, idx);
n = len(s);
if at >= 0 {
set_buf(self, at, s);
set_caret(self, at, n);
set_mark(self, at, n);
return;
}
self.st_fields += [NodeField { nf_node: idx, nf_buf: s, nf_caret: n, nf_mark: n }];
}
fn field_at(self: const Stage, idx: integer) -> integer {
if idx < 0 { return -1; }
for f in self.st_fields {
if f.nf_node == idx { return f#index; }
}
-1
}
// ── The six guarded ways to touch a field's record.
//
// ⚠ Every read and write of `st_fields` goes through one of these, so the bounds
// test is written ONCE per accessor instead of at each of the twenty-odd places
// an editing key touches the record.
fn buf_of(self: const Stage, at: integer) -> text {
if at >= 0 {
if at < len(self.st_fields) { return self.st_fields[at].nf_buf; }
}
""
}
fn caret_of(self: const Stage, at: integer) -> integer {
if at >= 0 {
if at < len(self.st_fields) { return self.st_fields[at].nf_caret; }
}
0
}
fn mark_of(self: const Stage, at: integer) -> integer {
if at >= 0 {
if at < len(self.st_fields) { return self.st_fields[at].nf_mark; }
}
0
}
fn set_buf(self: Stage, at: integer, v: text) {
if at >= 0 {
if at < len(self.st_fields) { self.st_fields[at].nf_buf = v; }
}
}
fn set_caret(self: Stage, at: integer, v: integer) {
if at >= 0 {
if at < len(self.st_fields) { self.st_fields[at].nf_caret = v; }
}
}
fn set_mark(self: Stage, at: integer, v: integer) {
if at >= 0 {
if at < len(self.st_fields) { self.st_fields[at].nf_mark = v; }
}
}
// Does this node carry an editable text field? Every `field_*` reader answers
// a harmless default where this is false, so asking first is optional.
pub fn is_field(self: const Stage, idx: integer) -> boolean { field_at(self, idx) >= 0 }
// What the field holds right now. The buffer is the truth; the caret and the
// mark are positions into it, counted in CHARACTERS.
pub fn field_text(self: const Stage, idx: integer) -> text {
buf_of(self, field_at(self, idx))
}
// Where the caret is, counted in CHARACTERS.
pub fn field_caret(self: const Stage, idx: integer) -> integer {
caret_of(self, field_at(self, idx))
}
// The selection anchor. Equal to the caret when nothing is selected.
pub fn field_mark(self: const Stage, idx: integer) -> integer {
mark_of(self, field_at(self, idx))
}
// The selected text, or "" when the caret is a point.
pub fn field_selection(self: const Stage, idx: integer) -> text {
at = field_at(self, idx);
chars_between(buf_of(self, at), min(caret_of(self, at), mark_of(self, at)),
max(caret_of(self, at), mark_of(self, at)))
}
// Put the caret at a CHARACTER index — what a click resolves to once whoever
// draws the text has turned an x-coordinate into a count.
pub fn field_place_caret(self: Stage, idx: integer, at_char: integer, extend: boolean) {
at = field_at(self, idx);
if at < 0 { return; }
c = clamp(at_char, 0, len(buf_of(self, at)));
set_caret(self, at, c);
if !extend { set_mark(self, at, c); }
}
// Our own clipboard — see the header.
pub fn clipboard(self: const Stage) -> text { self.st_clip }
// Fill it — how the host's clipboard reaches a paste.
pub fn set_clipboard(self: Stage, s: text) { self.st_clip = s; }
// Commit text into the focused field, replacing any selection.
//
// ⚠ This is where IME text arrives: `EV_TEXT` delivers a whole COMMITTED string,
// which may be several characters and is very often not ASCII.
pub fn text_event(self: Stage, s: text) {
at = field_at(self, self.st_focus);
if at < 0 { return; }
if s == "" { return; }
replace_selection(self, at, s);
}
fn replace_selection(self: Stage, at: integer, ins: text) {
lo = min(caret_of(self, at), mark_of(self, at));
hi = max(caret_of(self, at), mark_of(self, at));
set_buf(self, at, splice(buf_of(self, at), lo, hi, ins));
c = lo + len(ins);
set_caret(self, at, c);
set_mark(self, at, c);
}
// A key, with the modifiers that arrived WITH it. Tab moves the focus; every
// other key goes to the focused field.
pub fn key_event(self: Stage, key: integer, mods: integer) {
if key == graphics::KEY_TAB {
if (mods & graphics::MOD_SHIFT) != 0 { focus_prev(self); } else { focus_next(self); }
return;
}
at = field_at(self, self.st_focus);
if at < 0 { return; }
shift = (mods & graphics::MOD_SHIFT) != 0;
n = len(buf_of(self, at));
caret = caret_of(self, at);
lo = min(caret, mark_of(self, at));
hi = max(caret, mark_of(self, at));
if (mods & graphics::MOD_CTRL) != 0 {
if key == graphics::KEY_A { set_mark(self, at, 0); set_caret(self, at, n); return; }
if key == KEY_COPY {
if lo != hi { self.st_clip = chars_between(buf_of(self, at), lo, hi); }
return;
}
if key == KEY_CUT {
if lo != hi {
self.st_clip = chars_between(buf_of(self, at), lo, hi);
replace_selection(self, at, "");
}
return;
}
if key == KEY_PASTE { replace_selection(self, at, self.st_clip); }
return;
}
if key == graphics::KEY_BACKSPACE {
if lo != hi { replace_selection(self, at, ""); return; }
if caret > 0 {
set_buf(self, at, splice(buf_of(self, at), caret - 1, caret, ""));
set_caret(self, at, caret - 1);
set_mark(self, at, caret - 1);
}
return;
}
if key == graphics::KEY_DELETE {
if lo != hi { replace_selection(self, at, ""); return; }
if caret < n { set_buf(self, at, splice(buf_of(self, at), caret, caret + 1, "")); }
return;
}
if key == graphics::KEY_LEFT {
// ⚠ Without shift a LIVE SELECTION collapses to its near end rather than
// stepping one further — pressing left with three characters selected
// must land at the START of them, not before the start.
if shift { set_caret(self, at, max(caret - 1, 0)); return; }
c = if lo != hi { lo } else { max(caret - 1, 0) };
set_caret(self, at, c);
set_mark(self, at, c);
return;
}
if key == graphics::KEY_RIGHT {
if shift { set_caret(self, at, min(caret + 1, n)); return; }
c = if lo != hi { hi } else { min(caret + 1, n) };
set_caret(self, at, c);
set_mark(self, at, c);
return;
}
if key == graphics::KEY_HOME {
set_caret(self, at, 0);
if !shift { set_mark(self, at, 0); }
return;
}
if key == graphics::KEY_END {
set_caret(self, at, n);
if !shift { set_mark(self, at, n); }
}
}
// The three editing keys `graphics` does not name. Loft's key constants ARE the
// lowercase ASCII codes, so these follow the same convention.
pub const KEY_CUT: integer = 120; // 'x'
pub const KEY_COPY: integer = 99; // 'c'
pub const KEY_PASTE: integer = 118; // 'v'
// ── A5: compositing ──────────────────────────────────────────────────────────
// Composite one premultiplied source over a destination channel.
//
// `src` is ALREADY multiplied by `ia`, so this is `src + dst * (1 - a)` in integer
// arithmetic, rounded to nearest. Straight alpha would need `src * a` here and
// would darken an anti-aliased edge under linear filtering — the reason the whole
// path is premultiplied.
// Linear blend of two channels, `t` in 0..255.
fn mix(a: integer, b: integer, t: integer) -> integer {
(a * (255 - t) + b * t + 127) / 255 ?? 0
}
fn over(src: integer, dst: integer, ia: integer) -> integer {
(src * 255 + dst * (255 - ia) + 127) / 255 ?? 0
}
// Paint the scene onto a software canvas, honouring each node's alpha and tint.
//
// `render(list, canvas)` stays the path for a `lavition_ui` command list, which
// carries 0xRRGGBB and no alpha by design. This is the stage's own path, where a
// node has an alpha to respect — and the two must agree wherever alpha is 1.
pub fn render_stage(self: const Stage, cv: graphics::Canvas) {
render_view(self, 0, cv);
}
// Paint one VIEW of the scene: the same nodes, through that view's camera, and
// confined to its own screen rect.
//
// ⚠ THE VIEWPORT NARROWS THE SPAN, it does not test each pixel — the same
// technique A6's clip uses, so the boundary falls where the arithmetic puts it
// rather than where a per-pixel predicate rounds it, and the two cannot disagree
// about a shared edge.
pub fn render_view(self: const Stage, view: integer, cv: graphics::Canvas) {
// ⚠ THREE PHASES, and the order IS the feature: world, composite, HUD. With
// the map off `st_hud_layer` is the no-HUD sentinel and both passes collapse
// into the single walk L1 had, so the unlit game keeps exactly its pixels.
if self.st_light_map {
render_band(self, view, cv, 0 - HUD_NONE, self.st_hud_layer);
composite_light(self, view, cv);
render_band(self, view, cv, self.st_hud_layer, HUD_NONE);
return;
}
render_band(self, view, cv, 0 - HUD_NONE, HUD_NONE);
}
// Paint the nodes whose layer is in `[lo, hi)`, blurring as each layer finishes.
//
// ⚠ THE BLUR IS INTERLEAVED WITH THE WALK, not applied at the end. That is what
// puts it BEFORE the layers above composite, so a sharp foreground over a hazy
// distance is possible at all — the property a fullscreen blur cannot give.
fn render_band(self: const Stage, view: integer, cv: graphics::Canvas,
lo: integer, hi: integer) {
// ⚠ PAINT ONE LAYER, THEN BLUR, THEN THE NEXT. Detecting the boundaries in
// one pass and painting in another only LOOKS interleaved: the blur then runs
// before anything is drawn and the whole feature is silent. `st_order` is
// sorted by layer first, so its distinct layers arrive in drawing order.
prev = 0 - HUD_NONE;
started = false;
for oi in self.st_order {
ln = self.st_nodes[oi].nd_layer;
if ln < lo or ln >= hi { continue; }
if started and ln == prev { continue; }
if started { blur_layer(self, view, cv, prev); }
paint_band(self, view, cv, ln, ln + 1);
prev = ln;
started = true;
}
if started { blur_layer(self, view, cv, prev); }
}
// Blur what has been drawn so far, if this layer asks for it.
fn blur_layer(self: const Stage, view: integer, cv: graphics::Canvas, layer: integer) {
r = layer_blur(self, layer);
if r <= 0 { return; }
vp = view_at(self, view);
x0 = 0;
y0 = 0;
x1 = cv.width;
y1 = cv.height;
if vp.vw_w > 0 and vp.vw_h > 0 {
x0 = vp.vw_x;
y0 = vp.vw_y;
if vp.vw_x + vp.vw_w < x1 { x1 = vp.vw_x + vp.vw_w; }
if vp.vw_y + vp.vw_h < y1 { y1 = vp.vw_y + vp.vw_h; }
}
if x0 < 0 { x0 = 0; }
if y0 < 0 { y0 = 0; }
blur_region(cv, x0, y0, x1, y1, r);
}
fn ly_density_of(self: const Stage, layer: integer) -> float {
layer_at(self, layer).ly_density
}
fn ly_fog_of(self: const Stage, layer: integer) -> integer {
layer_at(self, layer).ly_fog
}
fn paint_band(self: const Stage, view: integer, cv: graphics::Canvas,
lo: integer, hi: integer) {
vpx = view_at(self, view).vw_x;
vpy = view_at(self, view).vw_y;
vpw = view_at(self, view).vw_w;
vph = view_at(self, view).vw_h;
for i in self.st_order {
n = self.st_nodes[i];
if n.nd_layer < lo or n.nd_layer >= hi { continue; }
if !n.nd_visible or n.nd_w <= 0.0 or n.nd_h <= 0.0 { continue; }
if n.nd_alpha <= 0.0 { continue; }
ia = (n.nd_alpha * 255.0 + 0.5) as integer;
if ia > 255 { ia = 255; }
if ia < 0 { ia = 0; }
// Fade toward the haze colour before compositing, so distance reads as less
// distinct as well as smaller.
hz = (n.nd_cue * self.st_cue_haze * 255.0 + 0.5) as integer;
if hz < 0 { hz = 0; }
if hz > 255 { hz = 255; }
lc = lit_colour(self, i);
cr = mix((lc >> 16) & 255, (self.st_haze_colour >> 16) & 255, hz);
cg = mix((lc >> 8) & 255, (self.st_haze_colour >> 8) & 255, hz);
cb = mix(lc & 255, self.st_haze_colour & 255, hz);
// ⚠ The LAYER's fog, after the depth cue's haze and in the same mix:
// one is per-node by distance, the other a flat band, and they compose
// rather than compete. Density 0 makes `fg` zero, so an unfogged layer
// takes the identity path and is bit-identical.
fg = (ly_density_of(self, n.nd_layer) * 255.0 + 0.5) as integer;
if fg > 0 {
if fg > 255 { fg = 255; }
fc = ly_fog_of(self, n.nd_layer);
cr = mix(cr, (fc >> 16) & 255, fg);
cg = mix(cg, (fc >> 8) & 255, fg);
cb = mix(cb, fc & 255, fg);
}
sr = (cr * ia + 127) / 255 ?? 0;
sg = (cg * ia + 127) / 255 ?? 0;
sb = (cb * ia + 127) / 255 ?? 0;
o = world_point(self, i, 0.0, 0.0);
far = world_point(self, i, n.nd_w, n.nd_h);
off = view_offset(self, view, n.nd_layer);
sw = sway_of(self, i);
x0 = (o.p_x + off.p_x + sw) as integer;
y0 = (o.p_y + off.p_y) as integer;
x1 = (far.p_x + off.p_x + sw) as integer;
y1 = (far.p_y + off.p_y) as integer;
if x1 < x0 { t = x0; x0 = x1; x1 = t; }
if y1 < y0 { t2 = y0; y0 = y1; y1 = t2; }
// The clip is applied by NARROWING THE SPAN, not by testing each pixel:
// the boundary then falls where the arithmetic puts it rather than where a
// per-pixel predicate rounds it.
// ⚠ THE CLIP TAKES THE SAME OFFSET AS THE CONTENT. It is derived in WORLD
// space by `compose`, while what it cuts has already been moved by the
// camera and the viewport — so applying it raw cut the wrong region, and
// a pan of more than the node's own width cut ALL of it: a clipped panel
// silently disappeared the moment the camera moved. A6 and P2 each
// shipped green because no test moved a camera across a clip.
if n.nd_clipped {
k0 = (n.nd_cx0 + off.p_x) as integer;
k1 = (n.nd_cx1 + off.p_x) as integer;
l0 = (n.nd_cy0 + off.p_y) as integer;
l1 = (n.nd_cy1 + off.p_y) as integer;
if k0 > x0 { x0 = k0; }
if l0 > y0 { y0 = l0; }
if k1 < x1 { x1 = k1; }
if l1 < y1 { y1 = l1; }
}
// The view's own rect, narrowed the same way. An unbounded view (width
// or height 0) confines nothing, which is the unsplit stage.
if vpw > 0 and vph > 0 {
if vpx > x0 { x0 = vpx; }
if vpy > y0 { y0 = vpy; }
if vpx + vpw < x1 { x1 = vpx + vpw; }
if vpy + vph < y1 { y1 = vpy + vph; }
}
for py in y0..y1 {
for px in x0..x1 {
d = cv.get_pixel(px, py);
dr = (d >> 16) & 255;
dg = (d >> 8) & 255;
db = d & 255;
cv.set_pixel(px, py, 0xff000000
| (over(sr, dr, ia) << 16)
| (over(sg, dg, ia) << 8)
| over(sb, db, ia));
}
}
}
}
// ── P2: the camera ───────────────────────────────────────────────────────────
//
// ⚠ THE CAMERA IS APPLIED AT DRAW TIME, NEVER BAKED INTO NODE POSITIONS. The
// three knobs already let an app do parallax by recomputing every node each frame
// — and that is an O(N) rewrite per scrolled frame AND a full instance re-upload,
// which is the retained tree's whole budget spent on standing still. As an offset
// per run it costs one pair of floats.
//
// ⚠ AND IT TRANSLATES, IT DOES NOT SCALE. Scaling by parallax is a perspective
// projection and would resize the footprint a base-anchored sprite is aligned to.
// Distance-as-smaller is the depth CUE's job (`depth_cue`), which scales about the
// origin and therefore keeps the feet planted; parallax only moves things.
// Move the camera. Does NOT dirty the instance buffer — that is the point.
//
// An unsplit stage IS a one-view stage, so this is view 0's camera rather than a
// second place a camera can live — the same move P2 made with parallax, where
// flat scrolling is every factor at 1.0 instead of a separate path.
pub fn set_camera(self: Stage, x: float, y: float) { set_view_camera(self, 0, x, y); }
// Where the camera stands. An unsplit stage IS a one-view stage, so this is
// view 0 rather than a second place a camera can live.
pub fn camera_x(self: const Stage) -> float { view_camera_x(self, 0) }
// The other half of the same pair.
pub fn camera_y(self: const Stage) -> float { view_camera_y(self, 0) }
// How much of the camera a layer feels. 1.0 is the world; below that is a
// background that lags; above it a foreground that leads. Flat scrolling is every
// factor at 1.0, which is the degenerate case rather than a second code path.
pub fn set_parallax(self: Stage, layer: integer, factor: float) {
grow_layers(self, layer);
if layer < 0 { return; }
self.st_layers[layer].ly_parallax = factor;
}
// What a layer's factor is now. A layer nobody configured reads 1.0 — the
// world's own rate — so asking about one is always safe.
pub fn parallax_of(self: const Stage, layer: integer) -> float {
layer_at(self, layer).ly_parallax
}
// Make sure `st_layers` reaches `layer`, filling with the plain layer.
fn grow_layers(self: Stage, layer: integer) {
if layer < 0 { return; }
while len(self.st_layers) <= layer {
self.st_layers += [Layer { ly_parallax: 1.0, ly_fog: 0, ly_density: 0.0,
ly_blur: 0 }];
}
}
// ⚠ ALWAYS A FRESH RECORD, on every path. Answering `self.st_layers[layer]`
// directly would be a borrow on one branch and a literal on the other — the
// mixed verdict that corrupted the store on `--native` in P6 (loft#1017).
fn layer_at(self: const Stage, layer: integer) -> Layer {
if layer < 0 or layer >= len(self.st_layers) {
return Layer { ly_parallax: 1.0, ly_fog: 0, ly_density: 0.0, ly_blur: 0 };
}
l = self.st_layers[layer] ?? Layer { ly_parallax: 1.0, ly_fog: 0,
ly_density: 0.0, ly_blur: 0 };
Layer { ly_parallax: l.ly_parallax, ly_fog: l.ly_fog, ly_density: l.ly_density,
ly_blur: l.ly_blur }
}
// The screen-space offset a layer's content is drawn at, in view 0.
pub fn cam_offset(self: const Stage, layer: integer) -> P2 {
view_offset(self, 0, layer)
}
// How many times the instance buffer has been written. A pure camera move must
// not move this — the property the whole design turns on.
pub fn uploads(self: const Stage) -> integer { self.st_uploads }
// ── P3: ambient motion ───────────────────────────────────────────────────────
//
// ⚠ NOTHING PER FRAME AT ALL. A field of grass, a canopy, a banner: the phase is
// derived from POSITION and the animation from a single clock, so five hundred
// swaying tufts are one buffer uploaded once and one uniform that changes. This is
// the limit of what `P4`'s frame-index attribute exists to keep cheap — there, one
// integer per animating sprite; here, zero.
//
// ⚠ AND IT IS VISUAL ONLY. Sway displaces what is DRAWN and never the footprint:
// a swaying tree keeps its depth, its bounds and its hit area, because a sprite
// that re-sorted as it swayed would flicker past its neighbours, and grass that
// dodged the pointer would be worse than grass that ignored it.
// Example: @STG-008 — ambient motion is free, out of step, and visual only.
// Advance the ambient clock. Does NOT dirty the instance buffer — that is the
// whole point of deriving the phase rather than stepping it.
pub fn set_time(self: Stage, t: float) { self.st_time = t; }
// The ambient clock, which is the `t` every sway phase is derived from.
pub fn time_of(self: const Stage) -> float { self.st_time }
// How far node `idx` is displaced right now, in pixels. Bounded by its amplitude
// by construction, since it is an amplitude times a sine.
pub fn sway_of(self: const Stage, idx: integer) -> float {
if idx < 0 or idx >= len(self.st_nodes) { return 0.0; }
n = self.st_nodes[idx];
if n.nd_sway == 0.0 { return 0.0; }
n.nd_sway * sin(self.st_time + n.nd_phase)
}
// ── P4: animation ────────────────────────────────────────────────────────────
//
// ⚠ ADVANCED FROM A STEP, NEVER FROM WALL TIME. `advance` takes the simulation's
// own delta, so a cycle is identical at any frame rate and replayable under a
// recorded input stream — the property co-op determinism rests on. A clock read
// inside the library would quietly break both.
//
// ⚠ AND THE DELTA IS INTEGER MICROSECONDS, not seconds. Accumulating a float
// drifts: `0.1` added eight times is `0.7999…`, which truncates to frame 7 where 8
// is right, and two 1/60 steps do not equal one 1/30 step. Both failures showed up
// the first time this was gated. Integer units are what `fixstep` speaks and what
// makes "the same total elapsed gives the same frame" true rather than nearly true.
// Register a sequence and answer its index, for `Place.pl_seq`.
pub fn add_sequence(self: Stage, first: integer, count: integer, fps: float,
mode: integer) -> integer {
push_sequence(self, "", first, count, fps, mode)
}
// The one place a `Sequence` is built, so its name arrives with the rest of it
// and cannot be attached — or forgotten — afterwards.
fn push_sequence(self: Stage, name: text, first: integer, count: integer,
fps: float, mode: integer) -> integer {
self.st_seqs += [Sequence { q_first: first,
q_count: if count < 1 { 1 } else { count },
q_fps: fps, q_mode: mode, q_name: name }];
len(self.st_seqs) - 1
}
// Step every animated node by `dt_us` MICROSECONDS — the simulation's own delta,
// not a clock this library reads.
//
// ⚠ Dirties the instance buffer, unlike the camera and the ambient clock — an
// animated sprite genuinely changes one number per frame. That number is a FRAME
// INDEX and not a uv rect, so it is one float and not four.
pub fn advance(self: Stage, dt_us: integer) {
moved = false;
for i in 0..len(self.st_nodes) {
if self.st_nodes[i].nd_seq >= 0 {
self.st_nodes[i].nd_elapsed = self.st_nodes[i].nd_elapsed + dt_us;
moved = true;
}
}
// ⚠ The property tweens ride THIS call rather than one of their own, which is
// what stops a scene from owning two clocks — see § C2 below.
if step_tweens(self, dt_us) { moved = true; }
if moved { self.st_dirty = true; }
}
// Which cell node `idx` shows right now.
pub fn frame_of(self: const Stage, idx: integer) -> integer {
if idx < 0 or idx >= len(self.st_nodes) { return 0; }
n = self.st_nodes[idx];
if n.nd_seq < 0 or n.nd_seq >= len(self.st_seqs) { return 0; }
// ⚠ Indexed, not scanned. `pack_instances` asks this once per node per
// frame, so searching the sequence table would put the number of SEQUENCES
// inside the loop over SPRITES — the per-frame cost this phase's design call
// exists to hold at one number per sprite. The bounds test above is what
// makes the fallback unreachable.
// ⚠ The fallback is a LITERAL, not a call. A call in this position leaks the
// record it returns on an index miss (loft#1013, both backends), and the
// compiler's refusal of a struct-valued constant prescribes exactly that
// leaking form. A literal is the shape that does not. It stays unreachable:
// the bounds test above is what decides that, not this line.
q = self.st_seqs[n.nd_seq] ?? Sequence { q_first: 0, q_count: 1, q_fps: 0.0,
q_mode: LOOP, q_name: "" };
raw = ((n.nd_elapsed as float) * q.q_fps / 1000000.0) as integer;
if raw < 0 { raw = 0; }
if q.q_count <= 1 { return q.q_first; }
if q.q_mode == ONCE {
if raw > q.q_count - 1 { raw = q.q_count - 1; }
return q.q_first + raw;
}
if q.q_mode == PINGPONG {
// ⚠ The period is 2n-2, NOT 2n: the two END frames are each shown once
// per cycle, not twice. A period of 2n stutters at both ends, which is
// the classic off-by-one in a ping-pong and reads as a limp.
period = q.q_count * 2 - 2;
pp = raw % period ?? 0;
if pp < q.q_count { return q.q_first + pp; }
return q.q_first + period - pp;
}
q.q_first + (raw % q.q_count ?? 0)
}
// Reset a node to the start of its sequence.
pub fn restart(self: Stage, idx: integer) {
if idx < 0 or idx >= len(self.st_nodes) { return; }
self.st_nodes[idx].nd_elapsed = 0;
self.st_dirty = true;
}
// ── C2: a node's numbers, read, written, and moved over time ────────────────
//
// ⚠⚠ **THE WRITE SWITCH IS THE POINT, AND THERE IS EXACTLY ONE OF IT.** loft has
// no property references, so "tween `node.x`" cannot be a pointer to a field — it
// has to be a name the library switches on. `write_prop` is that switch, and
// both doors go through it: `set_prop` writes now, a tween writes every frame.
// Two switches would be two spellings of *what X means* that can disagree.
//
// ⚠ **`Prop.X` is the first public way to MOVE a node at all.** Before this the
// stage could place a node and never shift it — a consumer had to reach into
// `st_nodes[i].nd_x` itself, which is a field write no gate could see.
//
// ⚠⚠ **AT MOST ONE TWEEN PER (NODE, PROPERTY), and starting another REPLACES it.**
// That is the invariant this section exists to hold, and it is structural rather
// than checked: two tweens on one property are two writers of one number, and the
// answer would depend on which happened to be later in a vector. Replacement is
// also what a caller means — "now go there instead" — so the safe rule and the
// expected one are the same rule.
//
// ⚠ **A finished tween stops writing.** It stays in the table so
// `prop_tweening` can answer, but it no longer touches the node — otherwise it
// would keep re-asserting its end value every frame and silently undo a later
// `set_prop`, which is the same two-writers defect one move down the line.
//
// ⚠ **The unit is the one `advance` already takes: MICROSECONDS.** A duration
// here is `dt_us`, not `tween`'s own unit-agnostic count and not `fixstep`'s
// 3 000 000-per-second base — because the tween rides `advance`, and a scene that
// had to convert between two units to move a sprite would be carrying two clocks.
// A consumer on `fixstep` converts once, where it already converts for `advance`.
// Which of a node's numbers a tween drives.
//
// ⚠ Every one is a `float` the node already stores. Colour, atlas and the
// sequence are not here on purpose: they are not numbers you can be *half way*
// between, and interpolating them would produce values the node never holds.
pub enum Prop {
X, Y,
Rot,
ScaleX, ScaleY,
OriginX, OriginY,
Alpha,
Depth,
}
// One property of one node, moving.
struct NodeTween {
nt_node: integer,
nt_prop: Prop = Prop.X,
nt_tw: Tween,
}
// What that property reads right now.
pub fn prop_of(self: const Stage, idx: integer, p: Prop) -> float {
if idx >= 0 {
if idx < len(self.st_nodes) {
n = self.st_nodes[idx];
return match p {
X => n.nd_x,
Y => n.nd_y,
Rot => n.nd_rot,
ScaleX => n.nd_sx,
ScaleY => n.nd_sy,
OriginX => n.nd_ox,
OriginY => n.nd_oy,
Alpha => n.nd_alpha,
Depth => n.nd_depth,
};
}
}
0.0
}
// Write it now. The immediate door — the tween door writes through the same
// switch, one frame at a time.
pub fn set_prop(self: Stage, idx: integer, p: Prop, v: float) {
write_prop(self, idx, p, v);
self.st_dirty = true;
}
// THE SWITCH. Every write to a node's number goes through here.
fn write_prop(self: Stage, idx: integer, p: Prop, v: float) {
if idx >= 0 {
if idx < len(self.st_nodes) {
match p {
X => self.st_nodes[idx].nd_x = v,
Y => self.st_nodes[idx].nd_y = v,
Rot => self.st_nodes[idx].nd_rot = v,
ScaleX => self.st_nodes[idx].nd_sx = v,
ScaleY => self.st_nodes[idx].nd_sy = v,
OriginX => self.st_nodes[idx].nd_ox = v,
OriginY => self.st_nodes[idx].nd_oy = v,
Alpha => self.st_nodes[idx].nd_alpha = v,
Depth => self.st_nodes[idx].nd_depth = v,
}
}
}
}
// Move `p` from where it is now to `to`, over `dur_us` microseconds.
//
// ⚠ `from` is read at THIS moment, so a tween started mid-flight carries on from
// where the last one had got to rather than snapping back to a remembered start.
// Replaces any tween already on this (node, property).
pub fn tween_prop(self: Stage, idx: integer, p: Prop, to: float, dur_us: integer,
c: Curve = Curve.Linear,
s: Shape = Shape.In) {
if idx < 0 or idx >= len(self.st_nodes) { return; }
cancel_prop_tween(self, idx, p);
self.st_tweens += [NodeTween {
nt_node: idx, nt_prop: p,
nt_tw: tween_new(prop_of(self, idx, p), to, dur_us, c, s),
}];
}
// Is that property still moving?
pub fn prop_tweening(self: const Stage, idx: integer, p: Prop) -> boolean {
for t in self.st_tweens {
if t.nt_node == idx and t.nt_prop == p and !t.nt_tw.done() { return true; }
}
false
}
// Stop it wherever it has got to. The property keeps its current value.
pub fn cancel_prop_tween(self: Stage, idx: integer, p: Prop) {
kept: vector<NodeTween> = [];
for t in self.st_tweens {
if t.nt_node != idx or t.nt_prop != p { kept += [t]; }
}
self.st_tweens = kept;
}
// Drop every tween on a node.
pub fn cancel_node_tweens(self: Stage, idx: integer) {
kept_n: vector<NodeTween> = [];
for t in self.st_tweens {
if t.nt_node != idx { kept_n += [t]; }
}
self.st_tweens = kept_n;
}
// How many property tweens the stage is holding — the gate reads this.
pub fn tween_count(self: const Stage) -> integer { len(self.st_tweens) }
// Step every property tween and write what it now reads. Answers whether any of
// them moved, so `advance` can dirty the buffer exactly when something did.
fn step_tweens(self: Stage, dt_us: integer) -> boolean {
if dt_us <= 0 { return false; }
any = false;
for i in 0..len(self.st_tweens) {
if i < len(self.st_tweens) {
if !self.st_tweens[i].nt_tw.done() {
_ = self.st_tweens[i].nt_tw.advance(dt_us);
write_prop(self, self.st_tweens[i].nt_node,
self.st_tweens[i].nt_prop,
self.st_tweens[i].nt_tw.value());
any = true;
}
}
}
any
}
// ── P5: facings, and the two models the projection picks between ─────────────
//
// ⚠ THE PROJECTION PICKS THE MODEL, NOT THE AUTHOR. An author writes one call —
// `face(node, angle)`, "this thing points that way" — and the stage decides what
// that costs. A top-down world turns one sprite continuously; a side-on one
// cannot turn a standing sprite at all, so it picks from an (action, facing)
// table and mirrors at most. Putting the choice in the projection is what lets
// the same mob code serve both views, which is the claim the 2.5-D vehicle rests
// on.
// One sprite authored in a locked orientation, rotated continuously to the
// facing. Pre-rotated frames never exist, so 15-degree steps cost no cells.
pub const TOP_DOWN: integer = 0;
// A standing sprite cannot be rotated into another facing — at most mirrored —
// so the facing selects a sequence and the other side is a flip.
pub const SIDE_ON: integer = 1;
// Pick `TOP_DOWN` or `SIDE_ON`. It decides what `face` does with an angle:
// rotate the one sprite, or resolve a sequence and mirror it.
pub fn set_projection(self: Stage, mode: integer) { self.st_proj = mode; }
// Which of the two models is in force.
pub fn projection(self: const Stage) -> integer { self.st_proj }
// How many sequences are registered; also the bound on a `pl_seq` index.
pub fn sequence_count(self: const Stage) -> integer { len(self.st_seqs) }
// Register a sequence under a NAME, so `face` and `set_action` can resolve it.
// The convention is `<key>`, `<key>_<action>`, `<key>_<action>_<facing>`, and
// resolution walks it most-specific first.
pub fn add_named_sequence(self: Stage, name: text, first: integer, count: integer,
fps: float, mode: integer) -> integer {
push_sequence(self, name, first, count, fps, mode)
}
// The sequence registered under `name`, or -1. Names are resolved when a facing
// or an action CHANGES, never per frame, which is what keeps a scan honest here.
fn seq_named(self: const Stage, name: text) -> integer {
if name == "" { return -1; }
found = -1;
for i in 0..len(self.st_seqs) {
qi = self.st_seqs[i];
if qi.q_name == name { found = i; }
}
found
}
// Which of the four compass facings an angle points at.
//
// ⚠ SCREEN SPACE, Y DOWN: angle 0 is +x (east) and +pi/2 is +y (south), matching
// `pl_rot` and the shader. The edges are HALF-OPEN, as A4's picking rects are —
// 45 degrees belongs to south — so no angle is claimed by two facings.
pub fn facing_name(angle: float) -> text {
turn = PI * 2.0;
norm = angle - floor(angle / turn ?? 0.0) * turn;
b = ((norm + PI / 4.0) / (PI / 2.0) ?? 0.0) as integer;
q = b % 4 ?? 0;
if q == 0 { return "e"; }
if q == 1 { return "s"; }
if q == 2 { return "w"; }
"n"
}
// The facing a flip would show instead, or "" where a flip shows nothing new —
// a front view mirrored is still a front view, so north and south have no partner.
fn opposite_facing(f: text) -> text {
if f == "e" { return "w"; }
if f == "w" { return "e"; }
""
}
// Resolve a node's sequence from its key, action and facing, and say whether the
// answer has to be mirrored to face the right way.
//
// ⚠ FALL BACK, NEVER FAIL. A missing sprite leaves the mob drawn with what it
// already had — the rule crawler's `<key>.png` loader keeps, and the reason a
// half-authored mob is a visibly wrong sprite rather than a blank or a stop.
fn resolve_facing(self: Stage, idx: integer) {
// An index that is not a node reads as the blank node, whose `nd_key` is
// empty — so the next line returns, which is the fall-back this documents.
n = self.st_nodes[idx]?;
if n.nd_key == "" { return; }
f = facing_name(n.nd_facing);
mirror = false;
q = -1;
if n.nd_action != "" {
q = seq_named(self, "{n.nd_key}_{n.nd_action}_{f}");
if q < 0 {
// At most mirrored: borrow the other side rather than turning.
opp = opposite_facing(f);
if opp != "" {
q = seq_named(self, "{n.nd_key}_{n.nd_action}_{opp}");
if q >= 0 { mirror = true; }
}
}
if q < 0 { q = seq_named(self, "{n.nd_key}_{n.nd_action}"); }
}
if q < 0 { q = seq_named(self, "{n.nd_key}_{f}"); }
if q < 0 { q = seq_named(self, n.nd_key); }
if q < 0 { return; } // nothing matched: keep what it had
// ⚠ THE PHASE CARRIES OVER — `nd_elapsed` is deliberately not touched. A mob
// turning a corner mid-stride must keep walking from where its legs were; a
// reset here is a visible stutter at the exact moment a player is watching.
// `restart` stays the one way to go back to frame 0.
self.st_nodes[idx].nd_seq = q;
self.st_nodes[idx].nd_mirror = mirror;
self.st_dirty = true;
}
// Point a node at `angle` radians. Top-down turns it; side-on picks and flips.
pub fn face(self: Stage, idx: integer, angle: float) {
if idx < 0 or idx >= len(self.st_nodes) { return; }
self.st_nodes[idx].nd_facing = angle;
if self.st_proj == TOP_DOWN {
// Continuous, off the 2x3 affine A1 already composes — so a finer step
// costs arithmetic and never an atlas entry.
self.st_nodes[idx].nd_rot = angle;
self.st_nodes[idx].nd_mirror = false;
self.st_dirty = true;
return;
}
resolve_facing(self, idx);
}
// What a node is doing — "idle", "walk", "attack". Re-resolves, phase intact.
pub fn set_action(self: Stage, idx: integer, action: text) {
if idx < 0 or idx >= len(self.st_nodes) { return; }
self.st_nodes[idx].nd_action = action;
if self.st_proj == SIDE_ON { resolve_facing(self, idx); }
}
// Where the node points, in radians. Kept in BOTH models — side-on holds the
// angle even though what it draws for it is a chosen sequence and a flip.
pub fn facing_of(self: const Stage, idx: integer) -> float {
if idx < 0 or idx >= len(self.st_nodes) { return 0.0; }
self.st_nodes[idx].nd_facing
}
// What the node is doing, as `set_action` last left it. Name resolution reads
// this together with the key and the facing.
pub fn action_of(self: const Stage, idx: integer) -> text {
if idx < 0 or idx >= len(self.st_nodes) { return ""; }
self.st_nodes[idx].nd_action
}
// The sprite's own rotation, which side-on leaves alone.
pub fn rotation_of(self: const Stage, idx: integer) -> float {
if idx < 0 or idx >= len(self.st_nodes) { return 0.0; }
self.st_nodes[idx].nd_rot
}
// Is this node drawn flipped? Only side-on ever sets it: mirroring is how the
// other half of the facings exists without another atlas cell.
pub fn mirrored(self: const Stage, idx: integer) -> boolean {
if idx < 0 or idx >= len(self.st_nodes) { return false; }
self.st_nodes[idx].nd_mirror
}
// ── P6: several views over one stage ─────────────────────────────────────────
//
// ⚠ ONE SCENE, PACKED ONCE, DRAWN N TIMES. A view carries only a camera and a
// screen rect, so adding one costs a draw pass and never a re-pack — which is
// P2's *the camera is a uniform* carried to N cameras. A view that baked its
// camera into node positions would spend A3's entire budget on looking twice.
//
// ⚠ AND EVERY VIEW SHARES ONE SCREEN-SPACE PROJECTION. A view is placed by
// OFFSETTING into its rect and scissoring to it, not by remapping the clip
// volume: the software path and the GL path then stay directly comparable — the
// parity rig A2 and A3b rest on — and a sprite is the same size in every view
// rather than quietly zoomed by a viewport of a different shape.
// How many views the stage paints into: view 0, plus one per `add_view`.
pub fn view_count(self: const Stage) -> integer { len(self.st_views) }
// Add a view painting into the screen rect `(x, y, w, h)`. A width of 0 or less
// means unbounded — the whole target, which is what view 0 starts as.
pub fn add_view(self: Stage, x: integer, y: integer, w: integer,
h: integer) -> integer {
self.st_views += [View { vw_cam_x: 0.0, vw_cam_y: 0.0, vw_x: x, vw_y: y,
vw_w: w, vw_h: h }];
len(self.st_views) - 1
}
// Move or resize a view's rectangle on screen. The camera it carries is left
// alone, so a split-screen resize does not move what either half is looking at.
pub fn set_view_rect(self: Stage, view: integer, x: integer, y: integer,
w: integer, h: integer) {
if view < 0 or view >= len(self.st_views) { return; }
self.st_views[view].vw_x = x;
self.st_views[view].vw_y = y;
self.st_views[view].vw_w = w;
self.st_views[view].vw_h = h;
}
// Move one view's camera. Like `set_camera`, it does not dirty the buffer.
pub fn set_view_camera(self: Stage, view: integer, x: float, y: float) {
if view < 0 or view >= len(self.st_views) { return; }
self.st_views[view].vw_cam_x = x;
self.st_views[view].vw_cam_y = y;
}
fn view_at(self: const Stage, view: integer) -> View {
if view < 0 or view >= len(self.st_views) {
return View { vw_cam_x: 0.0, vw_cam_y: 0.0, vw_x: 0, vw_y: 0,
vw_w: 0, vw_h: 0 };
}
// ⚠ ONE OWNERSHIP VERDICT ON EVERY PATH. Answering `self.st_views[view]`
// directly is a BORROW, while the guard above answers a FRESH record — one
// function telling its caller two different things about who frees the
// result — and on `--native` that corrupted the store several calls later,
// reading canvas pixels as a record number (loft#1017). Copying the fields
// out makes every return fresh.
v = self.st_views[view] ?? View { vw_cam_x: 0.0, vw_cam_y: 0.0, vw_x: 0,
vw_y: 0, vw_w: 0, vw_h: 0 };
View { vw_cam_x: v.vw_cam_x, vw_cam_y: v.vw_cam_y, vw_x: v.vw_x,
vw_y: v.vw_y, vw_w: v.vw_w, vw_h: v.vw_h }
}
// Where this view's camera stands. A view out of range answers 0.0 rather than
// failing, which is `view_at`'s verdict and not a second rule.
pub fn view_camera_x(self: const Stage, view: integer) -> float {
view_at(self, view).vw_cam_x
}
// The other half of the same pair.
pub fn view_camera_y(self: const Stage, view: integer) -> float {
view_at(self, view).vw_cam_y
}
// Where a layer's content lands on screen in this view: the camera un-applied by
// the layer's parallax, then moved into the view's rect.
//
// ⚠ THE VIEWPORT ORIGIN IS PART OF THE MAPPING, not decoration. Leave it out and
// both views draw on top of each other while every camera test still passes.
pub fn view_offset(self: const Stage, view: integer, layer: integer) -> P2 {
v = view_at(self, view);
f = parallax_of(self, layer);
P2 { p_x: -v.vw_cam_x * f + (v.vw_x as float),
p_y: -v.vw_cam_y * f + (v.vw_y as float) }
}
// Pack the instance buffer if the scene changed, and say whether it did.
//
// One home for *when does the buffer get rewritten*, so the GL path and any
// count of uploads cannot disagree about it — and so N views can be shown to
// cost ONE upload without a GL context to prove it in.
pub fn sync_instances(self: Stage) -> boolean {
if !self.st_dirty { return false; }
self.st_uploads = self.st_uploads + 1;
self.st_dirty = false;
true
}
// The topmost node under a screen point AS THAT VIEW SEES IT, or -1.
//
// ⚠ ONE SCREEN POINT IS A DIFFERENT WORLD POINT IN EACH VIEW — the same reason
// P2 un-applies the camera per LAYER. A point outside the view's own rect is
// not that view's to answer, whatever its camera would map it to.
pub fn pick_in(self: const Stage, view: integer, sx: float, sy: float) -> integer {
if !in_view_rect(self, view, sx, sy) { return -1; }
k = len(self.st_order) - 1;
while k >= 0 {
i = self.st_order[k] ?? -1;
if hits_in(self, view, i, sx, sy) { return i; }
k = k - 1;
}
-1
}
// Is this screen point inside the view's rect? An unbounded view holds every
// point, which is what keeps the unsplit stage a single code path.
pub fn in_view_rect(self: const Stage, view: integer, sx: float, sy: float) -> boolean {
v = view_at(self, view);
if v.vw_w <= 0 or v.vw_h <= 0 { return true; }
// Half-open, as A4's picking rects are, so abutting views never both claim
// a pixel — the seam of a split screen is exactly where that would show.
sx >= (v.vw_x as float) and sy >= (v.vw_y as float)
and sx < ((v.vw_x + v.vw_w) as float)
and sy < ((v.vw_y + v.vw_h) as float)
}
// Does this node cover the screen point, as `view` maps it?
pub fn hits_in(self: const Stage, view: integer, idx: integer, sx: float,
sy: float) -> boolean {
if idx < 0 or idx >= len(self.st_nodes) { return false; }
n = self.st_nodes[idx];
if !n.nd_visible or n.nd_w <= 0.0 or n.nd_h <= 0.0 { return false; }
off = view_offset(self, view, n.nd_layer);
l = to_local(self, idx, sx - off.p_x, sy - off.p_y);
if l.p_x < 0.0 or l.p_y < 0.0 or l.p_x >= n.nd_w or l.p_y >= n.nd_h { return false; }
solid_at(self, idx, l.p_x, l.p_y)
}
// ── L1: light sampled per sprite, applied as tint ────────────────────────────
//
// ⚠ NO PASS, NO FRAMEBUFFER, NO REORDER. Each sprite samples every light at its
// own ORIGIN and the answer folds into A5's tint attribute, so lighting a scene
// costs a multiply per sprite and cannot disturb the draw. That makes it
// order-independent by construction, which is the property L2's composite has to
// work for rather than inherit.
//
// ⚠ AND THE SAMPLE POINT IS THE ORIGIN — the footprint P1 sorts on, not the
// artwork. Two mobs standing on one tile take the same light however tall they
// are; sampling a corner would light a tall sprite as though it stood further
// away, which is the 2.5-D wrong picture wearing a different hat.
// Add a light. Answers its index, for `move_light`.
pub fn add_light(self: Stage, x: float, y: float, radius: float, colour: integer,
power: float) -> integer {
self.st_lights += [StageLight { lt_x: x, lt_y: y, lt_r: radius, lt_colour: colour,
lt_power: power }];
// ⚠ Unlike a camera, a light DIRTIES the buffer: it changes what is in the
// instance data rather than how it is looked at. A moving torch therefore
// re-packs every frame, and taking that cost off the per-sprite path is
// exactly what L2's composite pass is for.
self.st_dirty = true;
len(self.st_lights) - 1
}
// Carry a light to a new place. DIRTIES the buffer, exactly as adding one does
// — a moving torch therefore re-packs every frame, which is the cost L2's
// composite pass exists to take off the per-sprite path.
pub fn move_light(self: Stage, i: integer, x: float, y: float) {
if i < 0 or i >= len(self.st_lights) { return; }
self.st_lights[i].lt_x = x;
self.st_lights[i].lt_y = y;
self.st_dirty = true;
}
// How many lights the scene holds; the bound for `move_light`.
pub fn light_count(self: const Stage) -> integer { len(self.st_lights) }
// How lit the world is before any light: 1.0 leaves the material untouched, so a
// game that never asks for light cannot be dimmed by this existing. Turn it
// down and the scene goes dark except where a light reaches.
pub fn set_ambient(self: Stage, level: float) {
self.st_ambient = level;
self.st_dirty = true;
}
// The level `set_ambient` left.
pub fn ambient(self: const Stage) -> float { self.st_ambient }
// What a light contributes at a point: linear in the radius, clamped at both ends.
//
// ⚠ LINEAR, `1 - d/r`, and that is a design call. This phase's gate is
// hand-computed values, and a curve nobody can hand-compute is a gate nobody
// maintains. L2 owns the prettier curve, and its own gate says how: expectations
// GENERATED from the same constants the shader uses, so retuning is one edit.
pub fn light_reach(l: const StageLight, px: float, py: float) -> float {
if l.lt_r <= 0.0 { return 0.0; }
dx = px - l.lt_x;
dy = py - l.lt_y;
d = sqrt(dx * dx + dy * dy);
a = 1.0 - (d / l.lt_r ?? 1.0);
if a <= 0.0 { return 0.0; }
if a > 1.0 { return 1.0; }
// ⚠ ONE HOME FOR THE CURVE. L2's composite shader takes this same exponent
// as a uniform rather than baking a number into its text, so retuning the
// falloff is one edit and the per-sprite and per-pixel paths cannot drift.
if LIGHT_FALLOFF != 1.0 { a = pow(a, LIGHT_FALLOFF) ?? 0.0; }
a * l.lt_power
}
// The colour a node is actually drawn in: its material, multiplied by the light
// standing on its footprint.
//
// ⚠ ONE HOME. Both render paths and the instance packer read this rather than
// each folding the light in themselves — the shape P6 shipped a defect through,
// where one fact derived per path was validated only where the derivations
// happened to agree.
pub fn lit_colour(self: const Stage, idx: integer) -> integer {
if idx < 0 or idx >= len(self.st_nodes) { return 0; }
n = self.st_nodes[idx];
// ⚠ ONE LIGHTING MODEL AT A TIME. With the map on, the composite lights
// every pixel — so the sprite goes in RAW. Tinting it here as well would
// light it twice, and a torch-lit mob would come out darker than the floor
// it stands on.
if self.st_light_map { return n.nd_colour; }
if len(self.st_lights) == 0 and self.st_ambient >= 1.0 { return n.nd_colour; }
o = world_origin(self, idx);
lv = light_rgb_at(self, o.p_x, o.p_y);
lr = lv.li_r;
lg = lv.li_g;
lb = lv.li_b;
// ⚠ CLAMPED PER CHANNEL. Unclamped, standing between two torches multiplies
// past 255 and the byte wraps, so the brightest place in the scene comes out
// DARK — the classic and the one everybody finds by accident.
pack_channel(n.nd_colour >> 16, lr) << 16
| pack_channel(n.nd_colour >> 8, lg) << 8
| pack_channel(n.nd_colour, lb)
}
fn pack_channel(material: integer, level: float) -> integer {
k = level;
if k < 0.0 { k = 0.0; }
if k > 1.0 { k = 1.0; }
v = (((material & 255) as float) * k + 0.5) as integer;
if v < 0 { return 0; }
if v > 255 { return 255; }
v
}
// ── L2: the light-map composite pass ─────────────────────────────────────────
//
// What L1 cannot do: light that falls ACROSS the scene rather than across the
// sprites. The world draws, ONE multiply composite lights every pixel of it —
// including the gaps no sprite covers, which is why it earns a pass at all — and
// the HUD draws after, unlit.
//
// ⚠ A MULTIPLY AFTER THE SCENE IS ORDER-INDEPENDENT, so this does not fight
// A3a's *never reorder*: the composite cannot care which sprite was drawn first.
//
// ⚠ AND VISIBILITY STAYS THE APP'S. The light PRESENTS; it does not decide what
// is seen. A pixel in the dark is still there, still picked, still in its batch.
// The exponent on `1 - d/r`. One home, read by `light_reach` and handed to the
// composite shader as a uniform — never baked into its text, both because a
// second copy drifts and because loft renders `2.0` as `2`, which GLSL takes as
// an int and refuses.
pub const LIGHT_FALLOFF: float = 1.0;
// The sentinel meaning "no HUD band": every layer is world.
pub const HUD_NONE: integer = 1000000;
// A light level, per channel, 0..1.
pub struct Lit {
li_r: float,
li_g: float,
li_b: float,
}
// Switch the composite pass on. Off, light is L1's per-sprite tint, which is
// one colour for a whole sprite; on, it is resolved per pixel.
pub fn set_light_map(self: Stage, on: boolean) {
self.st_light_map = on;
self.st_dirty = true;
}
// Is the composite pass on?
pub fn light_map(self: const Stage) -> boolean { self.st_light_map }
// Layers at or above `n` draw AFTER the composite and take no light.
pub fn set_hud_layer(self: Stage, n: integer) { self.st_hud_layer = n; }
// The first layer that draws after the composite, or `HUD_NONE`.
pub fn hud_layer(self: const Stage) -> integer { self.st_hud_layer }
// The light standing at a world point, per channel.
//
// ⚠ THE ONE HOME both samplings read: L1 asks it at a sprite's origin, L2 asks
// it per pixel. One curve, one floor, one clamp — so the per-sprite and the
// per-pixel answers cannot disagree about the same point.
pub fn light_rgb_at(self: const Stage, wx: float, wy: float) -> Lit {
lr = self.st_ambient;
lg = self.st_ambient;
lb = self.st_ambient;
for i in 0..len(self.st_lights) {
l = self.st_lights[i];
a = light_reach(l, wx, wy);
// Skipping zero is work avoided, NOT the guard: `light_reach` is the one
// place that decides a light out of range contributes nothing, and it is
// gated there. Two places enforcing one rule is how they drift.
if a <= 0.0 { continue; }
lr = lr + (((l.lt_colour >> 16) & 255) as float / 255.0) * a;
lg = lg + (((l.lt_colour >> 8) & 255) as float / 255.0) * a;
lb = lb + ((l.lt_colour & 255) as float / 255.0) * a;
}
Lit { li_r: clamp01(lr), li_g: clamp01(lg), li_b: clamp01(lb) }
}
// The scalar level at a world point — the green channel of a white light, and
// the number the monotonic-and-floored invariant is stated about.
pub fn light_level_at(self: const Stage, wx: float, wy: float) -> float {
light_rgb_at(self, wx, wy).li_g
}
fn clamp01(v: float) -> float {
if v < 0.0 { return 0.0; }
if v > 1.0 { return 1.0; }
v
}
// Multiply every pixel of the view's rect by the light standing there.
//
// ⚠ THE SCREEN IS MAPPED TO THE WORLD THROUGH THE WORLD PLANE — layer 0, the
// plane lights live on. A parallaxed background is therefore lit as though it
// stood on that plane, which is the honest simplification here: per-layer
// atmosphere is L3's question, not this pass's.
pub fn composite_light(self: const Stage, view: integer, cv: graphics::Canvas) {
if !self.st_light_map { return; }
off = view_offset(self, view, 0);
vp = view_at(self, view);
x0 = 0;
y0 = 0;
x1 = cv.width;
y1 = cv.height;
if vp.vw_w > 0 and vp.vw_h > 0 {
x0 = vp.vw_x;
y0 = vp.vw_y;
if vp.vw_x + vp.vw_w < x1 { x1 = vp.vw_x + vp.vw_w; }
if vp.vw_y + vp.vw_h < y1 { y1 = vp.vw_y + vp.vw_h; }
}
if x0 < 0 { x0 = 0; }
if y0 < 0 { y0 = 0; }
for py in y0..y1 {
for px in x0..x1 {
lv = light_rgb_at(self, (px as float) - off.p_x, (py as float) - off.p_y);
d = cv.get_pixel(px, py);
cv.set_pixel(px, py, (d & 0xff000000)
| (scale_channel((d >> 16) & 255, lv.li_r) << 16)
| (scale_channel((d >> 8) & 255, lv.li_g) << 8)
| scale_channel(d & 255, lv.li_b));
}
}
}
fn scale_channel(c: integer, level: float) -> integer {
v = ((c as float) * level + 0.5) as integer;
if v < 0 { return 0; }
if v > 255 { return 255; }
v
}
// The composite's fragment shader. Public so a test can check it reads the
// falloff and the floor as UNIFORMS rather than carrying its own copy of them.
pub fn composite_shader_source() -> text { FRAG_LIGHT }
// ── L3: per-layer atmosphere — fog and blur ──────────────────────────────────
//
// A layer already carries a parallax factor (P2). Give it a fog colour, a
// density and a blur radius, and *distant, hazy, out-of-focus* becomes LAYER
// DATA rather than an effects pipeline.
//
// ⚠ FOG IS FREE, BLUR IS NOT. Fog is a lerp toward a colour, folded into the
// same per-node mix the depth cue's haze already uses, so it costs nothing and
// needs no buffer. Blur has to see a layer's neighbouring pixels, so it is a
// pass over what has been drawn — which is why they are separate switches and
// why only one of them is worth thinking about per frame.
// Fade this layer toward `colour`. Density 0 is clear and changes nothing.
pub fn set_layer_fog(self: Stage, layer: integer, colour: integer, density: float) {
grow_layers(self, layer);
if layer < 0 { return; }
self.st_layers[layer].ly_fog = colour;
self.st_layers[layer].ly_density = density;
self.st_dirty = true;
}
// The colour this layer fades toward. An unset layer reads the plain layer's,
// so a scene that never asked for fog cannot be tinted by this existing.
pub fn layer_fog_colour(self: const Stage, layer: integer) -> integer {
layer_at(self, layer).ly_fog
}
// How far it fades: 0 is clear.
pub fn layer_fog_density(self: const Stage, layer: integer) -> float {
layer_at(self, layer).ly_density
}
// Put this layer out of focus by `radius` pixels. 0 is sharp.
//
// ⚠ THE BLUR RUNS WHEN THE LAYER FINISHES, so it softens everything drawn up to
// and including it and NOTHING above — which is what makes a sharp foreground
// over a hazy distance possible, and what a fullscreen blur cannot do. Distant
// bands therefore blur together, which is what *distance is out of focus* means.
pub fn set_layer_blur(self: Stage, layer: integer, radius: integer) {
grow_layers(self, layer);
if layer < 0 { return; }
self.st_layers[layer].ly_blur = radius;
}
// This layer's blur radius in pixels; 0 is sharp.
pub fn layer_blur(self: const Stage, layer: integer) -> integer {
layer_at(self, layer).ly_blur
}
// Box-blur a rectangle of the canvas in place.
//
// ⚠ SAMPLES ARE CLAMPED TO THE EDGE, never read as black. Counting an
// off-image sample as black loses light everywhere the kernel overhangs, and it
// shows as a dark frame around the picture — the classic bug this phase gates
// against. Clamping keeps a uniform field EXACTLY itself.
pub fn blur_region(cv: graphics::Canvas, x0: integer, y0: integer, x1: integer,
y1: integer, radius: integer) {
// Returning early on 0 is work avoided, NOT the guard: a radius-0 kernel
// averages exactly one sample and is already the identity. What the gate
// actually defends is that 0 means NO blur rather than the smallest one.
if radius <= 0 { return; }
w = x1 - x0;
h = y1 - y0;
if w <= 0 or h <= 0 { return; }
src: vector<integer> = [];
for py in y0..y1 {
for px in x0..x1 { src += [cv.get_pixel(px, py)]; }
}
for py in 0..h {
for px in 0..w {
sr = 0;
sg = 0;
sb = 0;
n = 0;
for ky in (py - radius)..(py + radius + 1) {
jy = ky;
if jy < 0 { jy = 0; }
if jy >= h { jy = h - 1; }
for kx in (px - radius)..(px + radius + 1) {
jx = kx;
if jx < 0 { jx = 0; }
if jx >= w { jx = w - 1; }
v: integer = src[jy * w + jx] ?? 0;
sr = sr + ((v >> 16) & 255);
sg = sg + ((v >> 8) & 255);
sb = sb + (v & 255);
n = n + 1;
}
}
cv.set_pixel(x0 + px, y0 + py, 0xff000000
| (((sr + n / 2 ?? 0) / n ?? 0) << 16)
| (((sg + n / 2 ?? 0) / n ?? 0) << 8)
| ((sb + n / 2 ?? 0) / n ?? 0));
}
}
}
tests/ambient.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN144 P3 — ambient motion: a field of grass that costs nothing per frame.
//
// The phase comes from POSITION and the animation from one clock, so five hundred
// swaying tufts are a buffer uploaded once and a uniform that changes. Everything
// below asserts that property rather than describing it.
use stage::*;
use graphics::*;
fn grass(x: float, sway: float) -> stage::Place {
stage::Place { pl_x: x, pl_y: 10.0, pl_w: 2.0, pl_h: 6.0, pl_sway: sway,
pl_colour: 0x00ff00 }
}
// ⚠ THE GATE: 500 sprites, 100 ticks of the clock, NOT ONE FLOAT of the instance
// buffer changed. Stepping a stored phase would rewrite all 500 every frame.
fn test_five_hundred_swaying_sprites_cost_no_updates() {
st = stage::stage_new();
for i in 0..500 { _ = st.node_add(-1, grass((i * 3) as float, 4.0)); }
st.compose();
before = st.pack_instances();
for t in 0..100 { st.set_time((t as float) * 0.1); }
after = st.pack_instances();
assert(len(before) == len(after), "the buffer keeps its size");
differs = 0;
for i in 0..len(before) { if before[i] != after[i] { differs = differs + 1; } }
assert(differs == 0, "{differs} floats changed across 100 ticks — expected 0");
}
// ⚠ AND THEY ARE NOT IN LOCKSTEP. A phase derived from position is the whole
// reason a field looks like a field; one clock with no phase would sway every tuft
// identically, which reads as the ground moving rather than the grass.
fn test_neighbours_are_out_of_step() {
st = stage::stage_new();
a = st.node_add(-1, grass(0.0, 5.0));
b = st.node_add(-1, grass(7.0, 5.0));
c = st.node_add(-1, grass(19.0, 5.0));
st.compose();
st.set_time(0.3);
sa = st.sway_of(a);
sb = st.sway_of(b);
sc = st.sway_of(c);
assert(sa != sb and sb != sc and sa != sc,
"three tufts, three displacements: {sa} {sb} {sc}");
}
// The clock moves them: the same tuft is somewhere else a moment later.
fn test_the_clock_moves_them() {
st = stage::stage_new();
n = st.node_add(-1, grass(4.0, 5.0));
st.compose();
st.set_time(0.0);
at0 = st.sway_of(n);
st.set_time(1.0);
at1 = st.sway_of(n);
assert(at0 != at1, "the displacement follows the clock");
}
// Amplitude bounds it — a sine times an amplitude can do nothing else, and a tuft
// that wandered off its tile would be a bug you only see in a wide shot.
fn test_the_sway_is_bounded_by_its_amplitude() {
st = stage::stage_new();
n = st.node_add(-1, grass(3.0, 2.0));
st.compose();
for t in 0..64 {
st.set_time((t as float) * 0.25);
v = st.sway_of(n);
assert(v <= 2.0 and v >= -2.0, "displacement {v} escaped its amplitude");
}
}
// Zero amplitude is still — the degenerate case proved, so a scene with no wind is
// the same picture it was before sway existed.
fn test_no_amplitude_is_no_motion() {
st = stage::stage_new();
n = st.node_add(-1, grass(3.0, 0.0));
st.compose();
st.set_time(0.0);
a = graphics::canvas(32, 32, 0xff000000);
st.render_stage(a);
st.set_time(12.7);
b = graphics::canvas(32, 32, 0xff000000);
st.render_stage(b);
assert(st.sway_of(n) == 0.0, "no displacement");
for i in 0..len(a.data) { assert(a.data[i] == b.data[i], "and an identical picture"); }
}
// ⚠ SWAY IS VISUAL ONLY. It displaces what is drawn and never the footprint, so a
// swaying tree keeps its depth, its bounds and its hit area — one that re-sorted as
// it swayed would flicker past its neighbours.
fn test_sway_does_not_move_the_footprint() {
st = stage::stage_new();
n = st.node_add(-1, grass(10.0, 6.0));
st.compose();
st.set_time(0.0);
b0 = st.bounds_of(n);
o0 = st.world_origin(n);
hit0 = st.pick(11.0, 12.0);
st.set_time(1.9);
b1 = st.bounds_of(n);
o1 = st.world_origin(n);
assert(st.sway_of(n) != 0.0, "it IS swaying");
assert(b0.b_x0 == b1.b_x0 and b0.b_x1 == b1.b_x1, "bounds do not move");
assert(o0.p_x == o1.p_x, "the origin does not move");
assert(st.pick(11.0, 12.0) == hit0, "and neither does what you can click");
}
// The picture does change, though — a sway that no one could see would pass every
// numeric test above.
fn test_the_drawn_picture_actually_moves() {
st = stage::stage_new();
_ = st.node_add(-1, grass(10.0, 6.0));
st.compose();
st.set_time(0.0);
a = graphics::canvas(48, 32, 0xff000000);
st.render_stage(a);
st.set_time(1.6);
b = graphics::canvas(48, 32, 0xff000000);
st.render_stage(b);
moved = false;
for i in 0..len(a.data) { if a.data[i] != b.data[i] { moved = true; } }
assert(moved, "the drawn grass moved between the two clocks");
}
tests/animation.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN144 P4 — mob animation. Every expected frame is worked out by hand, and the
// three cells that matter are the ones that are wrong in most implementations:
// a loop that returns to 0 EXACTLY, two frame rates agreeing, and a ping-pong that
// does not stutter at its ends.
use stage::*;
fn walker(st: stage::Stage, seq: integer) -> integer {
st.node_add(-1, stage::Place { pl_w: 8.0, pl_h: 8.0, pl_seq: seq, pl_cols: 4 })
}
// A loop's worth of ticks returns to frame 0 exactly — not to 1, and not to the
// last frame.
fn test_a_loop_returns_to_frame_zero() {
st = stage::stage_new();
q = st.add_sequence(0, 4, 10.0, stage::LOOP); // 4 frames at 10 fps = 0.4 s
n = walker(st, q);
st.compose();
assert(st.frame_of(n) == 0, "starts on 0");
st.advance(400000);
assert(st.frame_of(n) == 0, "one full loop later, back on 0");
st.advance(100000);
assert(st.frame_of(n) == 1, "and moving again");
}
// ⚠ TICKED, NOT JUMPED — and this is the cell that pins integer microseconds.
// A game reaches 0.8 s by adding 0.1 s eight times, never by asking for 800000 at
// once, and in FLOAT SECONDS those are different numbers: 0.1 added eight times is
// 0.7999999999999999, which times 10 fps truncates to frame 7 where 8 is right.
// So this loop lands on 7 — one short of home — and an eight-frame walk cycle
// visibly hitches once per stride. Summing integer microseconds cannot do that,
// which is the whole reason `advance` takes them.
//
// Hand-computed: after tick k the elapsed is exactly k x 100000 us, so the frame
// is k % 8 — 1 2 3 4 5 6 7 0.
fn test_ticking_a_whole_loop_lands_exactly_on_zero() {
st = stage::stage_new();
q = st.add_sequence(0, 8, 10.0, stage::LOOP); // 8 frames at 10 fps = 0.8 s
n = walker(st, q);
st.compose();
want = [1, 2, 3, 4, 5, 6, 7, 0];
for k in 0..8 {
st.advance(100000); // 0.1 s, one tick at a time
assert(st.frame_of(n) == (want[k] ?? -1),
"tick {k + 1}: got {st.frame_of(n)}, want {want[k] ?? -1}");
}
}
// ⚠ FRAME-RATE INDEPENDENT. Stepping at 60 Hz and at 30 Hz must agree at the same
// elapsed time — the property that makes a recorded input stream replayable, and
// therefore the property co-op determinism rests on.
fn test_thirty_and_sixty_hertz_agree() {
fast = stage::stage_new();
qf = fast.add_sequence(0, 6, 12.0, stage::LOOP);
nf = walker(fast, qf);
fast.compose();
slow = stage::stage_new();
qs = slow.add_sequence(0, 6, 12.0, stage::LOOP);
ns = walker(slow, qs);
slow.compose();
// ⚠ Two 60 Hz steps SUM EXACTLY to one 30 Hz step in integer microseconds —
// 2 x 16667 = 33334 — which is the whole reason the delta is not a float.
for i in 0..60 {
fast.advance(16667);
fast.advance(16667);
slow.advance(33334);
assert(fast.frame_of(nf) == slow.frame_of(ns),
"step {i}: 60 Hz says {fast.frame_of(nf)}, 30 Hz says {slow.frame_of(ns)}");
}
}
// ONCE stops on the last frame and stays there — it does not wrap, and it does not
// run off the end of the atlas.
fn test_once_stops_on_the_last_frame() {
st = stage::stage_new();
q = st.add_sequence(10, 3, 10.0, stage::ONCE); // cells 10, 11, 12
n = walker(st, q);
st.compose();
assert(st.frame_of(n) == 10, "starts at the sequence's first cell");
st.advance(250000);
assert(st.frame_of(n) == 12, "reaches the last");
st.advance(50000000);
assert(st.frame_of(n) == 12, "and stays there rather than wrapping");
}
// ⚠ PING-PONG DOES NOT REPEAT ITS END FRAMES. For four frames the cycle is
// 0 1 2 3 2 1 — period SIX, not eight. A period of 2n shows frame 3 twice and
// frame 0 twice, which reads as a limp at both ends and is the classic off-by-one.
fn test_pingpong_reverses_without_stuttering() {
st = stage::stage_new();
q = st.add_sequence(0, 4, 10.0, stage::PINGPONG);
n = walker(st, q);
st.compose();
want = [0, 1, 2, 3, 2, 1, 0, 1, 2, 3, 2, 1];
for i in 0..12 {
got = st.frame_of(n);
assert(got == (want[i] ?? -1), "tick {i}: got {got}, want {want[i] ?? -1}");
st.advance(100000);
}
}
// A single-frame sequence is legal and still: no modulo by zero, no flicker.
fn test_a_one_frame_sequence_is_still() {
st = stage::stage_new();
q = st.add_sequence(7, 1, 30.0, stage::PINGPONG);
n = walker(st, q);
st.compose();
for _i in 0..10 {
assert(st.frame_of(n) == 7, "a one-frame sequence never leaves its cell");
st.advance(50000);
}
}
// A still sprite ignores the clock entirely — the degenerate case proved.
fn test_a_still_sprite_never_animates() {
st = stage::stage_new();
n = st.node_add(-1, stage::Place { pl_w: 8.0, pl_h: 8.0 });
st.compose();
st.advance(100000000);
assert(st.frame_of(n) == 0, "no sequence, no motion");
}
// ⚠ THE FRAME REACHES THE INSTANCE BUFFER as ONE float, and the rest of the
// instance is untouched by a step — four floats per animating sprite is what this
// design exists to avoid.
fn test_a_step_dirties_exactly_one_float_per_sprite() {
st = stage::stage_new();
q = st.add_sequence(0, 8, 10.0, stage::LOOP);
_ = walker(st, q);
_ = walker(st, q);
st.compose();
before = st.pack_instances();
st.advance(100000);
after = st.pack_instances();
changed = 0;
for i in 0..len(before) { if before[i] != after[i] { changed = changed + 1; } }
assert(changed == 2, "two sprites, one float each — got {changed}");
assert(before[stage::OFF_CELL] != after[stage::OFF_CELL], "and it is the frame index");
}
// `restart` puts a sequence back to its beginning, which is what a state change
// (idle -> walk) needs.
fn test_restart_returns_to_the_first_frame() {
st = stage::stage_new();
q = st.add_sequence(0, 4, 10.0, stage::LOOP);
n = walker(st, q);
st.compose();
st.advance(250000);
assert(st.frame_of(n) != 0, "somewhere in the middle");
st.restart(n);
assert(st.frame_of(n) == 0, "and back to the start");
}
tests/atmosphere.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN144 L3 — per-layer atmosphere: fog and blur.
//
// A layer already carries a parallax factor (P2). Give it a fog colour, a
// density and a blur radius, and *distant, hazy, out-of-focus* becomes LAYER
// DATA rather than an effects pipeline.
use stage::*;
use graphics::*;
EPS = 0.000001;
fn near(a: float, b: float) -> boolean { a - b < EPS and b - a < EPS }
fn differing(a: const graphics::Canvas, b: const graphics::Canvas) -> integer {
n = 0;
for i in 0..len(a.data) { if (a.data[i] ?? 0) != (b.data[i] ?? 0) { n = n + 1; } }
n
}
fn luminance(cv: const graphics::Canvas) -> integer {
t = 0;
for v in cv.data { t = t + ((v >> 16) & 255) + ((v >> 8) & 255) + (v & 255); }
t
}
fn band(st: stage::Stage, x: float, w: float, layer: integer, colour: integer) -> integer {
st.node_add(-1, stage::Place { pl_x: x, pl_y: 0.0, pl_w: w, pl_h: 10.0,
pl_layer: layer, pl_colour: colour })
}
// ── fog: the degenerate cases proved, as with P2's factor 1.0 ────────────────
// ⚠ DENSITY 0 IS BIT-IDENTICAL TO NO FOG. Setting a fog colour must not tint
// anything until an author asks for density, or the feature changes games that
// never used it.
fn test_fog_at_density_zero_is_bit_identical() {
plain = stage::stage_new();
_ = band(plain, 0.0, 20.0, 0, 0xc03030);
plain.compose();
ca = graphics::canvas(40, 10, 0xff000000);
plain.render_view(0, ca);
fogged = stage::stage_new();
_ = band(fogged, 0.0, 20.0, 0, 0xc03030);
fogged.set_layer_fog(0, 0x2040ff, 0.0);
fogged.compose();
cb = graphics::canvas(40, 10, 0xff000000);
fogged.render_view(0, cb);
assert(differing(ca, cb) == 0, "{differing(ca, cb)} pixels moved at density 0");
}
// ⚠ AND DENSITY 1 IS EXACTLY THE FOG COLOUR — the other end pinned, so the lerp
// cannot be subtly wrong in the middle without one end giving it away.
fn test_fog_at_density_one_is_exactly_the_fog_colour() {
st = stage::stage_new();
_ = band(st, 0.0, 20.0, 0, 0xc03030);
st.set_layer_fog(0, 0x2040ff, 1.0);
st.compose();
cv = graphics::canvas(40, 10, 0xff000000);
st.render_view(0, cv);
assert(cv.get_pixel(5, 5) == 0xff2040ff,
"full fog is the fog colour exactly — got {cv.get_pixel(5, 5)}");
}
// Hand-computed on the way between, with the same rounding the haze mix uses:
// t = round(0.5 * 255) = 128, and (192,48,48) toward (32,64,255) gives
// (112, 56, 152) = 0x703898.
fn test_fog_between_the_ends_is_hand_computed() {
st = stage::stage_new();
_ = band(st, 0.0, 20.0, 0, 0xc03030);
st.set_layer_fog(0, 0x2040ff, 0.5);
st.compose();
cv = graphics::canvas(40, 10, 0xff000000);
st.render_view(0, cv);
assert(cv.get_pixel(5, 5) == 0xff703898, "half fog — got {cv.get_pixel(5, 5)}");
}
// ⚠ FOG IS PER LAYER, so a foggy distance leaves the foreground alone. That is
// the whole reason it is layer data and not a stage-wide setting.
fn test_fog_reaches_only_its_own_layer() {
st = stage::stage_new();
_ = band(st, 0.0, 10.0, 0, 0xc03030); // background
_ = band(st, 20.0, 10.0, 1, 0xc03030); // foreground, same colour
st.set_layer_fog(0, 0x2040ff, 1.0);
st.compose();
cv = graphics::canvas(40, 10, 0xff000000);
st.render_view(0, cv);
assert(cv.get_pixel(5, 5) == 0xff2040ff, "the background is fogged out");
assert(cv.get_pixel(25, 5) == 0xffc03030, "the foreground is untouched");
}
// ── blur ────────────────────────────────────────────────────────────────────
// Radius 0 is no blur at all — the degenerate case, again bit-identical.
fn test_blur_radius_zero_is_bit_identical() {
plain = stage::stage_new();
_ = band(plain, 0.0, 20.0, 0, 0xc03030);
plain.compose();
ca = graphics::canvas(40, 10, 0xff000000);
plain.render_view(0, ca);
z = stage::stage_new();
_ = band(z, 0.0, 20.0, 0, 0xc03030);
z.set_layer_blur(0, 0);
z.compose();
cb = graphics::canvas(40, 10, 0xff000000);
z.render_view(0, cb);
assert(differing(ca, cb) == 0, "radius 0 moved {differing(ca, cb)} pixels");
}
// ⚠ A UNIFORM FIELD BLURS TO ITSELF, EXACTLY. This is the sharpest possible
// statement of *the border does not darken*: every sample is equal, so every
// average is that value — unless the kernel reads outside the image and counts
// black, which is the classic bug and shows up as a dark frame around the edge.
fn test_a_uniform_layer_blurs_to_itself() {
st = stage::stage_new();
_ = band(st, 0.0, 40.0, 0, 0x808080); // fills the whole canvas
st.set_layer_blur(0, 2);
st.compose();
cv = graphics::canvas(40, 10, 0xff000000);
st.render_view(0, cv);
off = 0;
for v in cv.data { if v != 0xff808080 { off = off + 1; } }
assert(off == 0, "{off} pixels changed — the border darkened");
}
// ⚠ AND A NON-UNIFORM LAYER PRESERVES TOTAL LUMINANCE. Sampling outside as
// black loses light everywhere the kernel overhangs; clamping to the edge keeps
// it. Rounding may move the total by a pixel or two, never by a border's worth.
fn test_blur_preserves_total_luminance() {
sharp = stage::stage_new();
_ = band(sharp, 0.0, 12.0, 0, 0xffffff);
_ = band(sharp, 26.0, 14.0, 0, 0x404040);
sharp.compose();
ca = graphics::canvas(40, 10, 0xff000000);
sharp.render_view(0, ca);
soft = stage::stage_new();
_ = band(soft, 0.0, 12.0, 0, 0xffffff);
_ = band(soft, 26.0, 14.0, 0, 0x404040);
soft.set_layer_blur(0, 2);
soft.compose();
cb = graphics::canvas(40, 10, 0xff000000);
soft.render_view(0, cb);
assert(differing(ca, cb) > 0, "the blur did something, or this proves nothing");
before = luminance(ca);
after = luminance(cb);
d = if after > before { after - before } else { before - after };
assert(d * 200 < before,
"luminance moved {before} -> {after} (delta {d}); a lost border shows here");
}
// ⚠ BLUR IS PER LAYER, BEFORE THE LAYERS ABOVE COMPOSITE — which a fullscreen
// blur cannot do and is the entire point. A blurred distance under a sharp
// foreground: the far edge goes soft, the near one stays a hard step.
fn test_a_sharp_foreground_over_a_blurred_background_stays_sharp() {
st = stage::stage_new();
_ = band(st, 0.0, 20.0, 0, 0xffffff); // background, edge at x = 20
st.set_layer_blur(0, 2);
_ = band(st, 30.0, 6.0, 1, 0x00ff00); // foreground, edge at x = 30
st.compose();
cv = graphics::canvas(40, 10, 0xff000000);
st.render_view(0, cv);
// The background edge is SOFT: the pixel on the boundary is neither colour.
e = (cv.get_pixel(20, 5) >> 16) & 255;
assert(e > 0 and e < 255, "the background edge is a gradient, got {e}");
// The foreground edge is HARD: background one side, full colour the other.
assert(cv.get_pixel(29, 5) == 0xff000000, "nothing just left of the foreground");
assert(cv.get_pixel(30, 5) == 0xff00ff00,
"and full colour immediately right — a fullscreen blur would soften this");
}
// ── layer data lives in one place ───────────────────────────────────────────
// ⚠ PARALLAX, FOG AND BLUR ARE ONE LAYER'S FACTS, in one record. They were
// three parallel vectors in an earlier draft, which is exactly the shape that
// put a sequence's name on another sequence's cells in P5: each list stays
// well-formed on its own and only the correspondence goes wrong.
fn test_a_layers_facts_travel_together() {
st = stage::stage_new();
st.set_layer_blur(3, 4); // reaches PAST the layers below it
st.set_parallax(1, 0.25);
st.set_layer_fog(3, 0x102030, 0.75);
assert(near(st.parallax_of(1), 0.25), "layer 1 kept its parallax");
assert(near(st.parallax_of(3), 1.0), "layer 3 was never given one");
assert(st.layer_blur(3) == 4, "layer 3 kept its blur");
assert(st.layer_blur(1) == 0, "layer 1 has none");
assert(st.layer_fog_colour(3) == 0x102030 and near(st.layer_fog_density(3), 0.75),
"and its fog");
assert(near(st.layer_fog_density(1), 0.0), "which layer 1 does not have");
}
// A layer nobody configured is the plain one: parallax 1, no fog, no blur.
fn test_an_unconfigured_layer_is_plain() {
st = stage::stage_new();
assert(near(st.parallax_of(7), 1.0), "the world plane");
assert(st.layer_blur(7) == 0, "sharp");
assert(near(st.layer_fog_density(7), 0.0), "and clear");
}
// ⚠ `blur_region` IS PUBLIC, so its own radius guard is its contract rather than
// a second copy of the layer's policy — `blur_layer` decides whether a layer
// blurs at all, and returns before this is reached. Two guards, two jobs; each
// is gated where it lives, which is what keeps them from becoming one rule with
// two homes.
fn test_blur_region_of_radius_zero_is_the_identity() {
cv = graphics::canvas(8, 4, 0xff000000);
cv.set_pixel(1, 1, 0xffffffff);
cv.set_pixel(6, 2, 0xff204060);
before: vector<integer> = [];
for v in cv.data { before += [v]; }
stage::blur_region(cv, 0, 0, 8, 4, 0);
moved = 0;
for i in 0..len(cv.data) { if (cv.data[i] ?? 0) != (before[i] ?? 0) { moved = moved + 1; } }
assert(moved == 0, "radius 0 changed {moved} pixels");
// And a real radius does move it, or the cell above is vacuous.
stage::blur_region(cv, 0, 0, 8, 4, 1);
spread = 0;
for i in 0..len(cv.data) { if (cv.data[i] ?? 0) != (before[i] ?? 0) { spread = spread + 1; } }
assert(spread > 0, "radius 1 must actually blur");
}
tests/batching.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN144 A3a — the batcher: contiguous runs, and the instance buffer.
//
// The design risk here is not speed, it is ORDER and STRIDE. A batcher that
// gathers every command of one atlas together is faster and draws the wrong
// picture; a stride or offset off by one fails as garbage geometry rather than as
// an error. So every expected value below is hand-computed, and the interleave
// case asserts the COST rather than hiding it.
use stage::*;
EPS = 0.0001;
fn near(a: single, b: float) -> boolean {
d = (a as float) - b;
d < EPS and -d < EPS
}
fn sized(atlas: integer, x: float, colour: integer) -> stage::Place {
stage::Place { pl_x: x, pl_w: 4.0, pl_h: 4.0, pl_atlas: atlas, pl_colour: colour }
}
// One atlas: everything merges into a single run.
fn test_one_atlas_is_one_run() {
st = stage::stage_new();
for i in 0..5 { _ = st.node_add(-1, sized(7, (i * 10) as float, 0x102030)); }
st.compose();
bs = st.batches();
assert(len(bs) == 1, "one atlas gives one run, got {len(bs)}");
assert(bs[0].b_atlas == 7 and bs[0].b_first == 0 and bs[0].b_count == 5,
"the run covers every instance");
}
// Grouped by atlas in scene order: two runs, and the boundary is exactly where
// the atlas changes.
fn test_runs_break_where_the_atlas_changes() {
st = stage::stage_new();
_ = st.node_add(-1, sized(1, 0.0, 0x111111));
_ = st.node_add(-1, sized(1, 10.0, 0x222222));
_ = st.node_add(-1, sized(2, 20.0, 0x333333));
_ = st.node_add(-1, sized(2, 30.0, 0x444444));
st.compose();
bs = st.batches();
assert(len(bs) == 2, "expected 2 runs, got {len(bs)}");
assert(bs[0].b_atlas == 1 and bs[0].b_first == 0 and bs[0].b_count == 2, "first run");
assert(bs[1].b_atlas == 2 and bs[1].b_first == 2 and bs[1].b_count == 2, "second run");
}
// ⚠ THE COST, ASSERTED RATHER THAN HIDDEN. Interleaving two atlases
// sprite-by-sprite gives ONE INSTANCE PER RUN — four draws for four sprites.
// A batcher that answered 2 here would be sorting, and sorting reorders the
// picture under alpha blending. The cure is packing, not batching: sprites that
// draw near each other must share a page.
fn test_interleaving_two_atlases_costs_a_run_each() {
st = stage::stage_new();
_ = st.node_add(-1, sized(1, 0.0, 0x111111));
_ = st.node_add(-1, sized(2, 10.0, 0x222222));
_ = st.node_add(-1, sized(1, 20.0, 0x333333));
_ = st.node_add(-1, sized(2, 30.0, 0x444444));
st.compose();
bs = st.batches();
assert(len(bs) == 4, "interleaved atlases must NOT be gathered; got {len(bs)} runs");
for i in 0..4 { assert(bs[i].b_count == 1 and bs[i].b_first == i, "run {i} is one instance"); }
}
// A batch indexes the packed buffer directly, so the two walks must agree about
// which nodes are skipped.
fn test_batches_and_packing_agree_about_what_is_skipped() {
st = stage::stage_new();
_ = st.node_add(-1, sized(1, 0.0, 0x111111));
_ = st.node_add(-1, stage::Place { pl_x: 5.0 }); // no size
_ = st.node_add(-1, stage::Place { pl_w: 4.0, pl_h: 4.0, pl_visible: false });
_ = st.node_add(-1, sized(1, 20.0, 0x222222));
st.compose();
bs = st.batches();
floats = st.pack_instances();
assert(len(bs) == 1 and bs[0].b_count == 2, "only the two drawable nodes count");
assert(len(floats) == 2 * stage::INSTANCE_STRIDE,
"the buffer holds exactly the batched instances: {len(floats)}");
}
// THE STRIDE GATE. A node at (10, 20), unit scale, no rotation, size 4x4,
// colour 0xff8040, alpha 1: the affine columns carry the SIZE, so
// a = 1*4, b = 0, c = 0, d = 1*4, and the translation is the origin (10, 20).
// rgba is 255/255, 128/255, 64/255, 1.
fn test_instance_layout_is_exact() {
st = stage::stage_new();
_ = st.node_add(-1, stage::Place { pl_x: 10.0, pl_y: 20.0, pl_w: 4.0, pl_h: 4.0,
pl_colour: 0xff8040 });
st.compose();
f = st.pack_instances();
assert(len(f) == stage::INSTANCE_STRIDE, "one instance is one stride");
assert(near(f[stage::OFF_AFFINE + 0], 4.0), "a = sx * w");
assert(near(f[stage::OFF_AFFINE + 1], 0.0), "b");
assert(near(f[stage::OFF_AFFINE + 2], 0.0), "c");
assert(near(f[stage::OFF_AFFINE + 3], 4.0), "d = sy * h");
assert(near(f[stage::OFF_AFFINE + 4], 10.0), "tx is the origin x");
assert(near(f[stage::OFF_AFFINE + 5], 20.0), "ty is the origin y");
// ⚠ This slot carries a FRAME INDEX and the atlas grid, not a uv rect — an
// animating sprite must dirty one float per frame, never four.
assert(near(f[stage::OFF_CELL + 0], 0.0), "a still sprite is on frame 0");
assert(near(f[stage::OFF_CELL + 1], 1.0) and near(f[stage::OFF_CELL + 2], 1.0),
"and a 1x1 grid by default");
assert(near(f[stage::OFF_RGBA + 0], 1.0), "r");
assert(near(f[stage::OFF_RGBA + 1], 128.0 / 255.0), "g");
assert(near(f[stage::OFF_RGBA + 2], 64.0 / 255.0), "b");
assert(near(f[stage::OFF_RGBA + 3], 1.0), "alpha");
}
// The second instance must start exactly one stride in — the off-by-one that
// fails as garbage geometry rather than as an error.
fn test_the_second_instance_starts_one_stride_in() {
st = stage::stage_new();
_ = st.node_add(-1, stage::Place { pl_x: 1.0, pl_y: 2.0, pl_w: 1.0, pl_h: 1.0 });
_ = st.node_add(-1, stage::Place { pl_x: 7.0, pl_y: 9.0, pl_w: 1.0, pl_h: 1.0 });
st.compose();
f = st.pack_instances();
assert(len(f) == 2 * stage::INSTANCE_STRIDE, "two instances");
assert(near(f[stage::INSTANCE_STRIDE + stage::OFF_AFFINE + 4], 7.0), "second tx");
assert(near(f[stage::INSTANCE_STRIDE + stage::OFF_AFFINE + 5], 9.0), "second ty");
}
// Rotation and scale reach the affine, so the shader keeps drawing a unit quad.
// A quarter turn with size 4x4: local +x becomes world +y, so a = 0, b = 4,
// and local +y becomes world -x, so c = -4, d = 0.
fn test_rotation_reaches_the_instance_affine() {
st = stage::stage_new();
_ = st.node_add(-1, stage::Place { pl_rot: 1.5707963267948966, pl_w: 4.0, pl_h: 4.0 });
st.compose();
f = st.pack_instances();
assert(near(f[stage::OFF_AFFINE + 0], 0.0), "a");
assert(near(f[stage::OFF_AFFINE + 1], 4.0), "b");
assert(near(f[stage::OFF_AFFINE + 2], -4.0), "c");
assert(near(f[stage::OFF_AFFINE + 3], 0.0), "d");
}
tests/camera.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN144 P2 — the camera, and the two scroll modes as ONE mechanism.
//
// Riding left and right across a world is a camera pan; a background that lags is
// the same pan at a smaller factor. Flat scrolling is every factor at 1.0, and
// that is proved here as a degenerate case rather than maintained as a second path.
use stage::*;
use graphics::*;
fn shot(st: const stage::Stage) -> graphics::Canvas {
cv = graphics::canvas(64, 32, 0xff000000);
st.render_stage(cv);
cv
}
fn same(a: const graphics::Canvas, b: const graphics::Canvas) -> boolean {
for i in 0..len(a.data) { if a.data[i] != b.data[i] { return false; } }
true
}
// FLAT MODE IS THE DEGENERATE CASE: with every factor 1.0, panning the camera is
// pixel-identical to having placed every node that much further left.
fn test_a_pan_equals_moving_every_node() {
panned = stage::stage_new();
_ = panned.node_add(-1, stage::Place { pl_x: 30.0, pl_y: 10.0, pl_w: 8.0, pl_h: 8.0,
pl_colour: 0x00ff00 });
_ = panned.node_add(-1, stage::Place { pl_x: 45.0, pl_y: 4.0, pl_w: 6.0, pl_h: 12.0,
pl_colour: 0x0000ff });
panned.compose();
panned.set_camera(12.0, 3.0);
byhand = stage::stage_new();
_ = byhand.node_add(-1, stage::Place { pl_x: 18.0, pl_y: 7.0, pl_w: 8.0, pl_h: 8.0,
pl_colour: 0x00ff00 });
_ = byhand.node_add(-1, stage::Place { pl_x: 33.0, pl_y: 1.0, pl_w: 6.0, pl_h: 12.0,
pl_colour: 0x0000ff });
byhand.compose();
assert(same(shot(panned), shot(byhand)),
"a pan of (12,3) draws exactly what placing the nodes 12 left and 3 up draws");
}
// A layer feels its OWN share of the pan: half the factor, half the movement.
fn test_a_layer_moves_by_its_factor() {
st = stage::stage_new();
_ = st.node_add(-1, stage::Place { pl_w: 4.0, pl_h: 4.0, pl_layer: 0 });
_ = st.node_add(-1, stage::Place { pl_w: 4.0, pl_h: 4.0, pl_layer: 1 });
st.set_parallax(0, 0.5);
st.set_parallax(1, 1.0);
st.compose();
st.set_camera(20.0, 8.0);
back = st.cam_offset(0);
front = st.cam_offset(1);
assert(back.p_x == -10.0 and back.p_y == -4.0, "half factor, half the pan");
assert(front.p_x == -20.0 and front.p_y == -8.0, "full factor, all of it");
}
// A layer with no factor set behaves as 1.0 — the world plane, without ceremony.
fn test_an_unset_layer_is_the_world_plane() {
st = stage::stage_new();
st.set_camera(7.0, 0.0);
assert(st.parallax_of(3) == 1.0, "unset layers are 1.0");
assert(st.cam_offset(3).p_x == -7.0, "and move with the world");
}
// ⚠ THE PROPERTY THE DESIGN TURNS ON: a pure camera move re-uploads NOTHING.
// Baking the camera into node positions would rewrite every node and re-upload the
// whole instance buffer on every scrolled frame.
fn test_a_camera_move_does_not_dirty_the_buffer() {
st = stage::stage_new();
for i in 0..8 {
_ = st.node_add(-1, stage::Place { pl_x: (i * 4) as float, pl_w: 3.0, pl_h: 3.0 });
}
st.compose();
before = st.pack_instances();
for i in 0..100 { st.set_camera((i * 3) as float, 0.0); }
after = st.pack_instances();
assert(len(before) == len(after), "the buffer is the same size");
differs = false;
for i in 0..len(before) { if before[i] != after[i] { differs = true; } }
assert(!differs, "and 100 camera moves changed not one float of it");
}
// ⚠ PICKING UN-APPLIES THE CAMERA PER LAYER. One screen point is a different
// world point in each layer, so a single inverse picks the wrong thing the moment
// a layer lags.
fn test_picking_un_applies_the_camera_per_layer() {
st = stage::stage_new();
back = st.node_add(-1, stage::Place { pl_x: 0.0, pl_y: 0.0, pl_w: 10.0, pl_h: 10.0,
pl_layer: 0 });
front = st.node_add(-1, stage::Place { pl_x: 40.0, pl_y: 0.0, pl_w: 10.0, pl_h: 10.0,
pl_layer: 1 });
st.set_parallax(0, 0.5);
st.compose();
st.set_camera(20.0, 0.0);
// back is drawn at 0 - 20*0.5 = -10 .. 0 -> screen x 5 misses it
// front is drawn at 40 - 20*1.0 = 20 .. 30 -> screen x 25 hits it
assert(st.pick(25.0, 5.0) == front, "the front layer is where its own factor puts it");
assert(st.pick(-5.0, 5.0) == back, "and the lagging layer where ITS factor puts it");
assert(st.pick(5.0, 5.0) == -1, "between them, nothing");
}
// Parallax TRANSLATES; it never resizes. Distance-as-smaller is the depth cue's
// job, which scales about the origin and keeps a sprite's feet planted.
fn test_parallax_does_not_resize() {
st = stage::stage_new();
n = st.node_add(-1, stage::Place { pl_w: 10.0, pl_h: 10.0, pl_layer: 0 });
st.set_parallax(0, 0.25);
st.compose();
st.set_camera(100.0, 0.0);
b = st.bounds_of(n);
assert(b.b_x1 - b.b_x0 > 9.99 and b.b_x1 - b.b_x0 < 10.01,
"the node is still 10 wide at any parallax");
}
tests/clipping.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN144 A6 — clipping: inherited, intersected, and breaking a batch.
//
// Every count below is hand-computed from the rectangles, and the classification
// checks the `other` bucket is 0 — a pixel of a third colour would mean the clip
// half-applied, which a total count alone would hide.
use stage::*;
use graphics::*;
fn count_of(cv: const graphics::Canvas, want: integer) -> integer {
n = 0;
for v in cv.data { if v == want { n = n + 1; } }
n
}
// A clipping parent cuts its child at the exact boundary.
// Parent clips to (0,0)-(10,10); the child covers (5,5)-(25,25); the visible part
// is (5,5)-(10,10) = 5 x 5 = 25 pixels.
fn test_a_child_is_cut_at_the_parent_boundary() {
st = stage::stage_new();
p = st.node_add(-1, stage::Place { pl_w: 10.0, pl_h: 10.0, pl_clips: true });
_ = st.node_add(p, stage::Place { pl_x: 5.0, pl_y: 5.0, pl_w: 20.0, pl_h: 20.0,
pl_colour: 0x00ff00 });
st.compose();
cv = graphics::canvas(32, 32, 0xff000000);
st.render_stage(cv);
assert(count_of(cv, 0xff00ff00) == 25,
"5x5 survives the clip, got {count_of(cv, 0xff00ff00)}");
}
// Nested two deep: the inner clip is the INTERSECTION, never the innermost alone.
// Outer clips to (0,0)-(20,20); inner clips to (10,10)-(40,40); the intersection is
// (10,10)-(20,20). A child covering everything therefore shows 10 x 10 = 100.
fn test_nested_clips_intersect() {
st = stage::stage_new();
outer = st.node_add(-1, stage::Place { pl_w: 20.0, pl_h: 20.0, pl_clips: true });
inner = st.node_add(outer, stage::Place { pl_x: 10.0, pl_y: 10.0, pl_w: 30.0,
pl_h: 30.0, pl_clips: true });
_ = st.node_add(inner, stage::Place { pl_x: -10.0, pl_y: -10.0, pl_w: 60.0,
pl_h: 60.0, pl_colour: 0x00ff00 });
st.compose();
cv = graphics::canvas(48, 48, 0xff000000);
st.render_stage(cv);
assert(count_of(cv, 0xff00ff00) == 100,
"the intersection is 10x10, got {count_of(cv, 0xff00ff00)}");
}
// Clips that do not overlap show nothing at all — the empty intersection.
fn test_disjoint_clips_show_nothing() {
st = stage::stage_new();
outer = st.node_add(-1, stage::Place { pl_w: 10.0, pl_h: 10.0, pl_clips: true });
inner = st.node_add(outer, stage::Place { pl_x: 20.0, pl_y: 20.0, pl_w: 10.0,
pl_h: 10.0, pl_clips: true });
_ = st.node_add(inner, stage::Place { pl_w: 40.0, pl_h: 40.0, pl_colour: 0x00ff00 });
st.compose();
cv = graphics::canvas(48, 48, 0xff000000);
st.render_stage(cv);
assert(count_of(cv, 0xff00ff00) == 0, "disjoint clips leave nothing visible");
}
// A clipper does not clip its SIBLINGS, only its own subtree.
fn test_a_clip_reaches_only_its_own_subtree() {
st = stage::stage_new();
clipper = st.node_add(-1, stage::Place { pl_w: 8.0, pl_h: 8.0, pl_clips: true });
_ = st.node_add(clipper, stage::Place { pl_w: 40.0, pl_h: 40.0, pl_colour: 0x00ff00 });
_ = st.node_add(-1, stage::Place { pl_x: 20.0, pl_y: 0.0, pl_w: 10.0, pl_h: 10.0,
pl_colour: 0x0000ff });
st.compose();
cv = graphics::canvas(48, 48, 0xff000000);
st.render_stage(cv);
assert(count_of(cv, 0xff00ff00) == 64, "the clipped child is 8x8");
assert(count_of(cv, 0xff0000ff) == 100, "the sibling is untouched at 10x10");
}
// The degenerate case, proved rather than assumed: no clip anywhere is the same
// picture as before clipping existed.
fn test_an_unclipped_scene_is_unchanged() {
st = stage::stage_new();
_ = st.node_add(-1, stage::Place { pl_x: 3.0, pl_y: 4.0, pl_w: 6.0, pl_h: 5.0,
pl_colour: 0x00ff00 });
st.compose();
cv = graphics::canvas(16, 16, 0xff000000);
st.render_stage(cv);
assert(count_of(cv, 0xff00ff00) == 30, "6x5 = 30 pixels, all of them");
}
// ⚠ A CLIP CHANGE BREAKS A BATCH. Two nodes on the SAME atlas under DIFFERENT
// clips are two runs — a scissor is state, exactly like a texture. Grouping by
// atlas alone would draw the second under the first's scissor.
fn test_a_clip_change_breaks_a_run() {
st = stage::stage_new();
// ⚠ A clip region is SIZED so it has a rect, and INVISIBLE so it is not itself
// drawn — the idiom for scaffolding that shapes its subtree without painting.
a = st.node_add(-1, stage::Place { pl_w: 10.0, pl_h: 10.0, pl_clips: true,
pl_visible: false });
_ = st.node_add(a, stage::Place { pl_w: 4.0, pl_h: 4.0, pl_atlas: 1 });
b = st.node_add(-1, stage::Place { pl_x: 20.0, pl_w: 10.0, pl_h: 10.0, pl_clips: true,
pl_visible: false });
_ = st.node_add(b, stage::Place { pl_w: 4.0, pl_h: 4.0, pl_atlas: 1 });
st.compose();
bs = st.batches();
// Only the two children reach the list — same atlas, different scissor.
assert(len(bs) == 2, "one atlas but two clips is two runs, got {len(bs)}");
assert(bs[0].b_clipped and bs[1].b_clipped, "both runs carry a scissor");
assert(bs[0].b_cx0 != bs[1].b_cx0, "and the scissors differ");
}
// Two nodes under the SAME clip stay one run — the break is a change, not a clip.
fn test_one_clip_is_still_one_run() {
st = stage::stage_new();
c = st.node_add(-1, stage::Place { pl_w: 20.0, pl_h: 20.0, pl_clips: true,
pl_visible: false });
_ = st.node_add(c, stage::Place { pl_w: 4.0, pl_h: 4.0, pl_atlas: 1 });
_ = st.node_add(c, stage::Place { pl_x: 6.0, pl_w: 4.0, pl_h: 4.0, pl_atlas: 1 });
st.compose();
assert(len(st.batches()) == 1, "same atlas, same clip, one run");
}
// ⚠ A CLIP AND A CAMERA, TOGETHER — the off-diagonal cell neither phase tested.
// The clip is derived in WORLD space by `compose`; what it cuts has already been
// moved by the camera. Applying it raw cut the wrong region, and a pan wider
// than the node cut ALL of it: a clipped panel silently disappeared the moment
// the camera moved. A6 and P2 were each green alone.
// A clipping parent at world (0,0) 10x10 with a child filling it. Pan the
// camera by 20: the whole thing should move 20 left and stay 10x10.
fn test_a_clip_follows_its_content_under_a_camera() {
st = stage::stage_new();
p = st.node_add(-1, stage::Place { pl_w: 10.0, pl_h: 10.0, pl_x: 30.0, pl_y: 0.0,
pl_clips: true });
_ = st.node_add(p, stage::Place { pl_w: 10.0, pl_h: 10.0, pl_colour: 0xc03030 });
st.compose();
a = graphics::canvas(60, 20, 0xff000000);
st.render_stage(a);
assert(count_of(a, 0xffc03030) == 100, "unpanned: 10x10 = 100 px, got {count_of(a, 0xffc03030)}");
assert(a.get_pixel(35, 5) == 0xffc03030, "and it is at x=30..40");
st.set_camera(20.0, 0.0);
b = graphics::canvas(60, 20, 0xff000000);
st.render_stage(b);
assert(count_of(b, 0xffc03030) == 100,
"panned: STILL 10x10 = 100 px, got {count_of(b, 0xffc03030)}");
assert(b.get_pixel(15, 5) == 0xffc03030, "moved to x=10..20");
}
tests/compositing.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN144 A5 — alpha and tint, composited, with every expected byte worked out by
// hand. Premultiplied throughout: `over` is `src + dst*(1-a)` with `src` already
// multiplied, because straight alpha darkens an anti-aliased edge under linear
// filtering and this stack is made of overlapping soft-edged sprites.
use stage::*;
use graphics::*;
// Classify a canvas by EXACT colour, dryopea's RENDERER.md R0 technique: a
// byte-diff says *different*, a classification says *what*. `other` must read 0.
fn count_of(cv: const graphics::Canvas, want: integer) -> integer {
n = 0;
for v in cv.data { if v == want { n = n + 1; } }
n
}
fn other_than(cv: const graphics::Canvas, a: integer, b: integer) -> integer {
n = 0;
for v in cv.data { if v != a and v != b { n = n + 1; } }
n
}
// Alpha 1 is exactly the colour — the degenerate case proved, not assumed.
fn test_full_alpha_is_the_colour() {
st = stage::stage_new();
_ = st.node_add(-1, stage::Place { pl_w: 4.0, pl_h: 4.0, pl_colour: 0xc03030 });
st.compose();
cv = graphics::canvas(8, 8, 0xff000000);
st.render_stage(cv);
assert(cv.get_pixel(1, 1) == 0xffc03030, "opaque paints the colour exactly");
assert(count_of(cv, 0xffc03030) == 16, "a 4x4 rect is 16 pixels");
assert(other_than(cv, 0xffc03030, 0xff000000) == 0, "no third colour anywhere");
}
// Alpha 0 leaves the destination untouched.
fn test_zero_alpha_paints_nothing() {
st = stage::stage_new();
_ = st.node_add(-1, stage::Place { pl_w: 4.0, pl_h: 4.0, pl_colour: 0xffffff,
pl_alpha: 0.0 });
st.compose();
cv = graphics::canvas(8, 8, 0xff102030);
st.render_stage(cv);
assert(count_of(cv, 0xff102030) == 64, "every pixel is still the background");
}
// THE HAND-COMPUTED CELL. alpha 0.5 -> ia = round(0.5*255) = 128.
// premultiplied source red = (255*128 + 127) / 255 = 32767/255 = 128
// over(128, 0, 128) = (128*255 + 0*127 + 127) / 255 = 32767/255 = 128
// so pure red at half alpha over black is exactly 0xff800000.
fn test_half_alpha_over_black() {
st = stage::stage_new();
_ = st.node_add(-1, stage::Place { pl_w: 4.0, pl_h: 4.0, pl_colour: 0xff0000,
pl_alpha: 0.5 });
st.compose();
cv = graphics::canvas(8, 8, 0xff000000);
st.render_stage(cv);
assert(cv.get_pixel(1, 1) == 0xff800000,
"half red over black is 0x80, got {cv.get_pixel(1, 1)}");
assert(other_than(cv, 0xff800000, 0xff000000) == 0, "no third colour");
}
// Half alpha over a NON-black destination — the case that catches a composite
// which ignores what is underneath.
// dst = 0x40, src premultiplied = 128
// over(128, 64, 128) = (128*255 + 64*127 + 127) / 255 = (32640+8128+127)/255
// = 40895/255 = 160
fn test_half_alpha_over_a_colour() {
st = stage::stage_new();
_ = st.node_add(-1, stage::Place { pl_w: 4.0, pl_h: 4.0, pl_colour: 0xff0000,
pl_alpha: 0.5 });
st.compose();
cv = graphics::canvas(8, 8, 0xff404040);
st.render_stage(cv);
got = cv.get_pixel(1, 1);
assert(got == 0xffa02020, "half red over 0x40 grey is 0xa02020, got {got}");
}
// Two translucent nodes stack IN ORDER, and the second sees the first's result.
//
// ⚠ Work the channels SEPARATELY — the mistake this comment replaces was carrying
// red's destination into green and blue. After node 1 the pixel is (128, 0, 0),
// not (128, 128, 128). Node 2 is white at 0.5, so its premultiplied source is
// (128, 128, 128) and:
// R: over(128, 128, 128) = (128*255 + 128*127 + 127)/255 = 49023/255 = 192
// G: over(128, 0, 128) = (128*255 + 0*127 + 127)/255 = 32767/255 = 128
// B: same as G = 128
fn test_two_translucent_nodes_stack_in_order() {
st = stage::stage_new();
_ = st.node_add(-1, stage::Place { pl_w: 4.0, pl_h: 4.0, pl_colour: 0xff0000,
pl_alpha: 0.5 });
_ = st.node_add(-1, stage::Place { pl_w: 4.0, pl_h: 4.0, pl_colour: 0xffffff,
pl_alpha: 0.5 });
st.compose();
cv = graphics::canvas(8, 8, 0xff000000);
st.render_stage(cv);
got = cv.get_pixel(1, 1);
assert(got == 0xffc08080, "red then white, both half — expect 0xc08080, got {got}");
}
// Tint is the node's own colour: two nodes of the same shape and alpha differ only
// by it, and the difference survives compositing.
fn test_tint_reaches_the_composite() {
st = stage::stage_new();
_ = st.node_add(-1, stage::Place { pl_x: 0.0, pl_w: 2.0, pl_h: 2.0, pl_colour: 0x00ff00 });
_ = st.node_add(-1, stage::Place { pl_x: 4.0, pl_w: 2.0, pl_h: 2.0, pl_colour: 0x0000ff });
st.compose();
cv = graphics::canvas(8, 8, 0xff000000);
st.render_stage(cv);
assert(cv.get_pixel(1, 1) == 0xff00ff00 and cv.get_pixel(5, 1) == 0xff0000ff,
"each node keeps its own tint");
}
// The two paths agree where alpha is 1 — `render` (a lavition_ui command list) and
// `render_stage` must not drift into two different pictures.
fn test_the_two_render_paths_agree_at_full_alpha() {
st = stage::stage_new();
_ = st.node_add(-1, stage::Place { pl_x: 2.0, pl_y: 3.0, pl_w: 5.0, pl_h: 4.0,
pl_colour: 0x3060a0 });
st.compose();
a = graphics::canvas(16, 16, 0xff000000);
st.render_stage(a);
b = graphics::canvas(16, 16, 0xff000000);
stage::render(st.draw_list(), b);
same = true;
for i in 0..len(a.data) { if a.data[i] != b.data[i] { same = false; } }
assert(same, "render_stage and render draw the same opaque picture");
}
tests/depth.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN144 P1 — layer and depth: how a 2-D scene presents a 3-D world.
//
// The case these exist for: buildings authored in 3-D and projected to a
// footprint, with mobs, workers and a player as sprites that must sort correctly
// against them. A sprite standing in front of a wall covers it; one standing
// behind it does not; and neither may flicker between frames.
use stage::*;
use graphics::*;
fn at(depth: float, layer: integer, colour: integer) -> stage::Place {
stage::Place { pl_w: 8.0, pl_h: 8.0, pl_depth: depth, pl_layer: layer,
pl_colour: colour }
}
fn seq(st: const stage::Stage) -> text {
out = "";
for i in st.order() { out += "{i},"; }
out
}
// Depth decides, whatever the insertion order was.
fn test_depth_orders_the_draw() {
st = stage::stage_new();
_ = st.node_add(-1, at(10.0, 0, 0x111111)); // 0 — far (large depth)
_ = st.node_add(-1, at(-5.0, 0, 0x222222)); // 1 — near
_ = st.node_add(-1, at(0.0, 0, 0x333333)); // 2 — middle
st.compose();
// Depth is distance INTO the screen, so the far one draws first.
assert(seq(st) == "0,2,1,", "far to near, got {seq(st)}");
}
// ⚠ LAYER OUTRANKS DEPTH ENTIRELY. A UI element at a huge depth still draws over
// a world sprite at a tiny one — that is what makes background / world / UI bands
// rather than a single number every node has to get right.
fn test_layer_outranks_depth() {
st = stage::stage_new();
// ⚠ The depths are chosen so layer-first and depth-first DISAGREE. With the UI
// element the FURTHER of the two, a sort that consulted depth first would draw it
// underneath — and an earlier version of this test used depths that gave the same
// answer either way, so it could not tell them apart.
_ = st.node_add(-1, at(1000.0, 1, 0x111111)); // 0 — UI band, very "far"
_ = st.node_add(-1, at(-1000.0, 0, 0x222222)); // 1 — world band, very "near"
st.compose();
assert(seq(st) == "1,0,", "the world band draws first however near it is");
}
// ⚠ THE SORT IS STABLE, and that is the third key's whole job. Two sprites on the
// same footprint — a worker standing where another stands — must not swap from
// frame to frame.
fn test_equal_depth_keeps_insertion_order() {
st = stage::stage_new();
// Six equal depths, deliberately more than the merge width so the tie-break is
// exercised at every level of the merge rather than only in one pass.
for _i in 0..6 { _ = st.node_add(-1, at(3.0, 0, 0x111111)); }
st.compose();
assert(seq(st) == "0,1,2,3,4,5,", "ties keep insertion order, got {seq(st)}");
st.compose();
assert(seq(st) == "0,1,2,3,4,5,", "and composing again does not reshuffle them");
}
// The 2.5-D idiom, end to end: `depth = -y` makes a sprite lower on the screen
// draw in front. A worker at y=40 covers a wall at y=20.
fn test_depth_from_minus_y_puts_the_lower_sprite_in_front() {
st = stage::stage_new();
wall = st.node_add(-1, stage::Place { pl_x: 0.0, pl_y: 20.0, pl_w: 16.0, pl_h: 16.0,
pl_depth: -20.0, pl_colour: 0x808080 });
worker = st.node_add(-1, stage::Place { pl_x: 4.0, pl_y: 28.0, pl_w: 8.0, pl_h: 8.0,
pl_depth: -28.0, pl_colour: 0x00ff00 });
st.compose();
assert(st.order()[1] == worker, "the lower sprite draws last");
cv = graphics::canvas(32, 48, 0xff000000);
st.render_stage(cv);
assert(cv.get_pixel(6, 30) == 0xff00ff00, "and it covers the wall where they overlap");
assert(cv.get_pixel(6, 22) == 0xff808080, "the wall still shows above it");
assert(wall == 0, "insertion order is unchanged by sorting");
}
// Picking follows the same order, so what you click is what you see on top.
fn test_picking_follows_the_draw_order() {
st = stage::stage_new();
// ⚠ The NEAR node is added FIRST, so the drawn order (far, near) is the reverse
// of the index order. A pick that walked indices instead of the order would
// answer `far` here and would have agreed with this test when the two coincided.
near = st.node_add(-1, stage::Place { pl_w: 20.0, pl_h: 20.0, pl_depth: -5.0 });
far = st.node_add(-1, stage::Place { pl_w: 20.0, pl_h: 20.0, pl_depth: 5.0 });
st.compose();
assert(near == 0 and far == 1, "indices are insertion order");
assert(seq(st) == "1,0,", "but the drawn order is far then near");
assert(st.pick(10.0, 10.0) == near, "and the pick follows the DRAWN order");
}
// A run still breaks on a state change, now measured along the SORTED order — the
// batcher sees the scene in the order it is drawn, never the order it was built.
fn test_batches_follow_the_sorted_order() {
st = stage::stage_new();
_ = st.node_add(-1, stage::Place { pl_w: 4.0, pl_h: 4.0, pl_atlas: 1, pl_depth: 2.0 });
_ = st.node_add(-1, stage::Place { pl_w: 4.0, pl_h: 4.0, pl_atlas: 2, pl_depth: 1.0 });
_ = st.node_add(-1, stage::Place { pl_w: 4.0, pl_h: 4.0, pl_atlas: 1, pl_depth: 0.0 });
st.compose();
bs = st.batches();
// sorted: atlas 1 (depth 0), atlas 2 (depth 1), atlas 1 (depth 2) -> three runs
assert(len(bs) == 3, "three runs along the drawn order, got {len(bs)}");
assert(bs[0].b_atlas == 1 and bs[1].b_atlas == 2 and bs[2].b_atlas == 1,
"and in the drawn sequence");
}
// ── the 2.5-D depth cue ──────────────────────────────────────────────────────
// Off by default, and off means an IDENTICAL picture — the degenerate case proved
// rather than assumed, as with the camera's factor 1.0 and fog's density 0.
fn test_the_cue_is_off_by_default() {
st = stage::stage_new();
_ = st.node_add(-1, stage::Place { pl_x: 4.0, pl_y: 4.0, pl_w: 8.0, pl_h: 8.0,
pl_depth: 100.0, pl_colour: 0x00ff00 });
st.compose();
cv = graphics::canvas(32, 32, 0xff000000);
st.render_stage(cv);
n = 0;
for v in cv.data { if v == 0xff00ff00 { n = n + 1; } }
assert(n == 64, "8x8 undisturbed, got {n}");
assert(st.haze_of(0) == 0.0, "and no haze");
}
// ⚠ THE PROPERTY THAT MATTERS: a distant node is drawn SMALLER but its ORIGIN
// DOES NOT MOVE. A cue that shrank about the centre would slide a mob off the
// tile it is standing on, which is precisely the bug 2.5-D art shows worst.
fn test_distance_shrinks_a_sprite_without_moving_its_feet() {
st = stage::stage_new();
// origin at the sprite's feet: bottom-centre of a 10x10 body.
far = st.node_add(-1, stage::Place { pl_x: 20.0, pl_y: 20.0, pl_w: 10.0, pl_h: 10.0,
pl_ox: 5.0, pl_oy: 10.0, pl_depth: 100.0 });
st.depth_cue(0.0, 100.0, 0.5, 0.0, 0x000000);
st.compose();
o = st.world_origin(far);
assert(o.p_x > 19.99 and o.p_x < 20.01 and o.p_y > 19.99 and o.p_y < 20.01,
"the feet stay at (20,20), got ({o.p_x}, {o.p_y})");
b = st.bounds_of(far);
assert(b.b_x1 - b.b_x0 > 4.99 and b.b_x1 - b.b_x0 < 5.01,
"and the body is half as wide: {b.b_x1 - b.b_x0}");
assert(b.b_y1 > 19.99 and b.b_y1 < 20.01, "its base still rests on the same line");
}
// The near plane is untouched, so only DISTANCE costs size.
fn test_the_near_plane_is_untouched() {
st = stage::stage_new();
near = st.node_add(-1, stage::Place { pl_w: 10.0, pl_h: 10.0, pl_depth: 0.0 });
st.depth_cue(0.0, 100.0, 0.25, 1.0, 0x000000);
st.compose();
b = st.bounds_of(near);
assert(b.b_x1 - b.b_x0 > 9.99, "full size at the near plane");
assert(st.haze_of(near) == 0.0, "and no haze");
}
// Haze interpolates, and is CLAMPED outside the planes so a stray depth cannot
// invert the effect.
fn test_haze_interpolates_and_clamps() {
st = stage::stage_new();
a = st.node_add(-1, stage::Place { pl_w: 2.0, pl_h: 2.0, pl_depth: 50.0 });
b = st.node_add(-1, stage::Place { pl_w: 2.0, pl_h: 2.0, pl_depth: 500.0 });
c = st.node_add(-1, stage::Place { pl_w: 2.0, pl_h: 2.0, pl_depth: -500.0 });
st.depth_cue(0.0, 100.0, 1.0, 0.8, 0x000000);
st.compose();
h = st.haze_of(a);
assert(h > 0.39 and h < 0.41, "half way is 0.4 of 0.8, got {h}");
assert(st.haze_of(b) > 0.79 and st.haze_of(b) < 0.81, "beyond the far plane clamps");
assert(st.haze_of(c) == 0.0, "nearer than the near plane clamps");
}
// A hazed node is painted toward the haze colour. Fully hazed toward white, a
// pure red node paints white.
fn test_haze_fades_the_paint() {
st = stage::stage_new();
_ = st.node_add(-1, stage::Place { pl_w: 4.0, pl_h: 4.0, pl_colour: 0xff0000,
pl_depth: 100.0 });
st.depth_cue(0.0, 100.0, 1.0, 1.0, 0xffffff);
st.compose();
cv = graphics::canvas(8, 8, 0xff000000);
st.render_stage(cv);
assert(cv.get_pixel(1, 1) == 0xffffffff,
"fully hazed toward white, got {cv.get_pixel(1, 1)}");
}
tests/draw_list.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN144 A2 — the stage draws what a hand-written immediate-mode pass draws.
//
// This is a PARALLEL RUN, which is the point: the old way and the new way both
// exist and are compared exactly, pixel for pixel, rather than one being swapped
// in and eyeballed. A software `Canvas` is the comparison surface because it is
// headless and deterministic — no window, no driver, no timing.
use stage::*;
use graphics::*;
fn canvas_equal(a: const graphics::Canvas, b: const graphics::Canvas) -> boolean {
if a.width != b.width or a.height != b.height { return false; }
for i in 0..len(a.data) {
if a.data[i] != b.data[i] { return false; }
}
true
}
fn differing(a: const graphics::Canvas, b: const graphics::Canvas) -> integer {
n = 0;
for i in 0..len(a.data) { if a.data[i] != b.data[i] { n = n + 1; } }
n
}
// THE GATE: a scene rendered through the stage is byte-identical to the same
// rectangles drawn by hand.
fn test_stage_matches_a_hand_written_pass() {
st = stage::stage_new();
_ = st.node_add(-1, stage::Place { pl_x: 10.0, pl_y: 12.0, pl_w: 30.0, pl_h: 20.0,
pl_colour: 0xc03030 });
_ = st.node_add(-1, stage::Place { pl_x: 50.0, pl_y: 40.0, pl_w: 12.0, pl_h: 44.0,
pl_colour: 0x2080c0 });
st.compose();
by_stage = graphics::canvas(128, 96, 0xff000000);
stage::render(st.draw_list(), by_stage);
by_hand = graphics::canvas(128, 96, 0xff000000);
by_hand.fill_rect(10, 12, 30, 20, 0xffc03030);
by_hand.fill_rect(50, 40, 12, 44, 0xff2080c0);
assert(canvas_equal(by_stage, by_hand),
"stage output differs from the hand-written pass in {differing(by_stage, by_hand)} pixels");
}
// A CHILD inherits its parent's transform, and the hand-written equivalent has to
// do that arithmetic itself — which is the ergonomic difference the arc exists for.
fn test_a_child_is_placed_through_its_parent() {
st = stage::stage_new();
p = st.node_add(-1, stage::Place { pl_x: 20.0, pl_y: 30.0, pl_sx: 2.0, pl_sy: 2.0 });
_ = st.node_add(p, stage::Place { pl_x: 5.0, pl_y: 5.0, pl_w: 10.0, pl_h: 10.0,
pl_colour: 0x40a040 });
st.compose();
by_stage = graphics::canvas(96, 96, 0xff000000);
stage::render(st.draw_list(), by_stage);
// by hand: the child sits at parent (20,30) + 2 x (5,5) = (30,40), sized 2 x 10.
by_hand = graphics::canvas(96, 96, 0xff000000);
by_hand.fill_rect(30, 40, 20, 20, 0xff40a040);
assert(canvas_equal(by_stage, by_hand),
"child placement differs in {differing(by_stage, by_hand)} pixels");
}
// The alpha seam. A `DrawRect` carries 0xRRGGBB and `Canvas` reads 0xAARRGGBB,
// where a missing top byte means FULLY TRANSPARENT — graphics' own trap.
// Without `opaque` at the boundary the stage paints nothing at all, and the canvas
// stays exactly the colour it was cleared to.
//
// Example: @GFX-001
fn test_the_alpha_byte_is_added_at_the_boundary() {
assert(stage::opaque(0xc03030) == 0xffc03030, "0xRRGGBB gains an opaque alpha");
assert(stage::opaque(0x80112233) == 0x80112233, "an explicit alpha is left alone");
st = stage::stage_new();
_ = st.node_add(-1, stage::Place { pl_x: 0.0, pl_y: 0.0, pl_w: 4.0, pl_h: 4.0,
pl_colour: 0xc03030 });
st.compose();
cv = graphics::canvas(8, 8, 0xff000000);
stage::render(st.draw_list(), cv);
assert(cv.get_pixel(1, 1) == 0xffc03030, "a 0xRRGGBB node paints, rather than vanishing");
}
// Scaffolding emits nothing: a parent that exists only to hold a transform has no
// size, and a zero-sized node must not reach the list at all.
fn test_a_sizeless_node_emits_nothing() {
st = stage::stage_new();
p = st.node_add(-1, stage::Place { pl_x: 10.0, pl_y: 10.0 });
_ = st.node_add(p, stage::Place { pl_w: 5.0, pl_h: 5.0, pl_colour: 0x111111 });
hidden = st.node_add(-1, stage::Place { pl_w: 5.0, pl_h: 5.0, pl_visible: false });
st.compose();
assert(len(st.draw_list()) == 1, "only the sized, visible node emits");
assert(hidden == 2, "an invisible node still occupies its index");
}
// A list shaped exactly as `lavition_ui`'s `panel_draw_list` produces renders
// UNMODIFIED — no shim, no field rename. The field names here are copied from
// that package; if this ever needs adapting, the command shape has drifted.
fn test_a_panel_shaped_list_renders_unmodified() {
panel: vector<stage::DrawRect> = [
stage::DrawRect { dr_rect: stage::UiRect { r_x: 2, r_y: 2, r_w: 40, r_h: 12 },
dr_colour: 0x303038 },
stage::DrawRect { dr_rect: stage::UiRect { r_x: 4, r_y: 4, r_w: 12, r_h: 8 },
dr_colour: 0x5060a0 },
];
by_list = graphics::canvas(64, 32, 0xff000000);
stage::render(panel, by_list);
by_hand = graphics::canvas(64, 32, 0xff000000);
by_hand.fill_rect(2, 2, 40, 12, 0xff303038);
by_hand.fill_rect(4, 4, 12, 8, 0xff5060a0);
assert(canvas_equal(by_list, by_hand), "a panel-shaped list renders through the stage path");
}
tests/facing.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN144 P5 — facings, and the two models the PROJECTION picks between.
//
// The claim under test is that an author writes ONE call — "this thing points
// that way" — and the stage decides whether that is a rotation or a sequence
// plus a mirror. Top-down rotates one sprite continuously and must never cost
// an atlas cell for doing it; side-on cannot rotate a standing sprite into
// another facing, so it picks from an (action, facing) table and mirrors at
// most, and a facing change mid-walk must not snap the legs back to frame 0.
use stage::*;
EPS = 0.000001;
fn near(a: float, b: float) -> boolean { a - b < EPS and b - a < EPS }
fn nears(a: single, b: float) -> boolean {
d = (a as float) - b;
d < EPS and -d < EPS
}
// A mob: 10 wide, 20 tall, origin at the FEET (bottom-centre), stood at (100,200).
fn mob(st: stage::Stage, key: text) -> integer {
st.node_add(-1, stage::Place { pl_w: 10.0, pl_h: 20.0, pl_ox: 5.0, pl_oy: 20.0,
pl_x: 100.0, pl_y: 200.0, pl_key: key })
}
// ── the quantiser ────────────────────────────────────────────────────────────
//
// Screen space, y DOWN: angle 0 is +x (east), +pi/2 is +y (south). Hand-computed
// at every boundary, and the edges are HALF-OPEN like A4's picking — 45 degrees
// belongs to south, not to east, so two facings never both claim one angle.
fn test_the_compass_is_half_open_at_its_boundaries() {
cases = [0.0, 44.0, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0, 359.0,
-45.0, -46.0, -90.0];
want = ["e", "e", "s", "s", "w", "w", "n", "n", "e", "e",
"e", "n", "n"];
for i in 0..len(cases) {
d = cases[i] ?? 0.0;
got = stage::facing_name(d * PI / 180.0);
assert(got == (want[i] ?? ""), "{d} degrees: got '{got}', want '{want[i] ?? ""}'");
}
}
// ── TOP-DOWN: continuous rotation, and no atlas cost for it ──────────────────
// ⚠ 15-DEGREE STEPS COST NO ATLAS ENTRIES. Pre-rotated frames are exactly what
// this model avoids, so 24 facings must be ONE cell turned 24 ways — not 24
// cells. A sprite authored in a locked orientation (crawler's "front = up") is
// what makes that legal.
fn test_fifteen_degree_steps_cost_no_atlas_entries() {
st = stage::stage_new();
st.set_projection(stage::TOP_DOWN);
q = st.add_named_sequence("orc", 3, 1, 0.0, stage::LOOP);
ns: vector<integer> = [];
for i in 0..24 {
n = st.node_add(-1, stage::Place { pl_w: 1.0, pl_h: 1.0, pl_seq: q });
st.face(n, (i as float) * 15.0 * PI / 180.0);
ns += [n];
}
st.compose();
assert(st.sequence_count() == 1, "24 facings, one sequence — got {st.sequence_count()}");
f = st.pack_instances();
// Every instance names the SAME cell and the same atlas...
for i in 0..24 {
base = i * stage::INSTANCE_STRIDE;
assert(nears(f[base + stage::OFF_CELL] ?? 0.0f, 3.0),
"facing {i}: cell moved to {f[base + stage::OFF_CELL] ?? 0.0f}");
}
// ...and the affines are 24 DISTINCT rotations. Hand-computed at 15 degrees:
// cos = 0.9659258, sin = 0.2588190.
assert(nears(f[stage::INSTANCE_STRIDE + stage::OFF_AFFINE] ?? 0.0f, 0.9659258),
"a at 15 degrees");
assert(nears(f[stage::INSTANCE_STRIDE + stage::OFF_AFFINE + 1] ?? 0.0f, 0.2588190),
"b at 15 degrees");
seen = 0;
for i in 0..24 {
ai = f[i * stage::INSTANCE_STRIDE + stage::OFF_AFFINE] ?? 0.0f;
bi = f[i * stage::INSTANCE_STRIDE + stage::OFF_AFFINE + 1] ?? 0.0f;
dup = false;
for j in 0..i {
aj = f[j * stage::INSTANCE_STRIDE + stage::OFF_AFFINE] ?? 0.0f;
bj = f[j * stage::INSTANCE_STRIDE + stage::OFF_AFFINE + 1] ?? 0.0f;
if nears(ai, aj as float) and nears(bi, bj as float) { dup = true; }
}
if !dup { seen = seen + 1; }
}
assert(seen == 24, "24 facings must give 24 distinct affines — got {seen}");
}
// Top-down keeps the angle exactly: it is a rotation, not a bucket.
fn test_top_down_rotation_is_continuous() {
st = stage::stage_new();
st.set_projection(stage::TOP_DOWN);
n = mob(st, "orc");
st.face(n, 0.3);
st.compose();
assert(near(st.facing_of(n), 0.3), "the facing is kept exactly");
assert(near(st.rotation_of(n), 0.3), "and top-down turns the sprite by it");
st.face(n, 0.31);
st.compose();
assert(near(st.rotation_of(n), 0.31), "a hundredth of a radian still moves it");
}
// ── SIDE-ON: an (action, facing) table, mirrored at most ─────────────────────
// ⚠ A FACING CHANGE MID-WALK KEEPS THE PHASE. Switching sequences must carry the
// elapsed over, or a mob turning a corner snaps its legs back to frame 0 and
// visibly stutters at exactly the moment the player is watching it.
fn test_turning_mid_walk_keeps_the_frame_phase() {
st = stage::stage_new();
st.set_projection(stage::SIDE_ON);
_ = st.add_named_sequence("orc_walk_e", 100, 6, 10.0, stage::LOOP);
_ = st.add_named_sequence("orc_walk_n", 200, 6, 10.0, stage::LOOP);
n = mob(st, "orc");
st.set_action(n, "walk");
st.face(n, 0.0); // east
st.compose();
st.advance(250000); // 0.25 s at 10 fps -> offset 2
assert(st.frame_of(n) == 102, "walking east, on the third cell");
st.face(n, -PI / 2.0); // turn north, mid-stride
assert(st.frame_of(n) == 202,
"the phase carries over — got {st.frame_of(n)}, want 202 (not 200)");
st.advance(100000);
assert(st.frame_of(n) == 203, "and it keeps walking from there");
}
// ⚠ WEST IS EAST MIRRORED, NOT EAST ROTATED. A standing sprite turned by pi
// lies on its head; the only legal transform between the two sides is a flip.
// Hand-computed for w=10 h=20 ox=5 oy=20 at (100,200): east packs a=+10, tx=95;
// west packs a=-10, tx=105 — and BOTH span x 95..105 with the origin at
// (100,200). The footprint does not move, exactly as P3's sway does not.
fn test_west_is_east_mirrored_and_the_footprint_does_not_move() {
st = stage::stage_new();
st.set_projection(stage::SIDE_ON);
_ = st.add_named_sequence("orc_walk_e", 100, 6, 10.0, stage::LOOP);
n = mob(st, "orc");
st.set_action(n, "walk");
st.face(n, 0.0); // east
st.compose();
be = st.bounds_of(n);
oe = st.world_origin(n);
fe = st.pack_instances();
assert(nears(fe[stage::OFF_AFFINE] ?? 0.0f, 10.0), "east: a = +w");
assert(!st.mirrored(n), "east is not mirrored");
st.face(n, PI); // west
st.compose();
bw = st.bounds_of(n);
ow = st.world_origin(n);
fw = st.pack_instances();
assert(st.mirrored(n), "west mirrors");
assert(near(st.rotation_of(n), 0.0),
"and it does NOT rotate — a standing sprite stays upright");
assert(nears(fw[stage::OFF_AFFINE] ?? 0.0f, -10.0), "west: a = -w, the flip");
assert(nears(fw[stage::OFF_AFFINE + 3] ?? 0.0f, 20.0),
"while d is untouched — flipped side to side, never top to bottom");
assert(st.frame_of(n) == 100, "west reuses the EAST cells, mirrored");
assert(near(bw.b_x0, be.b_x0) and near(bw.b_x1, be.b_x1),
"the footprint does not move: {bw.b_x0}..{bw.b_x1} vs {be.b_x0}..{be.b_x1}");
assert(near(bw.b_y0, be.b_y0) and near(bw.b_y1, be.b_y1), "nor vertically");
assert(near(ow.p_x, oe.p_x) and near(ow.p_y, oe.p_y),
"and the origin is still where the mob stands");
}
// North and south have no mirror partner — a front view flipped is still a front
// view — so they fall back rather than borrowing the other one.
fn test_north_and_south_do_not_mirror() {
st = stage::stage_new();
st.set_projection(stage::SIDE_ON);
_ = st.add_named_sequence("orc_walk", 50, 4, 10.0, stage::LOOP);
_ = st.add_named_sequence("orc_walk_e", 100, 4, 10.0, stage::LOOP);
n = mob(st, "orc");
st.set_action(n, "walk");
st.face(n, -PI / 2.0); // north
assert(st.frame_of(n) == 50, "north falls back to the actionful sequence");
assert(!st.mirrored(n), "and does not borrow east");
}
// ── the projection picks, not the author ─────────────────────────────────────
// ⚠ THE SAME CALL, TWO MODELS. This is the phase's whole claim: `face` means
// "point that way", and what that costs is the projection's decision.
fn test_one_call_two_models() {
top = stage::stage_new();
top.set_projection(stage::TOP_DOWN);
_ = top.add_named_sequence("orc_walk_e", 100, 4, 10.0, stage::LOOP);
tn = mob(top, "orc");
top.set_action(tn, "walk");
top.face(tn, PI);
top.compose();
side = stage::stage_new();
side.set_projection(stage::SIDE_ON);
_ = side.add_named_sequence("orc_walk_e", 100, 4, 10.0, stage::LOOP);
sn = mob(side, "orc");
side.set_action(sn, "walk");
side.face(sn, PI);
side.compose();
assert(near(top.rotation_of(tn), PI), "top-down turns the sprite");
assert(!top.mirrored(tn), "and never mirrors it");
assert(near(side.rotation_of(sn), 0.0), "side-on leaves it upright");
assert(side.mirrored(sn), "and flips it instead");
}
// ── by-name resolution: fall back, never fail ────────────────────────────────
// Most specific wins, and each rung is reached only when the one above is absent.
fn test_resolution_prefers_the_most_specific_name() {
st = stage::stage_new();
st.set_projection(stage::SIDE_ON);
n = mob(st, "orc");
st.set_action(n, "walk");
_ = st.add_named_sequence("orc", 10, 1, 0.0, stage::LOOP);
st.face(n, -PI / 2.0); // north
assert(st.frame_of(n) == 10, "only the bare key exists, so it is used");
_ = st.add_named_sequence("orc_walk", 20, 1, 0.0, stage::LOOP);
st.face(n, -PI / 2.0);
assert(st.frame_of(n) == 20, "the action variant outranks the bare key");
_ = st.add_named_sequence("orc_walk_n", 30, 1, 0.0, stage::LOOP);
st.face(n, -PI / 2.0);
assert(st.frame_of(n) == 30, "and the facing variant outranks that");
}
// ⚠ A MISS FALLS BACK, IT DOES NOT FAIL. A missing sprite must leave a mob
// drawn with what it had rather than blanking it or stopping the frame — the
// same rule crawler's `<key>.png` loader keeps.
fn test_an_unknown_key_falls_back_rather_than_failing() {
st = stage::stage_new();
st.set_projection(stage::SIDE_ON);
_ = st.add_named_sequence("orc_walk_e", 100, 4, 10.0, stage::LOOP);
n = mob(st, "orc");
st.set_action(n, "walk");
st.face(n, 0.0);
assert(st.frame_of(n) == 100, "resolved east");
st.set_action(n, "cast_a_spell_nobody_drew");
assert(st.frame_of(n) == 100, "an unknown action keeps what it had");
st.compose();
assert(len(st.pack_instances()) == stage::INSTANCE_STRIDE, "and still draws");
}
// ── the degenerate case ──────────────────────────────────────────────────────
// A flat 2-D game never calls any of this, and nothing above may reach it.
fn test_a_flat_scene_is_untouched() {
st = stage::stage_new();
n = st.node_add(-1, stage::Place { pl_w: 10.0, pl_h: 20.0, pl_x: 3.0, pl_y: 4.0 });
st.compose();
before = st.pack_instances();
assert(st.projection() == stage::TOP_DOWN, "top-down is the default");
assert(!st.mirrored(n), "nothing is mirrored");
assert(near(st.facing_of(n), 0.0), "and nothing is facing anywhere");
after = st.pack_instances();
assert(len(before) == len(after), "reading it changed nothing");
for i in 0..len(before) {
assert((before[i] ?? 0.0f) == (after[i] ?? 0.0f), "float {i} moved");
}
}
// ⚠ A NAME BELONGS TO ITS OWN SEQUENCE. This was a real defect: the name lived
// in a vector beside `st_seqs` that only the naming registrar appended to, so
// mixing `add_sequence` with `add_named_sequence` shifted every name onto
// somebody else's cells — silently, because both lists stayed individually
// well-formed. "orc" resolved to cell 900.
fn test_mixing_registrars_keeps_names_on_their_own_sequence() {
st = stage::stage_new();
st.set_projection(stage::SIDE_ON);
_ = st.add_sequence(900, 1, 0.0, stage::LOOP); // unnamed, index 0
_ = st.add_named_sequence("orc", 42, 1, 0.0, stage::LOOP); // named, index 1
n = mob(st, "orc");
st.face(n, 0.0);
assert(st.frame_of(n) == 42, "'orc' must name cell 42 — got {st.frame_of(n)}");
assert(st.sequence_count() == 2, "both registrars add exactly one sequence");
}
// Changing the projection changes what the NEXT `face` means — the model is the
// stage's, so a mob that keeps facing each frame lands in the new one by itself.
// (The projection belongs at setup; this is what happens if it moves anyway.)
fn test_the_projection_decides_the_next_face() {
st = stage::stage_new();
_ = st.add_named_sequence("orc_walk_e", 100, 4, 10.0, stage::LOOP);
n = mob(st, "orc");
st.set_action(n, "walk");
st.face(n, PI); // top-down by default: turns
assert(near(st.rotation_of(n), PI), "top-down turned it");
assert(!st.mirrored(n), "and did not flip it");
st.set_projection(stage::SIDE_ON);
st.face(n, PI); // the same call, now the other model
assert(st.mirrored(n), "side-on flips it instead");
assert(st.frame_of(n) == 100, "onto the east cells, mirrored");
}
tests/light.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN144 L1 — light sampled per sprite, applied as tint.
//
// The whole feature for a flat-lit 2-D game, and deliberately first: it rides
// A5's tint attribute, so it costs no pass, needs no framebuffer, and cannot
// disturb draw order. L2 is only worth its pass when light must fall ACROSS the
// scene rather than across the sprites.
use stage::*;
use graphics::*;
EPS = 0.000001;
fn near(a: float, b: float) -> boolean { a - b < EPS and b - a < EPS }
fn count_of(cv: const graphics::Canvas, want: integer) -> integer {
n = 0;
for v in cv.data { if v == want { n = n + 1; } }
n
}
// A mob standing at (x, y): 10 wide, 20 tall, origin at its FEET.
fn mob_at(st: stage::Stage, x: float, y: float) -> integer {
st.node_add(-1, stage::Place { pl_w: 10.0, pl_h: 20.0, pl_ox: 5.0, pl_oy: 20.0,
pl_x: x, pl_y: y, pl_colour: 0xc03030 })
}
// ── the degenerate case first ────────────────────────────────────────────────
// ⚠ AN UNLIT SCENE IS UNTOUCHED. Ambient is 1.0 and there are no lights, so a
// game that never asks for light cannot be dimmed by this feature existing.
fn test_no_lights_is_the_material_unchanged() {
st = stage::stage_new();
n = mob_at(st, 100.0, 200.0);
st.compose();
assert(near(st.ambient(), 1.0), "ambient starts fully lit");
assert(st.light_count() == 0, "and there are no lights");
assert(st.lit_colour(n) == 0xc03030, "so the colour is the material, exactly");
}
// Dimming with no lights at all is the other degenerate case, and it is how a
// night scene starts before a torch is put in it.
fn test_ambient_alone_dims_everything_evenly() {
st = stage::stage_new();
a = mob_at(st, 0.0, 0.0);
b = mob_at(st, 900.0, 900.0);
st.set_ambient(0.5);
st.compose();
// 0xC03030 = (192,48,48); halved and rounded = (96,24,24).
assert(st.lit_colour(a) == 0x601818, "half light, hand-computed");
assert(st.lit_colour(b) == 0x601818, "and distance means nothing without a light");
}
// ── falloff, every value worked out by hand ──────────────────────────────────
// ⚠ THE FALLOFF IS LINEAR IN THE RADIUS — `1 - d/r`, clamped. Chosen because
// this phase's gate is hand-computed values, and a curve nobody can hand-compute
// is a gate nobody maintains. L2 owns the question of a prettier curve, and its
// own gate says so: expectations GENERATED from the constants the shader uses.
fn test_the_falloff_is_hand_computed_at_every_distance() {
st = stage::stage_new();
st.set_ambient(0.0);
_ = st.add_light(100.0, 200.0, 100.0, 0xffffff, 1.0);
on = mob_at(st, 100.0, 200.0); // d = 0
half = mob_at(st, 150.0, 200.0); // d = 50
edge = mob_at(st, 200.0, 200.0); // d = 100, exactly the radius
past = mob_at(st, 250.0, 200.0); // d = 150
st.compose();
assert(st.lit_colour(on) == 0xc03030, "standing on the light: the full material");
assert(st.lit_colour(half) == 0x601818, "half way out: exactly half");
assert(st.lit_colour(edge) == 0x000000, "at the radius the light has ended");
assert(st.lit_colour(past) == 0x000000, "and beyond it stays ended, never negative");
}
// Equal distance, equal light — in any direction. A falloff that used only x,
// or the bounding box, passes the horizontal cell and fails this one.
fn test_two_sprites_at_equal_distance_take_equal_tint() {
st = stage::stage_new();
st.set_ambient(0.0);
_ = st.add_light(100.0, 200.0, 100.0, 0xffffff, 1.0);
east = mob_at(st, 150.0, 200.0);
west = mob_at(st, 50.0, 200.0);
south = mob_at(st, 100.0, 250.0);
north = mob_at(st, 100.0, 150.0);
st.compose();
assert(st.lit_colour(east) == 0x601818, "east");
assert(st.lit_colour(west) == st.lit_colour(east), "west matches");
assert(st.lit_colour(south) == st.lit_colour(east), "south matches");
assert(st.lit_colour(north) == st.lit_colour(east), "north matches");
}
// ── light COMPOSES with the material, it does not replace it ─────────────────
// ⚠ A RED SPRITE UNDER A BLUE LIGHT GOES DARK, NOT BLUE. Light multiplies the
// material — overwriting it would throw away A5's tint, which is the attribute
// this rides. Hand-computed: (192,48,48) x (0,0,1) = (0,0,48).
fn test_light_composes_with_the_material_tint() {
st = stage::stage_new();
st.set_ambient(0.0);
_ = st.add_light(100.0, 200.0, 100.0, 0x0000ff, 1.0);
n = mob_at(st, 100.0, 200.0);
st.compose();
assert(st.lit_colour(n) == 0x000030,
"red under blue is nearly black — got {st.lit_colour(n)}");
}
// ⚠ TWO LIGHTS CANNOT OVERFLOW PAST THE MATERIAL. Unclamped, 2.0 x 192 wraps
// and a doubly-lit sprite comes out DARK — the classic brightness bug, and the
// one everyone finds by standing between two torches.
fn test_overlapping_lights_clamp_instead_of_wrapping() {
st = stage::stage_new();
st.set_ambient(0.0);
_ = st.add_light(100.0, 200.0, 100.0, 0xffffff, 1.0);
_ = st.add_light(100.0, 200.0, 100.0, 0xffffff, 1.0);
n = mob_at(st, 100.0, 200.0);
st.compose();
assert(st.lit_colour(n) == 0xc03030,
"two full lights is the material, not more and not wrapped");
}
// ── sampled at the ORIGIN — the footprint, not the artwork ───────────────────
// ⚠ THE SAMPLE POINT IS THE SPRITE'S ORIGIN, the same point P1 sorts on. Two
// mobs standing on one tile must take the same light however tall their artwork
// is; sample the corner instead and a tall sprite is lit as though it stood
// further away, which is the 2.5-D wrong picture in another form.
fn test_light_is_sampled_at_the_footprint_not_the_artwork() {
st = stage::stage_new();
st.set_ambient(0.0);
_ = st.add_light(100.0, 200.0, 100.0, 0xffffff, 1.0);
short = st.node_add(-1, stage::Place { pl_w: 10.0, pl_h: 20.0, pl_ox: 5.0,
pl_oy: 20.0, pl_x: 150.0, pl_y: 200.0,
pl_colour: 0xc03030 });
tall = st.node_add(-1, stage::Place { pl_w: 10.0, pl_h: 60.0, pl_ox: 5.0,
pl_oy: 60.0, pl_x: 150.0, pl_y: 200.0,
pl_colour: 0xc03030 });
st.compose();
o1 = st.world_origin(short);
o2 = st.world_origin(tall);
assert(near(o1.p_x, o2.p_x) and near(o1.p_y, o2.p_y),
"both stand on the same spot, whatever their height");
assert(st.lit_colour(short) == 0x601818, "the short one, hand-computed at d=50");
assert(st.lit_colour(tall) == st.lit_colour(short),
"and the tall one takes exactly the same light");
}
// ── it rides A5's attribute: no pass, no reorder ─────────────────────────────
// ⚠ LIGHT MUST NOT DISTURB THE DRAW. It is a colour, not a pass — so the batch
// count, the draw order and the geometry are all unchanged by lighting a scene.
fn test_light_changes_no_batch_and_no_order() {
st = stage::stage_new();
_ = mob_at(st, 100.0, 200.0);
_ = mob_at(st, 140.0, 200.0);
_ = mob_at(st, 180.0, 200.0);
st.compose();
runs_before = len(st.batches());
order_before = "";
for i in st.order() { order_before += "{i},"; }
st.set_ambient(0.2);
_ = st.add_light(100.0, 200.0, 90.0, 0xffe0a0, 1.0);
st.compose();
order_after = "";
for i in st.order() { order_after += "{i},"; }
assert(len(st.batches()) == runs_before,
"lighting adds no run — {len(st.batches())} vs {runs_before}");
assert(order_after == order_before, "and reorders nothing");
}
// ⚠ BUT IT DOES DIRTY THE BUFFER, unlike the camera. A light changes what is
// IN the instance data, so honesty here matters: a moving torch re-packs every
// frame, and that is exactly the cost L2's pass exists to take over.
fn test_a_light_dirties_the_buffer_where_a_camera_does_not() {
st = stage::stage_new();
_ = mob_at(st, 100.0, 200.0);
st.compose();
_ = st.sync_instances();
st.set_camera(50.0, 0.0);
assert(!st.sync_instances(), "a camera move re-packs nothing");
l = st.add_light(100.0, 200.0, 100.0, 0xffffff, 1.0);
assert(st.sync_instances(), "adding a light does re-pack");
st.move_light(l, 120.0, 200.0);
assert(st.sync_instances(), "and so does moving one");
st.set_ambient(0.5);
assert(st.sync_instances(), "and changing the ambient");
}
// ── one home: both render paths agree ────────────────────────────────────────
// ⚠ THE LIT COLOUR IS DERIVED ONCE and both paths read it. P6 shipped a defect
// that was exactly this shape the other way round — a fact re-derived per path
// and validated only where the derivations happened to coincide.
fn test_both_render_paths_agree_about_the_light() {
st = stage::stage_new();
st.set_ambient(0.0);
// ⚠ THE LIGHT IS PLACED SO THE LIT COLOUR DIFFERS FROM THE MATERIAL. An
// earlier version stood the sprite ON the light, where the two are equal — so
// a render path that ignored lighting entirely still matched, and the test
// proved nothing. The control is what exposed that; the assertion below now
// makes it impossible to drift back.
_ = st.add_light(52.0, 3.0, 100.0, 0xffffff, 1.0); // d = 50, reach 0.5
n = st.node_add(-1, stage::Place { pl_x: 2.0, pl_y: 3.0, pl_w: 5.0, pl_h: 4.0,
pl_colour: 0xc03030 });
st.compose();
want = st.lit_colour(n);
assert(want == 0x601818, "half-lit, hand-computed — got {want}");
assert(want != 0xc03030, "and it is NOT the raw material, or this proves nothing");
cv = graphics::canvas(16, 16, 0xff000000);
st.render_stage(cv);
assert(count_of(cv, 0xff000000 | want) == 20,
"the software path paints the LIT colour over its 5x4");
f = st.pack_instances();
// The packed rgba is premultiplied and alpha is 1, so it is the lit colour
// divided by 255 — the same number the software path used.
r = ((f[stage::OFF_RGBA] ?? 0.0f) as float) * 255.0 + 0.5;
assert((r as integer) == ((want >> 16) & 255),
"and the instance buffer carries the same red: {r as integer}");
}
// ⚠ A LIGHT OUT OF RANGE CONTRIBUTES NOTHING — it must not SUBTRACT. Unclamped,
// `1 - d/r` goes negative past the radius, and a distant lamp then darkens what
// a nearby one lit: the sprite at 250 is half-lit by B and 250 units outside A,
// where the raw falloff is -4. With one light the final clamp hides it, so this
// is the cell that catches it — and the control that removed the clamp passed
// every other test in this file.
fn test_a_light_out_of_range_does_not_subtract() {
st = stage::stage_new();
st.set_ambient(0.0);
_ = st.add_light(0.0, 200.0, 50.0, 0xffffff, 1.0); // far away, tiny radius
_ = st.add_light(300.0, 200.0, 100.0, 0xffffff, 1.0); // the one that lights it
n = mob_at(st, 250.0, 200.0); // d = 250 from A, 50 from B
st.compose();
assert(st.lit_colour(n) == 0x601818,
"lit by B alone, undimmed by A — got {st.lit_colour(n)}");
}
// ⚠ THE REACH ITSELF IS BOUNDED, and this is where that is gated — `lit_colour`
// skips a zero contribution as work avoided, so a negative reach would never
// show through it. The invariant is `light_reach`'s, so it is tested on
// `light_reach`: past the radius it answers exactly 0, never a negative that
// would let a distant lamp DARKEN what a near one lit.
fn test_the_reach_is_bounded_at_both_ends() {
l = stage::StageLight { lt_x: 0.0, lt_y: 0.0, lt_r: 100.0,
lt_colour: 0xffffff, lt_power: 1.0 };
assert(near(stage::light_reach(l, 0.0, 0.0), 1.0), "at the centre, full");
assert(near(stage::light_reach(l, 50.0, 0.0), 0.5), "half way, half");
assert(near(stage::light_reach(l, 100.0, 0.0), 0.0), "at the radius, nothing");
assert(near(stage::light_reach(l, 500.0, 0.0), 0.0),
"and far beyond it is still nothing, NOT -4: got {stage::light_reach(l, 500.0, 0.0)}");
z = stage::StageLight { lt_x: 0.0, lt_y: 0.0, lt_r: 0.0,
lt_colour: 0xffffff, lt_power: 1.0 };
assert(near(stage::light_reach(z, 0.0, 0.0), 0.0),
"a radius of zero reaches nothing, and does not divide by it");
}
tests/lightmap.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN144 L2 — the light-map composite pass.
//
// What L1 cannot do: light that falls ACROSS the scene rather than across the
// sprites. The world draws, one multiply composite lights every pixel of it
// including the gaps between sprites, and the HUD draws after, unlit.
//
// ⚠ THE EXPECTATIONS ARE GENERATED, NOT TYPED. A curve hand-computed per pixel
// is not a gate anyone maintains, so these read the falloff from the SAME symbol
// the renderer and the shader use. What is hand-computed is the floor, the
// ceiling and the shape — monotonic, bounded, and never under its floor.
use stage::*;
use graphics::*;
EPS = 0.000001;
fn near(a: float, b: float) -> boolean { a - b < EPS and b - a < EPS }
fn red_of(cv: const graphics::Canvas, x: integer, y: integer) -> integer {
(cv.get_pixel(x, y) >> 16) & 255
}
fn differing(a: const graphics::Canvas, b: const graphics::Canvas) -> integer {
n = 0;
for i in 0..len(a.data) { if (a.data[i] ?? 0) != (b.data[i] ?? 0) { n = n + 1; } }
n
}
// A white slab covering the whole canvas, so a pixel's red channel reads the
// light level directly.
fn slab(st: stage::Stage, layer: integer) -> integer {
st.node_add(-1, stage::Place { pl_w: 200.0, pl_h: 40.0, pl_colour: 0xffffff,
pl_layer: layer })
}
// ── the degenerate case ──────────────────────────────────────────────────────
// ⚠ THE MAP IS OFF UNTIL ASKED. With it off the scene is exactly what L1 drew,
// byte for byte — so a game that wants light across its SPRITES keeps it.
fn test_the_map_is_off_by_default_and_l1_is_untouched() {
a = stage::stage_new();
a.set_ambient(0.5);
_ = a.add_light(50.0, 20.0, 100.0, 0xffffff, 1.0);
_ = slab(a, 0);
a.compose();
assert(!a.light_map(), "off by default");
ca = graphics::canvas(200, 40, 0xff000000);
a.render_view(0, ca);
b = stage::stage_new();
b.set_ambient(0.5);
_ = b.add_light(50.0, 20.0, 100.0, 0xffffff, 1.0);
_ = slab(b, 0);
b.compose();
cb = graphics::canvas(200, 40, 0xff000000);
b.render_view(0, cb);
assert(differing(ca, cb) == 0, "two unlit-map stages agree");
// And turning it on must actually CHANGE something, or this proves nothing.
b.set_light_map(true);
cc = graphics::canvas(200, 40, 0xff000000);
b.render_view(0, cc);
assert(differing(ca, cc) > 0, "the map is not a no-op");
}
// ── the point of the phase: light falls ACROSS a sprite ──────────────────────
// ⚠ THIS IS WHAT L1 CANNOT DO. One sprite, one light near its left end: the
// near end is brighter than the far end. Per-sprite lighting gives the whole
// slab ONE value, so it fails this by construction.
fn test_light_falls_across_a_single_sprite() {
st = stage::stage_new();
st.set_ambient(0.0);
st.set_light_map(true);
_ = st.add_light(0.0, 20.0, 200.0, 0xffffff, 1.0);
_ = slab(st, 0);
st.compose();
cv = graphics::canvas(200, 40, 0xff000000);
st.render_view(0, cv);
near_end = red_of(cv, 0, 20);
mid = red_of(cv, 100, 20);
far_end = red_of(cv, 199, 20);
assert(near_end > mid and mid > far_end,
"one sprite, a gradient across it: {near_end} > {mid} > {far_end}");
assert(near_end == 255, "at the light itself, full");
}
// ⚠ AND IT LIGHTS THE GAPS. A fullscreen composite reaches the background
// between sprites, which is the reason it earns a pass at all — a per-sprite
// tint cannot touch a pixel no sprite covers.
fn test_the_composite_lights_the_background_too() {
st = stage::stage_new();
st.set_ambient(0.0);
st.set_light_map(true);
_ = st.add_light(10.0, 20.0, 200.0, 0xffffff, 1.0);
// A small sprite, so most of the canvas is bare background.
_ = st.node_add(-1, stage::Place { pl_w: 4.0, pl_h: 4.0, pl_colour: 0xffffff,
pl_x: 150.0, pl_y: 30.0 });
st.compose();
cv = graphics::canvas(200, 40, 0xff404040);
st.render_view(0, cv);
assert(red_of(cv, 12, 20) > red_of(cv, 190, 20),
"bare background is lit, and falls off with distance");
}
// ── the invariant: monotonic, and never under its floor ──────────────────────
// ⚠ THE GATE THE PLAN ASKS FOR, and it is a PROPERTY rather than a table:
// along a ray from the light the level never rises, and never drops below the
// floor the ambient sets. Hand-computing a curve per pixel is not a gate
// anyone maintains; this is.
fn test_the_falloff_along_a_ray_is_monotonic_and_floored() {
st = stage::stage_new();
floor = 0.25;
st.set_ambient(floor);
st.set_light_map(true);
_ = st.add_light(0.0, 0.0, 100.0, 0xffffff, 1.0);
prev = 2.0;
for i in 0..40 {
d = (i as float) * 5.0; // out to 195, well past the radius
lv = st.light_level_at(d, 0.0);
assert(lv <= prev + EPS, "step {i} at d={d} rose: {lv} after {prev}");
assert(lv >= floor - EPS, "step {i} at d={d} went under the floor: {lv}");
assert(lv <= 1.0 + EPS, "step {i} at d={d} went over the ceiling: {lv}");
prev = lv;
}
assert(near(st.light_level_at(500.0, 0.0), floor),
"far away it IS the floor, exactly");
assert(near(st.light_level_at(0.0, 0.0), 1.0),
"and at the light it is the ceiling");
}
// ⚠ GENERATED FROM THE SAME SYMBOL THE RENDERER USES. Not a second table of
// numbers to keep in step — if the curve is retuned, this test retunes with it,
// which is the whole point of the technique.
fn test_the_level_agrees_with_the_shared_reach_function() {
st = stage::stage_new();
st.set_ambient(0.1);
st.set_light_map(true);
l = stage::StageLight { lt_x: 30.0, lt_y: 40.0, lt_r: 120.0,
lt_colour: 0xffffff, lt_power: 1.0 };
_ = st.add_light(l.lt_x, l.lt_y, l.lt_r, l.lt_colour, l.lt_power);
for i in 0..12 {
px = (i as float) * 13.0;
want = 0.1 + stage::light_reach(l, px, 40.0);
if want > 1.0 { want = 1.0; }
got = st.light_level_at(px, 40.0);
assert(near(got, want), "x={px}: composite {got} vs reach {want}");
}
}
// ── the HUD draws after, unlit ───────────────────────────────────────────────
// ⚠ THE COMMENT BECOMES A TEST. "The HUD draws after, unlit" is only true if
// its pixels are BIT-IDENTICAL with the light on and off — anything less and a
// dark room quietly dims the health bar.
fn test_the_hud_is_bit_identical_with_the_light_on_and_off() {
lit = stage::stage_new();
lit.set_hud_layer(10);
lit.set_light_map(true);
lit.set_ambient(0.0);
_ = lit.add_light(20.0, 20.0, 60.0, 0xffffff, 1.0);
_ = slab(lit, 0); // world
_ = lit.node_add(-1, stage::Place { pl_w: 20.0, pl_h: 10.0, pl_x: 100.0,
pl_y: 5.0, pl_colour: 0x40e080,
pl_layer: 10 }); // HUD
lit.compose();
ca = graphics::canvas(200, 40, 0xff000000);
lit.render_view(0, ca);
dark = stage::stage_new();
dark.set_hud_layer(10);
dark.set_light_map(true);
dark.set_ambient(0.0); // NO light at all
_ = slab(dark, 0);
_ = dark.node_add(-1, stage::Place { pl_w: 20.0, pl_h: 10.0, pl_x: 100.0,
pl_y: 5.0, pl_colour: 0x40e080,
pl_layer: 10 });
dark.compose();
cb = graphics::canvas(200, 40, 0xff000000);
dark.render_view(0, cb);
// The world differs — one is lit, one is not.
assert(ca.get_pixel(20, 30) != cb.get_pixel(20, 30), "the world DID change");
// The HUD does not, anywhere in its rect.
moved = 0;
for y in 5..15 {
for x in 100..120 {
if ca.get_pixel(x, y) != cb.get_pixel(x, y) { moved = moved + 1; }
}
}
assert(moved == 0, "{moved} HUD pixels moved when the light changed");
assert(ca.get_pixel(105, 8) == 0xff40e080, "and the HUD is its own colour, undimmed");
}
// ── the two models do not stack ──────────────────────────────────────────────
// ⚠ ONE LIGHTING MODEL AT A TIME. With the map on, a sprite is packed with its
// RAW material and the map does the lighting — otherwise every sprite would be
// lit twice and a torch-lit mob would come out darker than the floor it stands on.
fn test_the_map_replaces_per_sprite_lighting() {
st = stage::stage_new();
st.set_ambient(0.5);
_ = st.add_light(0.0, 0.0, 100.0, 0xffffff, 1.0);
n = st.node_add(-1, stage::Place { pl_w: 8.0, pl_h: 8.0, pl_colour: 0xc03030,
pl_x: 60.0, pl_y: 0.0 });
st.compose();
assert(st.lit_colour(n) != 0xc03030, "with the map OFF, L1 tints the sprite");
st.set_light_map(true);
assert(st.lit_colour(n) == 0xc03030,
"with the map ON the sprite is raw material — got {st.lit_colour(n)}");
}
// ── order independence ──────────────────────────────────────────────────────
// A multiply composite after the scene cannot depend on the order the scene was
// drawn in, so it does not fight A3a's *never reorder*.
fn test_the_composite_is_order_independent() {
one = stage::stage_new();
one.set_light_map(true);
one.set_ambient(0.2);
_ = one.add_light(30.0, 20.0, 120.0, 0xffffff, 1.0);
_ = one.node_add(-1, stage::Place { pl_w: 60.0, pl_h: 20.0, pl_x: 0.0,
pl_y: 10.0, pl_colour: 0xffffff, pl_depth: 1.0 });
_ = one.node_add(-1, stage::Place { pl_w: 60.0, pl_h: 20.0, pl_x: 40.0,
pl_y: 10.0, pl_colour: 0x8080ff, pl_depth: 0.0 });
one.compose();
c1 = graphics::canvas(200, 40, 0xff000000);
one.render_view(0, c1);
two = stage::stage_new(); // the same scene, inserted the other way up
two.set_light_map(true);
two.set_ambient(0.2);
_ = two.add_light(30.0, 20.0, 120.0, 0xffffff, 1.0);
_ = two.node_add(-1, stage::Place { pl_w: 60.0, pl_h: 20.0, pl_x: 40.0,
pl_y: 10.0, pl_colour: 0x8080ff, pl_depth: 0.0 });
_ = two.node_add(-1, stage::Place { pl_w: 60.0, pl_h: 20.0, pl_x: 0.0,
pl_y: 10.0, pl_colour: 0xffffff, pl_depth: 1.0 });
two.compose();
c2 = graphics::canvas(200, 40, 0xff000000);
two.render_view(0, c2);
assert(differing(c1, c2) == 0, "{differing(c1, c2)} pixels depended on insertion order");
}
// ── the shader reads the same constant ───────────────────────────────────────
// ⚠ ONE HOME FOR THE CURVE. The composite shader takes the falloff as a
// UNIFORM fed from `LIGHT_FALLOFF` — the same symbol `light_reach` uses — rather
// than a number baked into its text. Baking it would be a second copy, and
// interpolating it is worse than it looks: loft renders `2.0` as `2`, which GLSL
// takes as an int and refuses.
fn test_the_composite_shader_takes_the_falloff_as_a_uniform() {
src = stage::composite_shader_source();
assert(src.contains("uniform float uFalloff"), "the shader declares the uniform");
assert(src.contains("uniform float uFloor"), "and the floor it must never undercut");
// The exponent the curve is raised to must BE the uniform — a number here
// would be a second copy of the curve, free to drift from `light_reach`.
assert(src.contains("uFalloff)"), "the exponent is the uniform, not a literal");
assert(near(stage::LIGHT_FALLOFF, 1.0), "the curve is linear today");
}
tests/nodetween.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN145 C2 — a tween bound to a node property.
//
// THE GATE is a PARALLEL RUN, the same shape `draw_list.loft` uses for A2: the
// tween-driven scene and a hand-moved one both exist, and are compared pixel for
// pixel every frame rather than one being swapped in and eyeballed.
//
// ⚠ The numbers are chosen so the HAND side needs no library call at all: 64 px
// over 800 000 us sampled every 100 000 us is exactly 8 px per frame, and every
// progress `k/8` is exact in binary. A hand path that reached for `lerp` or
// `ease` would be comparing the tween against itself.
use stage::*;
use graphics::*;
const DUR = 800000; // us
const STEP = 100000; // us — eight frames
const FAR = 64.0; // px — so each frame is exactly 8 px
fn canvas_equal(a: const graphics::Canvas, b: const graphics::Canvas) -> boolean {
if a.width != b.width or a.height != b.height { return false; }
for i in 0..len(a.data) {
if a.data[i] != b.data[i] { return false; }
}
true
}
fn differing(a: const graphics::Canvas, b: const graphics::Canvas) -> integer {
n = 0;
for i in 0..len(a.data) { if a.data[i] != b.data[i] { n = n + 1; } }
n
}
fn mover(st: Stage) -> integer {
st.node_add(-1, Place { pl_w: 8.0, pl_h: 8.0, pl_colour: 0xc03030 })
}
fn shot(st: Stage) -> graphics::Canvas {
st.compose();
cv = graphics::canvas(96, 16, 0xff000000);
st.render_stage(cv);
cv
}
// THE GATE. Eight frames of a tweened `x`, byte-identical to eight frames of a
// hand-moved one.
fn test_a_tweened_property_draws_what_a_hand_moved_one_draws() {
auto = stage_new();
a = mover(auto);
auto.tween_prop(a, Prop.X, FAR, DUR);
hand = stage_new();
h = mover(hand);
ink = 0;
for k in 1..=8 {
auto.advance(STEP);
// The hand side: k frames of 8 px, computed here and written straight in.
hand.set_prop(h, Prop.X, (k * 8) as float);
ca = shot(auto);
ch = shot(hand);
assert(canvas_equal(ca, ch),
"frame {k} differs in {differing(ca, ch)} pixels — tween x is {auto.prop_of(a, Prop.X)}, hand is {hand.prop_of(h, Prop.X)}");
for v in ca.data { if v == 0xffc03030 { ink = ink + 1; } }
}
assert(auto.prop_of(a, Prop.X) == FAR, "and it lands exactly on 64");
assert(ink == 8 * 64, "each of the 8 frames drew an 8x8 sprite, got {ink}");
}
// ⚠ THE CONTROL for the gate: the comparison can FAIL. One pixel of offset on
// the hand side and the canvases part — so a green gate above is a statement
// about the values, not about a comparison that never fires.
fn test_the_pixel_comparison_can_fail() {
a_st = stage_new();
a_n = mover(a_st);
a_st.set_prop(a_n, Prop.X, 8.0);
b_st = stage_new();
b_n = mover(b_st);
b_st.set_prop(b_n, Prop.X, 9.0);
ca = shot(a_st);
cb = shot(b_st);
assert(!canvas_equal(ca, cb), "one pixel of offset must show");
assert(differing(ca, cb) == 16, "an 8x8 sprite moved by 1 px differs in 16 pixels, got {differing(ca, cb)}");
}
// ⚠ THE SWITCH, cell by cell. Nine properties, and a write to one must move
// that one and NOTHING else — the copy-paste failure a nine-arm match invites.
fn test_each_property_writes_its_own_field_and_no_other() {
props = [Prop.X, Prop.Y, Prop.Rot, Prop.ScaleX, Prop.ScaleY,
Prop.OriginX, Prop.OriginY, Prop.Alpha, Prop.Depth];
assert(len(props) == 9, "nine properties");
for p in props {
st = stage_new();
n = mover(st);
before: vector<float> = [];
for q in props { before += [st.prop_of(n, q)]; }
st.set_prop(n, p, 0.375); // a value no field holds by default
changed = 0;
for q in props {
now = st.prop_of(n, q);
was = before[q#index] ?? 0.0;
if now != was {
changed = changed + 1;
assert(q == p, "writing one property moved another one");
assert(now == 0.375, "and it landed on the value written");
}
}
assert(changed == 1, "exactly one field moved, {changed} did");
}
}
// ⚠⚠ AT MOST ONE TWEEN PER (NODE, PROPERTY) — a second REPLACES the first, so
// one number never has two writers.
fn test_a_second_tween_replaces_the_first() {
st = stage_new();
n = mover(st);
st.tween_prop(n, Prop.X, 64.0, DUR);
assert(st.tween_count() == 1, "one tween");
st.advance(400000); // half way: 32
assert(st.prop_of(n, Prop.X) == 32.0, "half way is 32, got {st.prop_of(n, Prop.X)}");
st.tween_prop(n, Prop.X, 0.0, 400000); // now go back instead
assert(st.tween_count() == 1, "still ONE tween — the first was replaced, not joined");
st.advance(200000); // half of the new one: 32 → 16
assert(st.prop_of(n, Prop.X) == 16.0, "the new tween starts from 32, got {st.prop_of(n, Prop.X)}");
st.advance(200000);
assert(st.prop_of(n, Prop.X) == 0.0, "and lands on its own target");
// A tween on a DIFFERENT property of the same node is a different writer and
// coexists.
st.tween_prop(n, Prop.Y, 10.0, DUR);
st.tween_prop(n, Prop.X, 5.0, DUR);
assert(st.tween_count() == 2, "x and y are two tweens, got {st.tween_count()}");
}
// ⚠ A FINISHED TWEEN STOPS WRITING, so a later hand-set sticks instead of being
// re-asserted every frame.
fn test_a_finished_tween_releases_the_property() {
st = stage_new();
n = mover(st);
st.tween_prop(n, Prop.X, 64.0, DUR);
st.advance(DUR);
assert(!st.prop_tweening(n, Prop.X), "it has finished");
assert(st.prop_of(n, Prop.X) == 64.0, "on its end value");
st.set_prop(n, Prop.X, 5.0);
st.advance(STEP);
assert(st.prop_of(n, Prop.X) == 5.0,
"the finished tween must not write again — got {st.prop_of(n, Prop.X)}");
}
// `from` is read when the tween STARTS, so one begun mid-flight carries on from
// where the property actually is.
fn test_from_is_read_at_the_moment_it_starts() {
st = stage_new();
n = mover(st);
st.set_prop(n, Prop.X, 20.0);
st.tween_prop(n, Prop.X, 28.0, DUR);
st.advance(400000);
assert(st.prop_of(n, Prop.X) == 24.0, "half of 20→28 is 24, got {st.prop_of(n, Prop.X)}");
}
// Cancelling leaves the property where it had got to.
fn test_cancel_leaves_the_value_where_it_is() {
st = stage_new();
n = mover(st);
st.tween_prop(n, Prop.X, 64.0, DUR);
st.advance(400000);
st.cancel_prop_tween(n, Prop.X);
assert(st.tween_count() == 0, "the tween is gone");
st.advance(DUR);
assert(st.prop_of(n, Prop.X) == 32.0, "and the value stayed at 32");
}
// ⚠ ONE CLOCK. The same `advance` drives an animation sequence and a property
// tween — which is the whole reason the binding lives here and takes MICROSECONDS
// rather than `tween`'s own unit-agnostic count.
fn test_one_advance_drives_both_the_sequence_and_the_tween() {
st = stage_new();
q = st.add_sequence(0, 8, 10.0, LOOP); // 8 frames at 10 fps = 0.8 s
n = st.node_add(-1, Place { pl_w: 8.0, pl_h: 8.0, pl_seq: q, pl_cols: 4 });
st.tween_prop(n, Prop.X, 64.0, DUR); // 0.8 s, the same span
for _ in 0..4 { st.advance(STEP); }
assert(st.frame_of(n) == 4, "four ticks in, the sequence is on frame 4");
assert(st.prop_of(n, Prop.X) == 32.0, "and the tween is half way");
for _ in 0..4 { st.advance(STEP); }
assert(st.frame_of(n) == 0, "a full loop returns to frame 0");
assert(st.prop_of(n, Prop.X) == 64.0, "and the tween has arrived");
}
// A tween on a node index that does not exist is dropped, not stored.
fn test_a_bad_node_index_is_refused() {
st = stage_new();
_ = mover(st);
st.tween_prop(99, Prop.X, 5.0, DUR);
st.tween_prop(-1, Prop.X, 5.0, DUR);
assert(st.tween_count() == 0, "neither was kept");
assert(st.prop_of(99, Prop.X) == 0.0, "and reading one answers the zero");
}
tests/picking.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN144 A4 — picking and input routing, on a hand-computed table.
//
// Two things here are not what a rect test gives you, and both are the point: a
// point over a sprite's HOLE resolves to what is behind it, and a release goes to
// the node that took the PRESS wherever the pointer has drifted to.
use stage::*;
fn solid(x: float, y: float, w: float, h: float) -> stage::Place {
stage::Place { pl_x: x, pl_y: y, pl_w: w, pl_h: h }
}
// The topmost node wins, and "topmost" means LAST — the same order that draws.
fn test_the_last_node_drawn_is_the_one_picked() {
st = stage::stage_new();
under = st.node_add(-1, solid(0.0, 0.0, 20.0, 20.0));
over = st.node_add(-1, solid(10.0, 10.0, 20.0, 20.0));
st.compose();
assert(st.pick(5.0, 5.0) == under, "only the lower node covers (5,5)");
assert(st.pick(25.0, 25.0) == over, "only the upper node covers (25,25)");
assert(st.pick(15.0, 15.0) == over, "where they overlap, the LAST one wins");
assert(st.pick(50.0, 50.0) == -1, "nothing covers (50,50)");
}
// The rect is half-open in the same direction the draw list is: a point exactly on
// the far edge is outside, so abutting nodes never both claim a pixel.
fn test_the_rect_is_half_open() {
st = stage::stage_new();
a = st.node_add(-1, solid(0.0, 0.0, 10.0, 10.0));
b = st.node_add(-1, solid(10.0, 0.0, 10.0, 10.0));
st.compose();
assert(st.pick(9.99, 5.0) == a, "just inside a");
assert(st.pick(10.0, 5.0) == b, "the shared edge belongs to b, not to both");
}
// Under rotation the inverse has to be real. A quarter turn about the origin:
// the node occupies local x in 0..20, y in 0..10, which in world space is
// x in -10..0, y in 0..20. So (-5, 5) is inside and (5, 5) is not.
fn test_picking_under_rotation() {
st = stage::stage_new();
n = st.node_add(-1, stage::Place { pl_w: 20.0, pl_h: 10.0,
pl_rot: 1.5707963267948966 });
st.compose();
assert(st.pick(-5.0, 5.0) == n, "inside the rotated rect");
assert(st.pick(5.0, 5.0) == -1, "outside it — the un-rotated rect would say inside");
l = st.to_local(n, -5.0, 5.0);
assert(l.p_x > 4.9 and l.p_x < 5.1 and l.p_y > 4.9 and l.p_y < 5.1,
"to_local inverts the affine: got ({l.p_x}, {l.p_y}) want (5, 5)");
}
// ⚠ THE ONE A RECT TEST CANNOT DO. A mask with a hole in the middle: a click
// there falls THROUGH to the node behind, because you can see through it.
fn test_a_click_falls_through_a_hole() {
st = stage::stage_new();
behind = st.node_add(-1, solid(0.0, 0.0, 40.0, 40.0));
// 2x2 mask: opaque except the bottom-right quadrant.
holed = st.add_mask(2, 2, [255, 255, 255, 0]);
front = st.node_add(-1, stage::Place { pl_w: 40.0, pl_h: 40.0, pl_mask: holed });
st.compose();
assert(st.pick(5.0, 5.0) == front, "the opaque quadrant belongs to the front node");
assert(st.pick(35.0, 35.0) == behind, "the HOLE lets the click reach what is behind");
assert(st.pick(35.0, 5.0) == front, "top-right is opaque");
}
// A node with no mask is solid — the right default for a panel or a tile.
fn test_no_mask_means_solid() {
st = stage::stage_new();
n = st.node_add(-1, solid(0.0, 0.0, 10.0, 10.0));
st.compose();
assert(st.pick(1.0, 1.0) == n and st.pick(9.0, 9.0) == n, "every point inside hits");
}
// ⚠ CAPTURE. Press on a node, drift off it, release: the release still belongs
// to the node that was pressed. Without this a button fires when you press and
// slide away, and never fires when you press and drift a pixel.
fn test_a_release_belongs_to_the_press() {
st = stage::stage_new();
btn = st.node_add(-1, solid(0.0, 0.0, 10.0, 10.0));
st.compose();
assert(st.press(5.0, 5.0) == btn, "the press lands on the button");
assert(st.capture() == btn, "and is held");
assert(st.release(500.0, 500.0) == btn, "the release belongs to it even far away");
assert(st.capture() == -1, "and the hold is let go");
assert(!st.released_on(btn, 500.0, 500.0), "but it did NOT release ON the button");
assert(st.press(5.0, 5.0) == btn and st.released_on(btn, 6.0, 6.0),
"pressed and released on it — this is the one that fires");
}
// A press that hits nothing captures nothing, and its release says so.
fn test_a_press_on_nothing_captures_nothing() {
st = stage::stage_new();
_ = st.node_add(-1, solid(0.0, 0.0, 10.0, 10.0));
st.compose();
assert(st.press(50.0, 50.0) == -1, "the press misses");
assert(st.capture() == -1, "nothing is held");
assert(st.release(5.0, 5.0) == -1, "and the release reports nothing, not the node under it");
}
tests/stage.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN144 A1 — transform composition, checked against HAND-COMPUTED POINTS.
//
// Not against matrix entries. `mesh3d` has no `mat4_rotate_z`, so a mat4 oracle
// cannot even express a 2-D rotation — but the deeper reason is that comparing
// entries checks a convention while comparing transformed points checks the
// thing a renderer needs. Every expected value below is worked out by hand and
// written as a literal; agreement between two runs of the same code is not a pass.
use stage::*;
EPS = 0.0001;
fn near(a: float, b: float) -> boolean { a - b < EPS and b - a < EPS }
fn check_pt(name: text, got: stage::P2, ex: float, ey: float) {
assert(near(got.p_x, ex) and near(got.p_y, ey),
"{name}: got ({got.p_x}, {got.p_y}) want ({ex}, {ey})");
}
// The ORIGIN is the anchor: local (ox, oy) must land exactly on (x, y), whatever
// the rotation and scale are. This is what lets a sprite stand up from its feet.
fn test_origin_is_the_anchor() {
st = stage::stage_new();
n = st.node_add(-1, stage::Place { pl_x: 100.0, pl_y: 40.0, pl_rot: 1.1, pl_sx: 3.0, pl_sy: 0.5, pl_ox: 5.0, pl_oy: 9.0 });
st.compose();
check_pt("origin lands on (x,y)", st.world_origin(n), 100.0, 40.0);
}
// A quarter turn: +ve rotation takes +x toward +y. With the origin at (0,0) and
// unit scale, local (1,0) -> (0,1), so the world point is (x, y) + (0, 1).
fn test_quarter_turn() {
st = stage::stage_new();
n = st.node_add(-1, stage::Place { pl_x: 10.0, pl_y: 20.0, pl_rot: 1.5707963267948966 });
st.compose();
check_pt("+x turns toward +y", st.world_point(n, 1.0, 0.0), 10.0, 21.0);
check_pt("+y turns toward -x", st.world_point(n, 0.0, 1.0), 9.0, 20.0);
}
// Non-uniform scale must scale the node's own axes, not the world's — so after a
// quarter turn, sx stretches what is now the world's y.
fn test_non_uniform_scale_follows_rotation() {
st = stage::stage_new();
n = st.node_add(-1, stage::Place { pl_rot: 1.5707963267948966, pl_sx: 3.0, pl_sy: 7.0 });
st.compose();
check_pt("sx acts along local x", st.world_point(n, 1.0, 0.0), 0.0, 3.0);
check_pt("sy acts along local y", st.world_point(n, 0.0, 1.0), -7.0, 0.0);
}
// A pivot away from the corner rotates ABOUT the pivot. Node at (100,100) with
// origin (5,5) and a quarter turn: local (5,5) pins to (100,100), and local (6,5)
// — one step along local +x — lands one step along world +y.
fn test_pivot_rotates_about_the_origin() {
st = stage::stage_new();
n = st.node_add(-1, stage::Place { pl_x: 100.0, pl_y: 100.0, pl_rot: 1.5707963267948966, pl_ox: 5.0, pl_oy: 5.0 });
st.compose();
check_pt("pivot pins", st.world_point(n, 5.0, 5.0), 100.0, 100.0);
check_pt("a step of local +x", st.world_point(n, 6.0, 5.0), 100.0, 101.0);
check_pt("a step of local +y", st.world_point(n, 5.0, 6.0), 99.0, 100.0);
}
// THE GATE: three deep, with rotation, non-uniform scale and a pivot at every
// level, all moving together. A sweep that varies one and pins the rest reads as
// proof and is not — this is the cell where a wrong multiply ORDER survives every
// simpler test.
//
// Worked by hand:
// A: pos (10,0), rot +90, scale (2,1), origin (0,0)
// A.linear = R90 . S(2,1) = [a=0, b=2, c=-1, d=0]; A.t = (10, 0)
// B: child of A, pos (3,0) in A's space, rot 0, scale (1,1), origin (0,0)
// B.local.t = (3,0) -> world t = A.linear . (3,0) + A.t = (10, 6)
// B.linear = A.linear
// C: child of B, pos (0,2) in B's space, rot 0, scale (1,4), origin (0,1)
// C.local: A=[1,0,0,4], t = (0,2) - A.(0,1) = (0, -2)
// C.world.linear = B.linear . C.local.linear = [a=0, b=2, c=-4, d=0]
// C.world.t = B.linear . (0,-2) + B.t = (0*0 + -1*-2 + 10, 2*0 + 0*-2 + 6)
// = (12, 6)
// C's origin (0,1) must therefore land at C.world.linear.(0,1) + C.world.t
// = (-4 + 12, 0 + 6) = (8, 6)
fn test_three_deep_all_axes_together() {
st = stage::stage_new();
q = 1.5707963267948966;
a = st.node_add(-1, stage::Place { pl_x: 10.0, pl_rot: q, pl_sx: 2.0 });
b = st.node_add(a, stage::Place { pl_x: 3.0 });
c = st.node_add(b, stage::Place { pl_y: 2.0, pl_sy: 4.0, pl_oy: 1.0 });
st.compose();
check_pt("A origin", st.world_origin(a), 10.0, 0.0);
check_pt("B origin", st.world_origin(b), 10.0, 6.0);
check_pt("C origin", st.world_origin(c), 8.0, 6.0);
check_pt("C local (1,1)", st.world_point(c, 1.0, 1.0), 8.0, 8.0);
}
// The invariant compose depends on, enforced rather than trusted: a parent must
// already exist. Without this a child could be composed against an unwritten
// parent and produce a plausible wrong frame instead of an error.
fn test_forward_parent_is_refused() {
st = stage::stage_new();
assert(st.node_add(5, stage::Place {}) == -1,
"a forward parent reference must be refused");
assert(st.node_add(-2, stage::Place {}) == -1,
"a parent below -1 must be refused");
assert(st.node_count() == 0, "a refused add must not append");
}
tests/textfield.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN145 D2 — focus, tab order and the text field, driven by REPLAYED events.
//
// ⚠ Every buffer below was worked out by hand from the keystroke list, and the
// non-ASCII cells are the point: `len(text)` counts CHARACTERS while `s[a..b]`
// slices BYTES, so a caret kept as a byte offset puts that trap inside the edit
// loop. Every index here is a character count, and the gates say so in text
// where the two differ.
use stage::*;
use graphics::*;
const NOMOD = 0;
const SHIFT = 1; // graphics::MOD_SHIFT
const CTRL = 2; // graphics::MOD_CTRL
fn field_node(st: Stage, s: text) -> integer {
n = st.node_add(-1, Place { pl_w: 100.0, pl_h: 12.0 });
st.compose();
st.set_field(n, s);
st.set_focus(n);
n
}
// THE GATE. A replayed keystroke sequence produces the exact buffer, and the
// text arriving through `text_event` is what an IME commits.
fn test_replayed_keystrokes_produce_the_exact_buffer() {
st = stage_new();
n = field_node(st, "");
st.text_event("Ada");
assert(st.field_text(n) == "Ada", "typed: {st.field_text(n)}");
assert(st.field_caret(n) == 3, "caret follows the insertion");
st.key_event(graphics::KEY_BACKSPACE, NOMOD);
assert(st.field_text(n) == "Ad", "backspace: {st.field_text(n)}");
st.key_event(graphics::KEY_LEFT, NOMOD);
st.text_event("m");
assert(st.field_text(n) == "Amd", "insert in the middle: {st.field_text(n)}");
st.key_event(graphics::KEY_END, NOMOD);
st.text_event("in");
assert(st.field_text(n) == "Amdin", "at the end: {st.field_text(n)}");
st.key_event(graphics::KEY_HOME, NOMOD);
st.key_event(graphics::KEY_DELETE, NOMOD);
assert(st.field_text(n) == "mdin", "delete at the start: {st.field_text(n)}");
assert(st.field_caret(n) == 0, "and the caret stays put");
}
// ⚠⚠ THE NON-ASCII GATE. Every one of these is a CHARACTER operation, and each
// would come out differently if the caret were a byte offset.
fn test_editing_counts_characters_and_not_bytes() {
st = stage_new();
n = field_node(st, "héllo");
assert(len("héllo") == 5, "five characters");
assert(size("héllo") == 6, "…but six bytes — which is the whole trap");
assert(st.field_caret(n) == 5, "a new field puts the caret after the last CHARACTER");
st.key_event(graphics::KEY_HOME, NOMOD);
st.key_event(graphics::KEY_RIGHT, NOMOD);
st.key_event(graphics::KEY_RIGHT, NOMOD);
assert(st.field_caret(n) == 2, "two rights is two characters");
st.key_event(graphics::KEY_BACKSPACE, NOMOD);
// Removing the 'é' — a two-byte character — must leave exactly "hllo".
assert(st.field_text(n) == "hllo", "backspace over a 2-byte char: {st.field_text(n)}");
assert(st.field_caret(n) == 1, "and the caret steps back one CHARACTER");
st.text_event("é");
assert(st.field_text(n) == "héllo", "and it goes back exactly where it was");
// A whole IME commit lands as one insertion, several characters wide.
st.key_event(graphics::KEY_END, NOMOD);
st.text_event("世界");
assert(st.field_text(n) == "héllo世界", "IME commit: {st.field_text(n)}");
assert(st.field_caret(n) == 7, "five plus two characters, got {st.field_caret(n)}");
assert(size(st.field_text(n)) == 12, "which is twelve BYTES — the counts differ");
}
// ⚠⚠ THE MODIFIER GATE. `D0b` measured that `input`'s bindings cannot express a
// modifier; the EVENT carries one, and here is the difference it makes: the same
// key with and without Shift does two different things.
fn test_the_same_key_with_shift_does_something_else() {
st = stage_new();
n = field_node(st, "abcdef");
st.key_event(graphics::KEY_LEFT, NOMOD);
assert(st.field_selection(n) == "", "plain left selects nothing");
assert(st.field_caret(n) == 5, "and just moves");
st.key_event(graphics::KEY_LEFT, SHIFT);
st.key_event(graphics::KEY_LEFT, SHIFT);
assert(st.field_selection(n) == "de", "shift+left selects: {st.field_selection(n)}");
assert(st.field_mark(n) == 5, "the anchor stayed where the selection began");
// Typing over a selection replaces it.
st.text_event("X");
assert(st.field_text(n) == "abcXf", "typing replaces the selection: {st.field_text(n)}");
}
// Ctrl+C / Ctrl+X / Ctrl+V round-trip through our own clipboard.
fn test_copy_cut_and_paste() {
st = stage_new();
n = field_node(st, "hello");
st.key_event(graphics::KEY_A, CTRL);
assert(st.field_selection(n) == "hello", "ctrl+a selects all");
st.key_event(KEY_COPY, CTRL);
assert(st.clipboard() == "hello", "ctrl+c copied: {st.clipboard()}");
assert(st.field_text(n) == "hello", "and changed nothing");
st.key_event(graphics::KEY_END, NOMOD);
st.key_event(KEY_PASTE, CTRL);
assert(st.field_text(n) == "hellohello", "ctrl+v pasted: {st.field_text(n)}");
st.key_event(graphics::KEY_A, CTRL);
st.key_event(KEY_CUT, CTRL);
assert(st.field_text(n) == "", "ctrl+x emptied it");
assert(st.clipboard() == "hellohello", "and took the text with it");
}
// Backspace with a selection deletes the SELECTION, not one character — the
// classic off-by-a-lot.
fn test_backspace_over_a_selection_deletes_the_selection() {
st = stage_new();
n = field_node(st, "abcdef");
st.key_event(graphics::KEY_HOME, NOMOD);
for _ in 0..3 { st.key_event(graphics::KEY_RIGHT, SHIFT); }
assert(st.field_selection(n) == "abc", "three selected");
st.key_event(graphics::KEY_BACKSPACE, NOMOD);
assert(st.field_text(n) == "def", "the selection went, not one char: {st.field_text(n)}");
assert(st.field_selection(n) == "", "and nothing is selected now");
}
// ⚠ A plain arrow with a LIVE selection collapses to its near end rather than
// stepping one further.
fn test_an_arrow_collapses_a_selection_to_its_near_end() {
st = stage_new();
n = field_node(st, "abcdef");
st.key_event(graphics::KEY_HOME, NOMOD);
for _ in 0..3 { st.key_event(graphics::KEY_RIGHT, SHIFT); }
assert(st.field_caret(n) == 3 and st.field_mark(n) == 0, "caret 3, anchor 0");
st.key_event(graphics::KEY_LEFT, NOMOD);
assert(st.field_caret(n) == 0, "left collapses to the START of the selection");
for _ in 0..3 { st.key_event(graphics::KEY_RIGHT, SHIFT); }
st.key_event(graphics::KEY_RIGHT, NOMOD);
assert(st.field_caret(n) == 3, "and right collapses to its END");
}
// Tab order is the DECLARED order, and it wraps in both directions.
fn test_tab_order_follows_the_declared_order() {
st = stage_new();
a = st.node_add(-1, Place { pl_w: 10.0, pl_h: 10.0 });
b = st.node_add(-1, Place { pl_w: 10.0, pl_h: 10.0 });
c = st.node_add(-1, Place { pl_w: 10.0, pl_h: 10.0 });
st.compose();
st.set_focusable(a, true);
st.set_focusable(c, true); // b is deliberately NOT focusable
assert(st.focused() == -1, "nothing has it to begin with");
st.focus_next();
assert(st.focused() == a, "forward from nothing lands on the FIRST");
st.focus_next();
assert(st.focused() == c, "and skips the one that cannot take it");
st.focus_next();
assert(st.focused() == a, "and wraps");
st.focus_prev();
assert(st.focused() == c, "backward wraps the other way");
st2 = stage_new();
_ = st2.node_add(-1, Place { pl_w: 10.0, pl_h: 10.0 });
z = st2.node_add(-1, Place { pl_w: 10.0, pl_h: 10.0 });
st2.compose();
st2.set_focusable(z, true);
st2.focus_prev();
assert(st2.focused() == z, "backward from nothing lands on the LAST");
assert(b == a + 1, "…and b really was between them");
}
// Tab is delivered as a key like any other, and moves the focus rather than
// typing.
fn test_tab_moves_the_focus_and_does_not_type() {
st = stage_new();
a = st.node_add(-1, Place { pl_w: 10.0, pl_h: 10.0 });
b = st.node_add(-1, Place { pl_w: 10.0, pl_h: 10.0 });
st.compose();
st.set_field(a, "one");
st.set_field(b, "two");
st.set_focus(a);
st.key_event(graphics::KEY_TAB, NOMOD);
assert(st.focused() == b, "tab moved on");
assert(st.field_text(a) == "one", "and typed nothing into the old field");
st.key_event(graphics::KEY_TAB, SHIFT);
assert(st.focused() == a, "shift+tab goes back");
// Keys reach only the FOCUSED field.
st.text_event("!");
assert(st.field_text(a) == "one!", "the focused field took it");
assert(st.field_text(b) == "two", "the other one did not");
}
// A press moves the keyboard: onto a focusable node, off everything else.
fn test_a_press_moves_the_keyboard() {
st = stage_new();
a = st.node_add(-1, Place { pl_w: 10.0, pl_h: 10.0 });
b = st.node_add(-1, Place { pl_w: 10.0, pl_h: 10.0, pl_x: 20.0 });
st.compose();
st.set_field(a, "");
st.set_focusable(b, false);
_ = st.press(5.0, 5.0);
assert(st.focused() == a, "pressing the field focused it");
_ = st.click(5.0, 5.0);
_ = st.press(25.0, 5.0);
assert(st.focused() == -1, "pressing a node that cannot take it blurs");
st.text_event("x");
assert(st.field_text(a) == "", "and nothing is typed anywhere");
}
// A disabled node cannot hold the keyboard and is skipped by tab.
fn test_a_disabled_node_leaves_the_tab_order() {
st = stage_new();
a = st.node_add(-1, Place { pl_w: 10.0, pl_h: 10.0 });
b = st.node_add(-1, Place { pl_w: 10.0, pl_h: 10.0 });
st.compose();
st.set_focusable(a, true);
st.set_focusable(b, true);
st.set_enabled(b, false);
st.focus_next();
assert(st.focused() == a, "on the first");
st.focus_next();
assert(st.focused() == a, "the disabled one is skipped, so it stays");
st.set_focus(b);
assert(st.focused() == -1, "and it cannot be focused directly either");
}
// A click resolves to a CHARACTER index — the seam whoever draws the text uses
// once it has turned an x-coordinate into a count.
fn test_placing_the_caret_by_character_index() {
st = stage_new();
n = field_node(st, "héllo");
st.field_place_caret(n, 2, false);
assert(st.field_caret(n) == 2, "placed at 2");
assert(st.field_selection(n) == "", "with nothing selected");
st.field_place_caret(n, 4, true);
assert(st.field_selection(n) == "ll", "extending selects: {st.field_selection(n)}");
st.field_place_caret(n, 99, false);
assert(st.field_caret(n) == 5, "a click past the end clamps to the length");
st.field_place_caret(n, -3, false);
assert(st.field_caret(n) == 0, "and before the start clamps to zero");
}
tests/views.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN144 P6 — several views over one stage.
//
// The split this enforces is the useful half: the WORLD is deterministic and
// shared, the PRESENTATION is local and free. Two players may look at one scene
// from two cameras through two viewports, and neither may perturb the other —
// not its pixels, not its picking, and not the single instance upload they share.
use stage::*;
use graphics::*;
EPS = 0.000001;
fn near(a: float, b: float) -> boolean { a - b < EPS and b - a < EPS }
fn same(a: const graphics::Canvas, b: const graphics::Canvas) -> integer {
n = 0;
for i in 0..len(a.data) { if (a.data[i] ?? 0) != (b.data[i] ?? 0) { n = n + 1; } }
n
}
fn count_of(cv: const graphics::Canvas, want: integer) -> integer {
n = 0;
for v in cv.data { if v == want { n = n + 1; } }
n
}
// A mark at world (100, 50), 10 x 10.
fn marked(st: stage::Stage) -> integer {
st.node_add(-1, stage::Place { pl_w: 10.0, pl_h: 10.0, pl_x: 100.0, pl_y: 50.0,
pl_colour: 0xc03030 })
}
// ── the degenerate case first ────────────────────────────────────────────────
// A stage nobody split has exactly ONE view, and the old camera calls are that
// view — one code path, as P2's parallax 1.0 is.
fn test_one_view_is_the_default_and_the_old_camera_is_it() {
st = stage::stage_new();
assert(st.view_count() == 1, "a fresh stage already has its view");
st.set_camera(7.0, 9.0);
assert(near(st.view_camera_x(0), 7.0) and near(st.view_camera_y(0), 9.0),
"set_camera writes view 0");
st.set_view_camera(0, 3.0, 4.0);
assert(near(st.camera_x(), 3.0) and near(st.camera_y(), 4.0),
"and reading it back through the old name agrees");
}
// ── a second view cannot perturb the first ───────────────────────────────────
// ⚠ THE PHASE'S WHOLE CLAIM. View 0's frame must be byte-identical whether or
// not a second view exists — otherwise split-screen is a different game.
fn test_a_second_view_does_not_change_the_first() {
alone = stage::stage_new();
_ = marked(alone);
alone.set_view_rect(0, 0, 0, 200, 100);
alone.set_view_camera(0, 60.0, 20.0);
alone.compose();
ca = graphics::canvas(400, 100, 0xff000000);
alone.render_view(0, ca);
shared = stage::stage_new();
_ = marked(shared);
shared.set_view_rect(0, 0, 0, 200, 100);
shared.set_view_camera(0, 60.0, 20.0);
v1 = shared.add_view(200, 0, 200, 100);
shared.set_view_camera(v1, -30.0, 5.0);
shared.compose();
cb = graphics::canvas(400, 100, 0xff000000);
shared.render_view(0, cb);
assert(shared.view_count() == 2, "two views");
assert(same(ca, cb) == 0,
"view 0 is byte-identical with a second view present — {same(ca, cb)} differ");
}
// ⚠ TWO VIEWS, ONE UPLOAD. The scene is packed once and every view draws from
// it; a view that re-packed would spend A3's entire budget on looking twice.
// This is P2's property — the camera is a uniform — carried to N cameras.
fn test_two_views_cost_one_upload() {
st = stage::stage_new();
_ = marked(st);
v1 = st.add_view(200, 0, 200, 100);
st.set_view_camera(v1, 40.0, 0.0);
st.compose();
assert(st.sync_instances(), "the scene is new, so the first pack is an upload");
assert(!st.sync_instances(), "nothing changed, so nothing re-packs");
st.set_view_camera(v1, 90.0, 0.0);
st.set_view_camera(0, -12.0, 3.0);
assert(!st.sync_instances(), "moving EITHER camera must not re-pack");
assert(st.uploads() == 1, "one upload serves both views — got {st.uploads()}");
}
// ── each view sees through its own camera ────────────────────────────────────
// Hand-computed. The mark sits at world x = 100. View 0 is the rect
// (0,0,200,100) with its camera at 0, so the mark lands at screen x = 100.
// View 1 is the rect (200,0,200,100) with its camera at 100, so the mark lands
// at 100 - 100 + 200 = 200 — the left edge of that viewport, because the camera
// is looking straight at it.
fn test_each_view_uses_its_own_camera_and_viewport() {
st = stage::stage_new();
_ = marked(st);
st.set_view_rect(0, 0, 0, 200, 100);
v1 = st.add_view(200, 0, 200, 100);
st.set_view_camera(v1, 100.0, 0.0);
st.compose();
c0 = graphics::canvas(400, 100, 0xff000000);
st.render_view(0, c0);
assert(c0.get_pixel(100, 50) == 0xffc03030, "view 0 draws the mark at x=100");
assert(count_of(c0, 0xffc03030) == 100, "one 10x10 mark, whole");
c1 = graphics::canvas(400, 100, 0xff000000);
st.render_view(v1, c1);
assert(c1.get_pixel(200, 50) == 0xffc03030, "view 1 draws it at its left edge");
assert(c1.get_pixel(100, 50) == 0xff000000,
"and NOT where view 0 put it — that would be one shared camera");
assert(count_of(c1, 0xffc03030) == 100, "still whole, just elsewhere");
}
// ⚠ A VIEW PAINTS ONLY INSIDE ITS OWN RECT. Without that, one player's world
// bleeds across the other's screen, which is the failure everybody sees first.
fn test_a_view_paints_only_inside_its_viewport() {
st = stage::stage_new();
_ = marked(st); // world x 100..110
v1 = st.add_view(200, 0, 200, 100);
st.set_view_camera(v1, 0.0, 0.0); // so it wants to draw at 100..110 + 200
st.set_view_camera(0, 0.0, 0.0);
st.set_view_rect(0, 0, 0, 200, 100);
st.compose();
c1 = graphics::canvas(400, 100, 0xff000000);
st.render_view(v1, c1);
assert(count_of(c1, 0xffc03030) == 100, "the mark is drawn, inside the rect");
assert(c1.get_pixel(300, 50) == 0xffc03030, "at 100 + 200, its viewport origin");
// Now push the camera so the mark would land left of the viewport.
st.set_view_camera(v1, 150.0, 0.0); // 100 - 150 + 200 = 150, outside 200..400
c2 = graphics::canvas(400, 100, 0xff000000);
st.render_view(v1, c2);
assert(count_of(c2, 0xffc03030) == 0,
"outside the rect it paints NOTHING — {count_of(c2, 0xffc03030)} pixels leaked");
}
// ── picking follows the view the point is in ─────────────────────────────────
// ⚠ ONE SCREEN POINT IS A DIFFERENT WORLD POINT IN EACH VIEW — the same reason
// P2 un-applies the camera per LAYER, now per view as well. A click routed
// through the wrong view picks the wrong entity, silently.
fn test_picking_un_applies_the_view_under_the_point() {
st = stage::stage_new();
m = marked(st);
st.set_view_rect(0, 0, 0, 200, 100);
v1 = st.add_view(200, 0, 200, 100);
st.set_view_camera(v1, 100.0, 0.0);
st.compose();
assert(st.pick_in(0, 105.0, 55.0) == m, "view 0: the mark is at screen 100..110");
assert(st.pick_in(0, 205.0, 55.0) == -1, "and nothing of view 0's is over there");
assert(st.pick_in(v1, 205.0, 55.0) == m, "view 1: the same mark, at 200..210");
assert(st.pick_in(v1, 105.0, 55.0) == -1, "not at view 0's screen position");
}
// A point outside a view's rect belongs to no entity OF THAT VIEW, whatever the
// camera would say — the viewport is part of the mapping, not decoration.
//
// ⚠ THE CAMERA IS CHOSEN SO THE GUARD IS THE ONLY THING SAYING NO. View 1's
// offset is -155 + 200 = 45, so screen x = 150 maps to world x = 105 — squarely
// inside the mark. Drop the viewport test and this point PICKS IT, through a
// view that does not even paint there. An earlier version of this test put the
// point where the camera missed anyway, so it passed either way and proved
// nothing; the control is what exposed that.
fn test_a_point_outside_a_view_picks_nothing_in_it() {
st = stage::stage_new();
m = marked(st);
st.set_view_rect(0, 0, 0, 200, 100);
v1 = st.add_view(200, 0, 200, 100);
st.set_view_camera(v1, 155.0, 0.0);
st.compose();
assert(st.hits_in(v1, m, 150.0, 55.0),
"the mark IS under that point as view 1 maps it — 150 - 45 = 105");
assert(st.pick_in(v1, 150.0, 55.0) == -1,
"but a point in view 0's half is not view 1's to answer");
}
// The unsplit path is unchanged: `pick` is view 0's pick.
fn test_pick_is_view_zeros_pick() {
st = stage::stage_new();
m = marked(st);
st.compose();
assert(st.pick(105.0, 55.0) == m, "the old entry point still works");
st.set_camera(50.0, 0.0);
assert(st.pick(55.0, 55.0) == m, "and still un-applies the camera");
}
// ── presentation is per view, the world is shared ────────────────────────────
// Adding a view adds no nodes, no sequences and no geometry — it is a way of
// LOOKING, and the scene cannot tell how many are looking.
fn test_a_view_is_presentation_not_world() {
st = stage::stage_new();
m = marked(st);
st.compose();
n_before = st.node_count();
b0 = st.bounds_of(m);
_ = st.add_view(200, 0, 200, 100);
_ = st.add_view(0, 100, 400, 100);
st.compose();
b1 = st.bounds_of(m);
assert(st.node_count() == n_before, "three views, no extra nodes");
assert(near(b0.b_x0, b1.b_x0) and near(b0.b_y0, b1.b_y0), "and the world did not move");
assert(near(b0.b_x1, b1.b_x1) and near(b0.b_y1, b1.b_y1), "in either corner");
}
// ── the GL path's own arithmetic ─────────────────────────────────────────────
//
// ⚠ GATED DIRECTLY, because `gl_render` needs a GL context these tests do not
// have. The scissor rect is the one piece of view arithmetic the software path
// does NOT share — it narrows integer spans where GL cuts with a rect — so a
// wrong answer here is a silent wrong picture on the target that ships. Every
// rect below is worked out by hand.
fn batch_at(clipped: boolean, x0: float, y0: float, x1: float, y1: float) -> stage::Batch {
stage::Batch { b_layer: 0, b_atlas: 0, b_first: 0, b_count: 1,
b_clipped: clipped, b_cx0: x0, b_cy0: y0, b_cx1: x1, b_cy1: y1 }
}
fn zero() -> stage::P2 { stage::P2 { p_x: 0.0, p_y: 0.0 } }
fn test_the_scissor_is_the_view_rect_met_with_the_clip() {
st = stage::stage_new();
v1 = st.add_view(200, 0, 200, 100);
// An unbounded view and an unclipped run: nothing is cut.
a = st.scissor_of(0, batch_at(false, 0.0, 0.0, 0.0, 0.0), zero());
assert(near(a.b_x0, 0.0) and a.b_x1 > 100000.0, "view 0 unbounded, run unclipped");
// A bounded view, unclipped run: exactly the view's rect.
b = st.scissor_of(v1, batch_at(false, 0.0, 0.0, 0.0, 0.0), zero());
assert(near(b.b_x0, 200.0) and near(b.b_x1, 400.0), "the viewport, in x");
assert(near(b.b_y0, 0.0) and near(b.b_y1, 100.0), "and in y");
// Unbounded view, clipped run: the clip alone.
c = st.scissor_of(0, batch_at(true, 10.0, 10.0, 50.0, 60.0), zero());
assert(near(c.b_x0, 10.0) and near(c.b_x1, 50.0), "the clip, in x");
assert(near(c.b_y0, 10.0) and near(c.b_y1, 60.0), "and in y");
// Both: the INTERSECTION, not the innermost — the rule A6 already fixed.
d = st.scissor_of(v1, batch_at(true, 100.0, 10.0, 300.0, 60.0), zero());
assert(near(d.b_x0, 200.0) and near(d.b_x1, 300.0),
"met with the viewport: {d.b_x0}..{d.b_x1}, want 200..300");
// ⚠ And the clip MOVES WITH ITS CONTENT. A camera 20 to the right shifts the
// cut 20 to the left, exactly as the software path shifts it.
e = st.scissor_of(0, batch_at(true, 100.0, 0.0, 200.0, 50.0),
stage::P2 { p_x: -20.0, p_y: 0.0 });
assert(near(e.b_x0, 80.0) and near(e.b_x1, 180.0),
"panned clip: {e.b_x0}..{e.b_x1}, want 80..180");
// Disjoint reads as EMPTY, so the caller can tell "cut everything" from "cut
// nothing" — an inverted rect handed to GL is undefined behaviour.
f = st.scissor_of(v1, batch_at(true, 0.0, 0.0, 50.0, 50.0), zero());
assert(f.b_x1 <= f.b_x0, "a run outside the view leaves an empty rect");
}
tests/widgetstate.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN145 D1 — the four widget states, driven by a REPLAYED pointer stream.
//
// The stream is data, so the gate is a recording rather than a script of calls:
// every expected state below was worked out by hand from the event list, and a
// failure prints the whole sequence rather than one cell.
//
// ⚠ The pointer path needed no new seam — `press` / `release` / `pointer_move`
// take coordinates and read no device, which is the constraint @PLN144 `A4` was
// built under. (The KEY half is the `input` package's `input_tick_from_state`,
// cleared by `D0b`.)
use stage::*;
const MOVE = 0;
const DOWN = 1;
const UP = 2;
struct Ev { e_kind: integer, e_x: float, e_y: float }
fn ev(k: integer, x: float, y: float) -> Ev { Ev { e_kind: k, e_x: x, e_y: y } }
fn name_of(s: UiState) -> text {
match s {
Normal => "Normal",
Over => "Over",
Down => "Down",
Disabled => "Disabled",
}
}
// Two buttons, side by side: A over x 0..10, B over x 20..30.
fn two_buttons(st: Stage) -> integer {
a = st.node_add(-1, Place { pl_w: 10.0, pl_h: 10.0 });
_ = st.node_add(-1, Place { pl_w: 10.0, pl_h: 10.0, pl_x: 20.0 });
st.compose();
a
}
// Apply one event; answer the node it fired, or -1.
fn apply(st: Stage, e: const Ev) -> integer {
if e.e_kind == MOVE { st.pointer_move(e.e_x, e.e_y); return -1; }
if e.e_kind == DOWN { _ = st.press(e.e_x, e.e_y); return -1; }
st.click(e.e_x, e.e_y)
}
fn the_stream() -> vector<Ev> {
[ev(MOVE, 5.0, 5.0), // 1 onto A
ev(DOWN, 5.0, 5.0), // 2 press A
ev(MOVE, 25.0, 5.0), // 3 drag off A, across B
ev(UP, 25.0, 5.0), // 4 release away — must NOT fire
ev(MOVE, 5.0, 5.0), // 5 back onto A
ev(DOWN, 5.0, 5.0), // 6 press A again
ev(MOVE, 6.0, 6.0), // 7 drift one pixel, still inside
ev(UP, 6.0, 6.0)] // 8 release inside — MUST fire
}
// THE GATE. Eight events, and the exact state of both buttons after each.
fn test_a_replayed_stream_drives_the_exact_state_sequence() {
// Hand-computed from the stream above. B is `Normal` at step 3 because a
// press in flight suppresses `Over` everywhere — dragging across a neighbour
// must not light it up.
want_a = ["Over", "Down", "Normal", "Normal", "Over", "Down", "Down", "Over"];
want_b = ["Normal", "Normal", "Normal", "Over", "Normal", "Normal", "Normal", "Normal"];
st = stage_new();
a = two_buttons(st);
b = a + 1;
fired: vector<integer> = [];
for e in the_stream() {
fired += [apply(st, e)];
k = e#index;
got_a = name_of(st.ui_state(a));
got_b = name_of(st.ui_state(b));
assert(got_a == (want_a[k] ?? ""), "step {k + 1}: A is {got_a}, expected {want_a[k] ?? \"\"}");
assert(got_b == (want_b[k] ?? ""), "step {k + 1}: B is {got_b}, expected {want_b[k] ?? \"\"}");
}
assert((fired[3] ?? 0) == -1, "step 4 released away from A and must NOT fire");
assert((fired[7] ?? 0) == a, "step 8 released inside A and MUST fire");
}
// ⚠⚠ THE INVARIANT: a node reads `Down` exactly when a release at the current
// pointer would fire it. Swept over five release positions — the picture and
// the click come from one predicate, so they cannot disagree.
fn test_down_means_exactly_that_a_release_would_fire() {
xs = [5.0, 9.5, 11.0, 25.0, 100.0];
ys = [5.0, 9.5, 5.0, 5.0, 100.0];
downs = 0;
fires = 0;
for i in 0..len(xs) {
st = stage_new();
a = two_buttons(st);
_ = st.press(5.0, 5.0); // always press A's middle
px = xs[i] ?? 0.0;
py = ys[i] ?? 0.0;
st.pointer_move(px, py);
looks_down = st.ui_state(a) == UiState.Down;
did_fire = st.click(px, py) == a;
assert(looks_down == did_fire,
"at ({px}, {py}) the button looked {name_of(st.ui_state(a))} but firing was {did_fire}");
if looks_down { downs = downs + 1; }
if did_fire { fires = fires + 1; }
}
// ⚠ The control: the sweep must contain BOTH answers, or the equivalence above
// is satisfied by everything being false.
assert(downs == 2, "two of the five positions are inside A, got {downs}");
assert(fires == 2, "and exactly those two fire");
}
// ⚠⚠ ON TOUCH THERE IS NO `Over` — replayed on the SAME stream, so what changes
// is only what is SHOWN, never what fires.
fn test_touch_never_shows_over_and_fires_the_same() {
mouse_fired: vector<integer> = [];
st_m = stage_new();
a_m = two_buttons(st_m);
for e in the_stream() { mouse_fired += [apply(st_m, e)]; }
assert(a_m == 0, "both runs build the same scene, so the node ids line up");
touch_fired: vector<integer> = [];
st_t = stage_new();
a_t = two_buttons(st_t);
st_t.set_pointer(POINTER_TOUCH);
overs = 0;
for e in the_stream() {
touch_fired += [apply(st_t, e)];
if st_t.ui_state(a_t) == UiState.Over { overs = overs + 1; }
if st_t.ui_state(a_t + 1) == UiState.Over { overs = overs + 1; }
}
assert(overs == 0, "a touch stream showed `Over` {overs} times");
assert(len(mouse_fired) == len(touch_fired), "same stream, same length");
for i in 0..len(mouse_fired) {
assert((mouse_fired[i] ?? 0) == (touch_fired[i] ?? 0),
"step {i + 1} fired differently on touch");
}
assert((touch_fired[7] ?? 0) == a_t, "and the touch DID fire on the last step");
// ⚠ The control: the same stream on a MOUSE does show `Over`, so the zero
// above is the pointer kind and not a stream that never hovers.
mouse_overs = 0;
st_c = stage_new();
a_c = two_buttons(st_c);
for e in the_stream() {
_ = apply(st_c, e);
if st_c.ui_state(a_c) == UiState.Over { mouse_overs = mouse_overs + 1; }
}
// Steps 1, 5 and 8 — onto A, back onto A, and after the release clears the
// capture with the pointer still on it.
assert(mouse_overs == 3, "the mouse run shows Over three times, got {mouse_overs}");
}
// Leaving and coming back RE-ARMS: the capture is held the whole time, so the
// press is not lost by drifting off and back.
fn test_leaving_and_returning_re_arms() {
st = stage_new();
a = two_buttons(st);
_ = st.press(5.0, 5.0);
assert(st.ui_state(a) == UiState.Down, "pressed");
st.pointer_move(50.0, 50.0);
assert(st.ui_state(a) == UiState.Normal, "away, so not armed");
assert(st.capture() == a, "…but the press is still held");
st.pointer_move(5.0, 5.0);
assert(st.ui_state(a) == UiState.Down, "back inside, armed again");
assert(st.click(5.0, 5.0) == a, "and it fires");
}
// `Disabled` outranks the other three, and refuses to fire.
fn test_a_disabled_node_never_fires_and_always_looks_disabled() {
st = stage_new();
a = two_buttons(st);
st.set_enabled(a, false);
assert(!st.enabled(a), "it is off");
st.pointer_move(5.0, 5.0);
assert(st.ui_state(a) == UiState.Disabled, "hovering it does not make it Over");
_ = st.press(5.0, 5.0);
assert(st.ui_state(a) == UiState.Disabled, "pressing it does not make it Down");
assert(st.click(5.0, 5.0) == -1, "and it does not fire");
st.set_enabled(a, true);
st.pointer_move(5.0, 5.0);
assert(st.ui_state(a) == UiState.Over, "and it comes back");
}
// `release` is untouched by D1 — it still answers who took the press, whether or
// not the pointer is still there. `click` is the other door.
fn test_release_still_answers_the_captured_node() {
st = stage_new();
a = two_buttons(st);
_ = st.press(5.0, 5.0);
assert(st.release(50.0, 50.0) == a, "release names the node that took the press");
_ = st.press(5.0, 5.0);
assert(st.click(50.0, 50.0) == -1, "while click names the node that FIRES");
}
// A press on nothing captures nothing, and a release then fires nothing.
fn test_pressing_empty_space_fires_nothing() {
st = stage_new();
a = two_buttons(st);
_ = st.press(50.0, 50.0);
assert(st.capture() == -1, "nothing was under the pointer");
assert(st.ui_state(a) == UiState.Normal, "and no button is lit");
assert(st.click(5.0, 5.0) == -1, "releasing over a button does not fire it");
}
tests/worked-examples.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Worked examples for the `stage` library (@PLN141). Each `test_*` below
// demonstrates a contract the signature cannot state; the function documents
// itself by CITING the tag with `// Example: @STG-00N`, and the doc-drift gate
// fails if a citation goes dangling. Distinct from the assertion suite in
// `stage.loft`.
//
// Everything here is floats in and floats out, so the type checker sees none of
// it. What a caller gets wrong is WHICH POINT a node is anchored by, that the
// tree is an ordered array rather than a graph, and that reading a world
// transform before composing answers a stale one rather than an error.
use stage::*;
use graphics::*;
EPS = 0.0001;
fn close(a: float, b: float) -> boolean { a - b < EPS and b - a < EPS }
// @STG-001 — a node is anchored by its ORIGIN, not by its top-left corner.
// `pl_ox`/`pl_oy` name a point in the node's own units, and `compose` places
// exactly that point at (`pl_x`, `pl_y`) — whatever the rotation and scale are.
// Put the origin at a sprite's feet and the sprite STANDS UP from where it was
// placed, which is what lets a 2-D scene present a 3-D world: the footprint
// decides where it sits and the artwork decides nothing. Move the origin and the
// node pivots about the new point rather than sliding, so the same `pl_x`/`pl_y`
// mean a different picture — the commonest surprise in this library.
fn test_origin_is_what_a_node_is_anchored_by() {
st = stage::stage_new();
feet = st.node_add(-1, stage::Place { pl_x: 50.0, pl_y: 80.0, pl_ox: 8.0, pl_oy: 16.0,
pl_rot: 0.7, pl_sx: 2.0, pl_sy: 3.0 });
st.compose();
at = st.world_origin(feet);
assert(close(at.p_x, 50.0) and close(at.p_y, 80.0),
"the origin lands on (pl_x, pl_y) under any rotation and scale");
}
// @STG-002 — the tree is a FLAT ARRAY and a parent must already exist. A child
// names its parent by index, and `node_add` REFUSES a forward reference by
// answering -1 rather than accepting it. That is not bookkeeping: `compose` is a
// single forward pass, so a child sitting before its parent would read a world
// transform that had not been written yet and produce a plausible wrong frame —
// the kind of bug that looks like an art problem. Build parents first and the
// invariant holds by construction.
fn test_a_parent_must_already_exist() {
st = stage::stage_new();
assert(st.node_add(3, stage::Place {}) == -1, "a forward parent reference is refused");
root = st.node_add(-1, stage::Place { pl_x: 1.0 });
child = st.node_add(root, stage::Place { pl_x: 2.0 });
assert(root == 0 and child == 1, "indices are assigned in insertion order");
assert(st.node_count() == 2, "a refused add appends nothing");
}
// @STG-003 — a world transform is DERIVED, and reading one before `compose` gives
// the identity rather than an error. Mutating a node is cheap precisely because
// nothing recomputes on write; the cost is that `compose` is the caller's to run,
// once per frame, after the last mutation. Skip it and the frame renders the
// PREVIOUS state — every node in the right relative place, the whole scene one
// step behind, which reads as input lag rather than as a missing call.
fn test_compose_is_the_callers_to_run() {
st = stage::stage_new();
n = st.node_add(-1, stage::Place { pl_x: 10.0, pl_y: 20.0 });
before = st.world_origin(n);
assert(close(before.p_x, 0.0) and close(before.p_y, 0.0),
"before compose, the world transform is still the identity");
st.compose();
after = st.world_origin(n);
assert(close(after.p_x, 10.0) and close(after.p_y, 20.0), "compose derives it");
}
// @STG-004 — the batcher merges ADJACENT runs and never sorts, and the cost of
// that is real. 2-D correctness is painter's algebra: overlapping translucent
// draws must composite in call order, so gathering every command of one atlas
// together would be faster and would draw the wrong picture. The consequence a
// caller must plan for is that interleaving two atlases sprite-by-sprite gives
// ONE INSTANCE PER RUN — four sprites, four draws. That is a PACKING problem and
// the cure is upstream: sprites that draw near each other must share a page.
// Batching cannot fix it without reordering, and reordering is the one thing it
// may not do.
fn test_the_batcher_never_reorders() {
st = stage::stage_new();
a = stage::Place { pl_w: 4.0, pl_h: 4.0, pl_atlas: 1 };
b = stage::Place { pl_w: 4.0, pl_h: 4.0, pl_atlas: 2 };
_ = st.node_add(-1, a);
_ = st.node_add(-1, b);
_ = st.node_add(-1, a);
_ = st.node_add(-1, b);
st.compose();
assert(len(st.batches()) == 4,
"interleaved atlases stay four runs — gathering them would reorder the frame");
grouped = stage::stage_new();
_ = grouped.node_add(-1, a);
_ = grouped.node_add(-1, a);
_ = grouped.node_add(-1, b);
_ = grouped.node_add(-1, b);
grouped.compose();
assert(len(grouped.batches()) == 2, "the same four sprites, packed well, are two runs");
}
// @STG-005 — a release belongs to the node that took the PRESS, not to whatever
// is under the pointer now. This is the half of input routing that a hit test
// alone does not give you, and leaving it out produces two bugs that both read as
// "the button is broken": it fires when you press it and slide away, and it fails
// to fire when you press it and drift a single pixel. `release` therefore answers
// the CAPTURED node wherever the pointer went, and `released_on` is the separate
// question a button actually asks — did the release land back on me.
fn test_a_release_belongs_to_the_press() {
st = stage::stage_new();
btn = st.node_add(-1, stage::Place { pl_w: 10.0, pl_h: 10.0 });
st.compose();
assert(st.press(5.0, 5.0) == btn, "the press lands");
assert(st.release(900.0, 900.0) == btn, "the release still belongs to it");
assert(!st.released_on(btn, 900.0, 900.0), "but it did not release ON it — no fire");
assert(st.press(5.0, 5.0) == btn and st.released_on(btn, 6.0, 6.0), "this one fires");
}
// @STG-006 — a sprite is mostly holes, so picking samples its ALPHA and a click
// over a transparent texel reaches what is behind. A node with no mask is solid,
// which is what a panel or a tile wants; give a node a mask and its shape becomes
// its art rather than its rectangle. You can see through the tree, so you can
// click through it.
fn test_a_click_falls_through_a_hole() {
st = stage::stage_new();
behind = st.node_add(-1, stage::Place { pl_w: 40.0, pl_h: 40.0 });
holed = st.add_mask(2, 2, [255, 255, 255, 0]);
front = st.node_add(-1, stage::Place { pl_w: 40.0, pl_h: 40.0, pl_mask: holed });
st.compose();
assert(st.pick(5.0, 5.0) == front, "the opaque quadrant is the front node");
assert(st.pick(35.0, 35.0) == behind, "the hole reaches what is behind");
}
// @STG-007 — a clip INHERITS and INTERSECTS, and a clip region is sized but
// invisible. Nesting two clippers gives the overlap of the two, never the inner
// one alone: a panel inside a scrolling list is bounded by both, which is what
// makes a scroll view composable. The idiom for the region itself is `pl_clips`
// with a size and `pl_visible: false` — it needs a rectangle to define the bound
// and no paint of its own. ⚠ The bound is AXIS-ALIGNED: a rotated clipper clips
// to its bounding box, because a hardware scissor is axis-aligned and there is no
// honest way to pretend otherwise.
fn test_a_clip_inherits_and_intersects() {
st = stage::stage_new();
outer = st.node_add(-1, stage::Place { pl_w: 20.0, pl_h: 20.0, pl_clips: true,
pl_visible: false });
inner = st.node_add(outer, stage::Place { pl_x: 10.0, pl_y: 10.0, pl_w: 30.0,
pl_h: 30.0, pl_clips: true,
pl_visible: false });
_ = st.node_add(inner, stage::Place { pl_w: 60.0, pl_h: 60.0, pl_colour: 0x00ff00 });
st.compose();
cv = graphics::canvas(48, 48, 0xff000000);
st.render_stage(cv);
n = 0;
for v in cv.data { if v == 0xff00ff00 { n = n + 1; } }
assert(n == 100, "the overlap of (0,0)-(20,20) and (10,10)-(40,40) is 10x10");
}
// @STG-008 — ambient motion costs NOTHING per frame, and that is a design
// constraint rather than an optimisation. A tuft's phase is derived from where it
// stands and its motion from one clock, so a field of five hundred is a buffer
// uploaded once plus a uniform that ticks; storing and stepping a phase would
// rewrite all five hundred every frame. Two consequences a caller should expect:
// neighbours are out of step (a field looks like a field rather than like the
// ground moving), and sway is VISUAL ONLY — a swaying tree keeps its depth, its
// bounds and its hit area, because one that re-sorted as it swayed would flicker
// past its neighbours and grass that dodged the pointer would be worse than grass
// that ignored it.
fn test_ambient_motion_is_free_and_visual_only() {
st = stage::stage_new();
a = st.node_add(-1, stage::Place { pl_x: 0.0, pl_w: 2.0, pl_h: 6.0, pl_sway: 4.0 });
b = st.node_add(-1, stage::Place { pl_x: 9.0, pl_w: 2.0, pl_h: 6.0, pl_sway: 4.0 });
st.compose();
before = st.pack_instances();
st.set_time(1.4);
after = st.pack_instances();
for i in 0..len(before) { assert(before[i] == after[i], "the clock rewrites nothing"); }
assert(st.sway_of(a) != st.sway_of(b), "and neighbours are out of step");
bounds = st.bounds_of(a);
st.set_time(2.9);
moved = st.bounds_of(a);
assert(bounds.b_x0 == moved.b_x0, "the footprint never moves with the sway");
}