stage source

← 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.

ItemDefinedWorked exampleCall sites
StageNode struct68—183 339
Layer struct139—198 1895 1903 1905 1907 1909
View struct152—197 314 2389 2412 2414 2423 … (7 total)
StageLight struct171—193 2521 2561 244 251 139
Stage struct182—308 309 324 336 365 467 … (153 total)
LOOP const217—2019 18 40 56 60 131 … (23 total)
ONCE const218—2023 79
PINGPONG const219—2027 94 108
Sequence struct228—202 1977 2018
Mask struct242—184 1092
Place struct258—336 12 13 11 120 25 … (139 total)
Bounds struct293—562 578 1066 1084
P2 struct301—583 584 586 593 594 1066 … (16 total)
stage_new fn308—20 36 51 64 77 94 … (173 total)
node_count fn324—103 204 210 51
node_add fn336STG-00221 37 38 39 52 65 … (170 total)
compose fn365STG-003186 22 40 53 66 79 … (144 total)
depth_cue fn467—135 150 164 178
cue_of fn479—380
haze_of fn490—124 154 166 168 169
order fn553—113 186 20 59 73 78 … (14 total)
bounds_of fn562STG-007438 98 102 112 140 152 … (12 total)
world_point fn583—567 568 569 570 596 648 … (18 total)
world_origin fn593STG-0012592 99 103 137 142 150 … (15 total)
UiRect struct612—621 657 104 106
DrawRect struct620—261 640 641 656 676 103 … (8 total)
DrawText struct627—none in this package
draw_list fn640—124 38 58 83 95
render fn676GFX-001124 127 38 58 83 110
opaque fn684—679 31 127 75 76 62 … (8 total)
Batch struct704—736 737 758 1066 222 223
INSTANCE_STRIDE const720—919 979 980 981 982 983 … (20 total)
OFF_AFFINE const723—980 981 93 94 95 96 … (23 total)
OFF_CELL const724—982 141 101 102 66 67
OFF_RGBA const730—983 104 105 106 107 216
OFF_SWAY const731—984
batches fn736STG-004968 28 43 61 75 103 … (13 total)
pack_instances fn770—911 23 25 135 137 76 … (20 total)
Painter struct884—897 903 910 963 1016 1033
painter_new fn897—none in this package
gl_render fn910—none in this package
scissor_of fn1066—988 233 237 242 247 253 … (7 total)
add_mask fn1091—269 59 126
to_local fn1101—2499 48 50
hits fn1116—1193 1285 72
pick fn1154STG-0061160 1185 1256 1315 100 107 … (26 total)
press fn1159—185 82 87 96 215 218 … (19 total)
release fn1180STG-00584 86 98 52 56 192 … (8 total)
released_on fn1192—86 87 114 115
capture fn1199—83 85 97 162 203
POINTER_MOUSE const1229—208 318 1245
POINTER_TOUCH const1230—208 1245 1296 123
UiState enum1236—1289 1290 1291 1292 1295 1296 … (20 total)
set_pointer fn1244—123
pointer fn1250—209 203
pointer_move fn1253—43 97 160 163 175 182
hovered fn1261—none in this package
set_enabled fn1264—232 172 181
enabled fn1272—173
ui_state fn1289—74 75 98 101 127 128 … (14 total)
click fn1308—107 63 217 255 45 99 … (10 total)
focusable fn1399—1169 1422 1445 161
set_focusable fn1410—160 161 177 213 230 231
focused fn1417—163 165 167 169 171 179 … (14 total)
set_focus fn1421—22 192 238
focus_next fn1430—1583 164 166 168 234 236
focus_prev fn1433—1583 170 178
set_field fn1451—21 190 191 212
is_field fn1519—none in this package
field_text fn1523—32 36 40 44 48 68 … (18 total)
field_caret fn1528—33 49 60 65 69 78 … (13 total)
field_mark fn1533—96 144
field_selection fn1538—90 95 108 131 134 249 … (7 total)
field_place_caret fn1546—247 251 254 256
clipboard fn1555—111 121
set_clipboard fn1557—none in this package
text_event fn1563—31 39 43 71 76 99 … (8 total)
key_event fn1581—35 38 42 46 47 62 … (29 total)
KEY_CUT const1655—1600 119
KEY_COPY const1656—1596 110
KEY_PASTE const1657—1607 115
render_stage fn1682—82 85 118 121 14 28 … (26 total)
render_view fn1693—1683 39 46 58 72 85 … (27 total)
set_camera fn1868—31 52 62 77 96 111 … (11 total)
camera_x fn1872—44
camera_y fn1874—44
set_parallax fn1879—182 49 50 94 109
parallax_of fn1887—2447 184 185 196 63
cam_offset fn1914—53 54 64
uploads fn1920—199 90
set_time fn1939STG-00824 41 54 56 68 80 … (13 total)
time_of fn1942—none in this package
sway_of fn1946—1800 42 43 44 55 57 … (10 total)
add_sequence fn1968—272 18 40 56 60 79 … (12 total)
advance fn1989—104 2198 22 24 45 67 … (28 total)
frame_of fn2004—784 21 23 25 46 47 … (31 total)
restart fn2040—153
Prop enum2082—2094 2099 2121 2127 2150 2162 … (40 total)
prop_of fn2099—2157 65 68 98 104 124 … (16 total)
set_prop fn2121—60 78 81 100 150 161
tween_prop fn2150—51 121 126 135 136 145 … (11 total)
prop_tweening fn2162—147
cancel_prop_tween fn2170—2154 173
cancel_node_tweens fn2179—none in this package
tween_count fn2188—122 127 137 174 203
TOP_DOWN const2222—203 317 2323 52 93 186 … (7 total)
SIDE_ON const2225—203 2338 111 134 170 194 … (10 total)
set_projection fn2229—52 93 111 134 170 186 … (11 total)
projection fn2232—254
sequence_count fn2235—61 277
add_named_sequence fn2240—53 112 113 135 171 172 … (14 total)
facing_name fn2262—2292 39
face fn2320—57 95 99 116 120 139 … (17 total)
set_action fn2335—115 137 174 189 197 214 … (9 total)
facing_of fn2343—97 256
action_of fn2350—none in this package
rotation_of fn2356—98 101 153 201 203 289
mirrored fn2363—145 152 158 177 202 204 … (10 total)
view_count fn2383—39 71
add_view fn2387—65 82 104 126 154 177 … (9 total)
set_view_rect fn2396—55 63 103 129 153 176
set_view_camera fn2406—1868 43 56 64 66 83 … (14 total)
view_camera_x fn2431—1872 41
view_camera_y fn2436—1874 41
view_offset fn2445—977 1040 1799 1915 2498 2704
sync_instances fn2457—916 177 179 181 183 185 … (9 total)
pick_in fn2469—1155 158 159 160 161 182
in_view_rect fn2482—2470
hits_in fn2493—1117 2474 180
add_light fn2519—62 79 99 112 113 129 … (21 total)
move_light fn2534—182
light_count fn2542—36
set_ambient fn2547—46 61 78 98 111 128 … (21 total)
ambient fn2553—107 201 275 35 185
light_reach fn2561—2672 246 247 248 249 250 … (8 total)
lit_colour fn2583—791 1778 37 49 50 68 … (24 total)
LIGHT_FALLOFF const2633—1043 2572 258
HUD_NONE const2636—313 937 946 950 1698 1700 … (8 total)
Lit struct2639—2666 2681
set_light_map fn2647—59 73 93 115 138 159 … (10 total)
light_map fn2653—45
set_hud_layer fn2656—158 171
hud_layer fn2659—none in this package
light_rgb_at fn2666—2593 2687 2720
light_level_at fn2686—120 126 128 146
composite_light fn2702—1699
composite_shader_source fn2739—252
set_layer_fog fn2755—43 55 69 82 183
layer_fog_colour fn2765—188
layer_fog_density fn2770—188 190 198
set_layer_blur fn2780—102 116 139 158 181
layer_blur fn2787—1733 186 187 197
blur_region fn2797—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");
}