feat(rendering): add Light.* — deterministic 2D light accumulation with hard shadows (#4)
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 18s
ci / build-and-test (push) Successful in 1m13s
commit-lint / conventional-commits (push) Successful in 5s
docs / build-and-deploy (push) Successful in 18s

A software light pass over the framebuffer, run in a render phase after drawing
the scene: Light.ambient multiplies the scene toward a tint (night/cave mood),
Light.point additively accumulates a radial glow with linear falloff clamped per
channel, and Light.occlude / Light.clear_occluders cast hard shadows by blocking
a light's rays against rectangular occluders. Integer + Q16.16 fixed throughout,
so a scene lights identically every run and in a headless render (diffable).

Engine in runtime/native/light.ludic, spliced on demand (g_uses_light) like the
regex/query runtimes; namespace wired in emit_call.ludic. Ships issue #4 tiers 1
(ambient + additive radial lights) and 2 (hard shadows). Normal-mapped sprites,
soft shadows, a day/night directional light, and auto-consuming Light2D/Occluder
components are follow-ups (the auto-system hook is tracked by #43).

- runtime/native/light.ludic: the light-accumulation engine (isqrt falloff,
  segment/occluder shadow test, ambient modulate)
- examples/library/lighting.ludic: 14 pixel-readback assertions
- docs/language/light/: Light.ambient/point/occlude/clear_occluders
- tools/x/test.ludic: lighting.ludic in the regression suite

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-31 14:07:19 +03:00
parent 31cfbc2465
commit a71279a7b9
12 changed files with 15747 additions and 14968 deletions

View file

@ -0,0 +1,3 @@
bump: minor
type: feat
2D lighting — the `Light.*` namespace: a deterministic software light-accumulation pass over the framebuffer. `Light.ambient` modulates the scene toward a tint (night/cave mood), `Light.point` adds a radial glow with linear falloff clamped per channel, and `Light.occlude`/`Light.clear_occluders` cast hard shadows by blocking a light's rays against rectangular occluders. Integer + Q16.16 fixed, so a scene lights identically every run and in a headless render.

View file

@ -0,0 +1,7 @@
---
id: light
title: Light
order: 33
---
A software 2D light-accumulation pass over the framebuffer, run in a render phase after drawing the scene and before <a href="screen-show"><code>Screen.show</code></a>. <a href="light-ambient"><code>Light.ambient</code></a> multiplies the whole scene toward a tint — the night/cave modulate that darkens everything so lights add mood back on top. <a href="light-point"><code>Light.point</code></a> accumulates a radial glow that falls off with distance and clamps per channel, and <a href="light-occlude"><code>Light.occlude</code></a> registers rectangles that block a light's rays to cast hard shadows (cleared each frame with <a href="light-clear_occluders"><code>Light.clear_occluders</code></a>). Lighting is a rendering concern only — it never touches game state or replays — and it is fully deterministic (integer and Q16.16 fixed), so the same scene lights identically every run and in a headless render, keeping screenshots diffable. Colours are <code>0x00RRGGBB</code>. This is the imperative surface; consuming <code>Light2D</code>/<code>Occluder</code> components automatically, normal-mapped sprites and soft shadows are planned follow-ups. Related: <a href="screen"><code>Screen</code></a>, <a href="color"><code>Color</code></a>, <a href="camera"><code>Camera</code></a>.

View file

@ -0,0 +1,30 @@
---
id: light-ambient
name: Light.ambient
category: light
kind: namespace-method
tokens: Light.ambient
sig: Light.ambient(color)
tip: Multiply the whole scene by a tint — the night/cave modulate.
order: 1
ns: Light
member: ambient
---
Multiplies every framebuffer pixel by <code>color</code> (a <code>0x00RRGGBB</code> tint), channel by channel (<code>channel * tint / 255</code>). This is the global modulate that sets mood before any light adds brightness back: a dim blue-grey gives night, a warm brown gives a cave, and <code>0xFFFFFF</code> is a no-op. Call it once, after drawing the scene and before the <a href="light-point"><code>Light.point</code></a> calls that light it back up.
Parameters:
- `color` — the ambient tint, `0x00RRGGBB` (darker/cooler = a darker scene)
```ludic
program Demo {
property Tag { v: int = 0 }
model Marker { Tag }
handler Render phase Update {
Screen.clear(Color.rgb(120, 120, 140))
Light.ambient(Color.rgb(48, 48, 72)) # dusk: dim and cool
Light.point(160, 120, 90, Color.Amber, 1.0)
Screen.show()
}
}
```

View file

@ -0,0 +1,30 @@
---
id: light-clear_occluders
name: Light.clear_occluders
category: light
kind: namespace-method
tokens: Light.clear_occluders
sig: Light.clear_occluders()
tip: Forget every occluder — call once per frame before re-registering.
order: 4
ns: Light
member: clear_occluders
---
Forgets every rectangle previously passed to <a href="light-occlude"><code>Light.occlude</code></a>. Occluders accumulate across calls and persist between frames, so a game that casts shadows calls this once at the top of its render phase and then re-registers the walls that should block light this frame — the same clear-then-rebuild rhythm as an immediate-mode draw list. With no occluders registered, <a href="light-point"><code>Light.point</code></a> lights its whole radius unobstructed.
Parameters: none.
```ludic
program Demo {
property Tag { v: int = 0 }
model Marker { Tag }
handler Render phase Update {
Screen.clear(0)
Light.clear_occluders() # start the frame with no shadows
Light.occlude(90, 60, 6, 40)
Light.point(40, 80, 100, Color.rgb(255, 255, 255), 1.0)
Screen.show()
}
}
```

View file

@ -0,0 +1,32 @@
---
id: light-occlude
name: Light.occlude
category: light
kind: namespace-method
tokens: Light.occlude
sig: Light.occlude(x, y, width, height)
tip: Register a rectangle that blocks light — a hard shadow caster.
order: 3
ns: Light
member: occlude
---
Registers an axis-aligned rectangle (screen space) that blocks light: for every <a href="light-point"><code>Light.point</code></a> that follows, any pixel whose ray from the light centre crosses this rectangle — or lies inside it — is left in shadow, carving a hard umbra behind the wall. Register the geometry that should cast shadows this frame, then draw the lights. Occluders persist until <a href="light-clear_occluders"><code>Light.clear_occluders</code></a>, so call that once per frame first; up to 64 occluders are kept.
Parameters:
- `x`, `y` — the top-left corner, in screen pixels
- `width`, `height` — the rectangle size in pixels
```ludic
program Demo {
property Wall { x: int = 0, y: int = 0 }
model Block { Wall }
handler Render phase Update {
Screen.clear(0)
Light.clear_occluders()
Light.occlude(120, 80, 8, 48) # a pillar
Light.point(60, 100, 120, Color.rgb(255, 240, 200), 1.0)
Screen.show()
}
}
```

View file

@ -0,0 +1,33 @@
---
id: light-point
name: Light.point
category: light
kind: namespace-method
tokens: Light.point
sig: Light.point(x, y, radius, color, energy)
tip: Add a radial glow that falls off with distance.
order: 2
ns: Light
member: point
---
Additively accumulates a radial point light centred at <code>(x, y)</code>. Brightness is full at the centre and falls off linearly to zero at <code>radius</code> pixels, scaled by <code>energy</code> (a <code>fixed</code> multiplier — <code>1.0</code> is full, <code>2.0</code> over-drives toward white, <code>0.5</code> is dim), and clamped per channel at 255 so overlapping lights add without wrapping. Only the light's bounding box is touched, and any <a href="light-occlude"><code>Light.occlude</code></a> rectangle between the centre and a pixel puts that pixel in shadow. Colours are <code>0x00RRGGBB</code>.
Parameters:
- `x`, `y` — the light centre, in screen pixels
- `radius` — the reach in pixels; brightness is zero at and beyond it
- `color` — the light colour, `0x00RRGGBB`
- `energy` — a `fixed` brightness multiplier (`1.0` = full)
```ludic
program Demo {
property Torch { x: int = 0, y: int = 0 }
model Lamp { Torch }
handler Render phase Update {
Screen.clear(0)
Light.ambient(Color.rgb(32, 32, 48))
Light.point(80, 60, 70, Color.rgb(255, 210, 130), 1.0) # a warm torch
Screen.show()
}
}
```

View file

@ -0,0 +1,69 @@
# lighting.ludic — the 2D light-accumulation pass: Light.ambient (scene modulate),
# Light.point (additive radial glow with linear falloff), and Light.occlude /
# Light.clear_occluders (hard shadows). Each assertion that holds prints its
# number, so a full run prints:
# 1 2 3 4 5 6 7 8 9 10 11 12 13 14
# It lights the headless framebuffer and reads pixels back with Screen.pixel, so
# it verifies the actual composited result — not just that a call compiled. All
# deterministic (integer + Q16.16 fixed). Colours are 0x00RRGGBB.
program Lighting {
property Tag { v: int = 0 }
model Marker { Tag }
handler Boot phase Start {
let WHITE = 16777215 # 0xFFFFFF
let RED = 16711680 # 0xFF0000
let BLUE = 64 # 0x000040
let GREY = 8421504 # 0x808080 (128,128,128)
# --- Light.ambient multiplies the whole scene by a tint (channel * a / 255) ---
Screen.clear(0)
Screen.put_pixel(5, 5, WHITE)
Screen.put_pixel(6, 5, 0)
Light.ambient(GREY) # white -> (128,128,128); black stays black
if Screen.pixel(5, 5) == GREY { print(1) }
if Screen.pixel(6, 5) == 0 { print(2) }
# --- Light.point adds a radial glow, brightest at the centre, 0 at the radius ---
Screen.clear(0)
Light.point(50, 50, 20, WHITE, 1.0)
if Screen.pixel(50, 50) == WHITE { print(3) } # centre: full brightness
if (Screen.pixel(60, 50) & 255) == 127 { print(4) } # d=10: 255*10/20 = 127
if Screen.pixel(75, 50) == 0 { print(5) } # d=25 > radius: untouched
# --- falloff is monotonic: nearer pixels are brighter ---
if (Screen.pixel(55, 50) & 255) > (Screen.pixel(65, 50) & 255) { print(6) }
if (Screen.pixel(55, 50) & 255) == 191 { print(7) } # d=5: 255*15/20 = 191
if (Screen.pixel(65, 50) & 255) == 63 { print(8) } # d=15: 255*5/20 = 63
# --- energy scales brightness (0.5 halves the centre) ---
Screen.clear(0)
Light.point(50, 50, 20, WHITE, 0.5)
if (Screen.pixel(50, 50) & 255) == 127 { print(9) } # 255 * 0.5 = 127
# --- lights accumulate additively, clamped per channel at 255 ---
Screen.clear(0)
Light.point(50, 50, 20, BLUE, 1.0)
Light.point(50, 50, 20, BLUE, 1.0)
if (Screen.pixel(50, 50) & 255) == 128 { print(10) } # 64 + 64
Screen.clear(0)
Light.point(50, 50, 20, RED, 2.0) # 255 * 2 -> clamp 255
if ((Screen.pixel(50, 50) >> 16) & 255) == 255 { print(11) }
# --- Light.occlude casts a hard shadow: a wall blocks the ray behind it ---
Screen.clear(0)
Light.clear_occluders()
Light.occlude(60, 45, 4, 10) # a thin wall just right of the light
Light.point(50, 50, 40, WHITE, 1.0)
if Screen.pixel(75, 50) == 0 { print(12) } # behind the wall: in shadow
if (Screen.pixel(25, 50) & 255) != 0 { print(13) } # opposite side: still lit
# --- clearing occluders reopens the light ---
Screen.clear(0)
Light.clear_occluders()
Light.point(50, 50, 40, WHITE, 1.0)
if Screen.pixel(75, 50) != 0 { print(14) } # no wall now: lit again
}
handler Run phase Update { quit() }
}

180
runtime/native/light.ludic Normal file
View file

@ -0,0 +1,180 @@
# ============================================================================
# light.ludic — 2D light accumulation over the framebuffer, in Ludic.
#
# The Light.* namespace (see emit_call.ludic) is a software light pass a game
# runs in its render phase, after drawing the scene and before Screen.show:
#
# Screen.clear(0); draw the world ...
# Light.ambient(0x303040) # night: multiply the scene down
# Light.clear_occluders()
# Light.occlude(wall_x, wall_y, w, h) # geometry that blocks light
# Light.point(torch_x, torch_y, 90, Color.Amber, 1.0) # add a glow
# Screen.show()
#
# It owns the framebuffer end to end (rt_fb in core.ludic), so lighting is a
# rendering concern only — it never touches game state, and it is fully
# deterministic (integer + Q16.16 fixed): the same scene lights identically on
# every run and in a headless render, so screenshots stay diffable.
#
# ludicc splices this file into a game via core.ludic (it reads/writes the
# framebuffer), so it links only where the renderer does.
#
# Tiers shipped here: (1) ambient modulate + additive radial point lights, and
# (2) hard shadows — a point light is blocked along any segment that crosses a
# registered rectangular occluder. Normal-mapped sprites, soft shadows and a
# day/night directional light (issue #4 tiers 3-4), and consuming Light2D /
# Occluder *components* automatically, are follow-ups: the auto-consumption
# needs an engine-owned system over user components, the same ECS hook issue #43
# tracks. Until then Light.* is the imperative escape hatch the proposal names.
# ============================================================================
# ---- occluder store -------------------------------------------------------
# Up to 64 rectangular occluders, stored flat as (x, y, w, h) i32 quads. A game
# clears them each frame (Light.clear_occluders) and re-registers the geometry
# that should cast shadows this frame.
var rt_light_occ: words = null # occluder rects: 4 i32 each — x, y, w, h
var rt_light_occ_n: int = 0 # number of occluders currently stored
function light_occ_init() -> void {
if rt_light_occ == null { rt_light_occ = words(64 * 4) }
}
# forget every occluder — call once per frame before re-registering geometry.
function light_clear_occluders() -> void { rt_light_occ_n = 0 }
# register a rectangular shadow caster (screen space). Silently ignored past 64.
function light_occlude(x: int, y: int, w: int, h: int) -> void {
light_occ_init()
if rt_light_occ_n >= 64 { return }
let i = rt_light_occ_n * 4
rt_light_occ[i] = x
rt_light_occ[i + 1] = y
rt_light_occ[i + 2] = w
rt_light_occ[i + 3] = h
rt_light_occ_n = rt_light_occ_n + 1
}
# ---- shadow geometry ------------------------------------------------------
# Orientation of point c relative to the directed segment a->b: 1 = left/ccw,
# -1 = right/cw, 0 = colinear. Pure integer; screen coordinates keep the cross
# product well inside i32.
function light_orient(ax: int, ay: int, bx: int, by: int, cx: int, cy: int) -> int {
let v = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)
if v > 0 { return 1 }
if v < 0 { return 0 - 1 }
return 0
}
# do segments a-b and c-d straddle each other (proper crossing)? Colinear
# touching is treated as no-cross — negligible for a light pass.
function light_seg_cross(ax: int, ay: int, bx: int, by: int, cx: int, cy: int, dx: int, dy: int) -> bool {
let d1 = light_orient(cx, cy, dx, dy, ax, ay)
let d2 = light_orient(cx, cy, dx, dy, bx, by)
let d3 = light_orient(ax, ay, bx, by, cx, cy)
let d4 = light_orient(ax, ay, bx, by, dx, dy)
if (d1 != d2) and (d3 != d4) { return true }
return false
}
function light_pt_in_rect(px: int, py: int, rx: int, ry: int, rw: int, rh: int) -> bool {
return (px >= rx) and (py >= ry) and (px < rx + rw) and (py < ry + rh)
}
# is the segment from light (lx,ly) to pixel (px,py) blocked by any occluder?
# A pixel inside an occluder is in shadow; otherwise the ray is blocked if it
# crosses any of the rectangle's four edges.
function light_blocked(lx: int, ly: int, px: int, py: int) -> bool {
var k = 0
while k < rt_light_occ_n {
let i = k * 4
let rx = rt_light_occ[i]
let ry = rt_light_occ[i + 1]
let rw = rt_light_occ[i + 2]
let rh = rt_light_occ[i + 3]
if light_pt_in_rect(px, py, rx, ry, rw, rh) { return true }
let x0 = rx
let y0 = ry
let x1 = rx + rw
let y1 = ry + rh
if light_seg_cross(lx, ly, px, py, x0, y0, x1, y0) { return true } # top
if light_seg_cross(lx, ly, px, py, x1, y0, x1, y1) { return true } # right
if light_seg_cross(lx, ly, px, py, x1, y1, x0, y1) { return true } # bottom
if light_seg_cross(lx, ly, px, py, x0, y1, x0, y0) { return true } # left
k = k + 1
}
return false
}
# integer square root (Newton) — 0..sqrt(n). Deterministic, overflow-safe for
# any screen-scale radius (unlike a fixed(radius^2) that would wrap past ~181px).
function light_isqrt(n: int) -> int {
if n <= 0 { return 0 }
var x = n
var y = (x + 1) / 2
while y < x {
x = y
y = (x + n / x) / 2
}
return x
}
# ---- the light pass -------------------------------------------------------
# Multiply the whole scene by an ambient tint (0x00RRGGBB): the CanvasModulate
# that gives a night/cave mood before any light adds brightness back. Ambient
# 0xFFFFFF is a no-op; darker/colored tints dim and gel the scene.
function light_ambient(color: int) -> void {
let ar = (color >> 16) & 255
let ag = (color >> 8) & 255
let ab = color & 255
let n = rt_fbw * rt_fbh
var i = 0
while i < n {
let cur = rt_fb[i]
let nr = (((cur >> 16) & 255) * ar) / 255
let ng = (((cur >> 8) & 255) * ag) / 255
let nb = ((cur & 255) * ab) / 255
rt_fb[i] = (nr << 16) | (ng << 8) | nb
i = i + 1
}
}
# Additively accumulate a radial point light centred at (cx,cy) with the given
# radius (px), colour (0x00RRGGBB) and energy (a Q16.16 fixed multiplier; 1.0 is
# full). Brightness falls off linearly with distance to zero at the radius, is
# clamped per channel at 255, and is cut where a registered occluder blocks the
# ray — a hard shadow. Only the light's bounding box is touched.
function light_point(cx: int, cy: int, radius: int, color: int, energy: fixed) -> void {
if radius <= 0 { return }
let lr = (color >> 16) & 255
let lg = (color >> 8) & 255
let lb = color & 255
var py = cy - radius
while py <= cy + radius {
if (py >= 0) and (py < rt_fbh) {
var px = cx - radius
while px <= cx + radius {
if (px >= 0) and (px < rt_fbw) {
let ddx = px - cx
let ddy = py - cy
let d = light_isqrt(ddx * ddx + ddy * ddy)
if d < radius {
if not light_blocked(cx, cy, px, py) {
let fall = radius - d # linear falloff, 0..radius
let br = lr * fall / radius # channel * falloff, 0..255
let bg = lg * fall / radius
let bb = lb * fall / radius
let idx = py * rt_fbw + px
let cur = rt_fb[idx]
let nr = min(255, ((cur >> 16) & 255) + floor(fixed(br) * energy))
let ng = min(255, ((cur >> 8) & 255) + floor(fixed(bg) * energy))
let nb = min(255, (cur & 255) + floor(fixed(bb) * energy))
rt_fb[idx] = (nr << 16) | (ng << 8) | nb
}
}
}
px = px + 1
}
}
py = py + 1
}
}

View file

@ -238,6 +238,16 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
if (meth == "flood") { bare = "grid_flood"; push(labels, "x"); push(labels, "y"); push(labels, "wall") } if (meth == "flood") { bare = "grid_flood"; push(labels, "x"); push(labels, "y"); push(labels, "wall") }
if (meth == "a_star") { bare = "path_a_star"; push(labels, "x0"); push(labels, "y0"); push(labels, "x1"); push(labels, "y1"); push(labels, "wall") } if (meth == "a_star") { bare = "path_a_star"; push(labels, "x0"); push(labels, "y0"); push(labels, "x1"); push(labels, "y1"); push(labels, "wall") }
} }
# Light.* — the 2D light-accumulation pass (runtime/native/light.ludic, spliced
# on demand). A game runs it in its render phase: ambient multiplies the scene
# down, point adds a radial glow (blocked by occluders -> hard shadows). Screen
# space, deterministic (integer + Q16.16), diffable. `energy` is a fixed.
if (ns == "Light") {
if (meth == "ambient") { bare = "light_ambient"; push(labels, "color") }
if (meth == "point") { bare = "light_point"; push(labels, "x"); push(labels, "y"); push(labels, "radius"); push(labels, "color"); push(labels, "energy") }
if (meth == "occlude") { bare = "light_occlude"; push(labels, "x"); push(labels, "y"); push(labels, "width"); push(labels, "height") }
if (meth == "clear_occluders") { bare = "light_clear_occluders" }
}
# Query.* — ECS spatial queries over the reflection ABI (runtime/native/query.ludic, # Query.* — ECS spatial queries over the reflection ABI (runtime/native/query.ludic,
# spliced on demand). `prop` is a property id (World.prop_id); the spatial forms # spliced on demand). `prop` is a property id (World.prop_id); the spatial forms
# read two int fields (field ids) as (x, y). nearest/first return an entity (-1 = # read two int fields (field ids) as (x, y). nearest/first return an entity (-1 =

View file

@ -164,6 +164,7 @@ function p_postfix() -> Node {
if e.a.kind == E_ID and e.a.s == "Regex" { g_uses_regex = true } # splice the regex runtime on demand if e.a.kind == E_ID and e.a.s == "Regex" { g_uses_regex = true } # splice the regex runtime on demand
if e.a.kind == E_ID and e.a.s == "Query" { g_uses_query = true } # splice the ECS spatial-query runtime on demand if e.a.kind == E_ID and e.a.s == "Query" { g_uses_query = true } # splice the ECS spatial-query runtime on demand
if e.a.kind == E_ID and e.a.s == "Reflect" { g_uses_reflect = true } # force-emit the reflection ABI (Reflect.* reads the world schema) if e.a.kind == E_ID and e.a.s == "Reflect" { g_uses_reflect = true } # force-emit the reflection ABI (Reflect.* reads the world schema)
if e.a.kind == E_ID and e.a.s == "Light" { g_uses_light = true } # splice the 2D light-accumulation pass on demand
} }
else { if is_op("[") { pi = pi + 1; let lo = expr() else { if is_op("[") { pi = pi + 1; let lo = expr()
if is_op("..") { pi = pi + 1; let sl = node(E_SLICE); sl.a = e; sl.b = lo; sl.c = expr(); eat_op("]"); e = sl } # s[a..b] substring if is_op("..") { pi = pi + 1; let sl = node(E_SLICE); sl.a = e; sl.b = lo; sl.c = expr(); eat_op("]"); e = sl } # s[a..b] substring
@ -378,6 +379,7 @@ var cur_dir: pointer
var g_uses_regex: bool = false # a program mentioned Regex.* -> splice the regex runtime var g_uses_regex: bool = false # a program mentioned Regex.* -> splice the regex runtime
var g_uses_query: bool = false # a program mentioned Query.* -> splice the query runtime + reflection ABI var g_uses_query: bool = false # a program mentioned Query.* -> splice the query runtime + reflection ABI
var g_uses_reflect: bool = false # a program mentioned Reflect.* -> force-emit the reflection ABI var g_uses_reflect: bool = false # a program mentioned Reflect.* -> force-emit the reflection ABI
var g_uses_light: bool = false # a program mentioned Light.* -> splice the 2D light pass
function already_loaded(full: pointer) -> bool { function already_loaded(full: pointer) -> bool {
var i = 0 var i = 0
@ -537,6 +539,13 @@ function maybe_splice_runtime() -> void {
do_import("runtime/native/query.ludic") do_import("runtime/native/query.ludic")
cur_dir = saved cur_dir = saved
} }
# any program that uses Light.* gets the 2D light-accumulation pass spliced in;
# it reads and writes the framebuffer (rt_fb), so it links with core.ludic.
if g_uses_light {
cur_dir = ""
do_import("runtime/native/light.ludic")
cur_dir = saved
}
} }
function parse_program() -> void { function parse_program() -> void {
@ -557,6 +566,7 @@ function parse_program() -> void {
g_uses_regex = false g_uses_regex = false
g_uses_query = false g_uses_query = false
g_uses_reflect = false g_uses_reflect = false
g_uses_light = false
loaded_paths = new []pointer loaded_paths = new []pointer
skipnl() skipnl()
g_game_name = "Ludic" g_game_name = "Ludic"

File diff suppressed because it is too large Load diff

View file

@ -108,6 +108,7 @@ function cmd_test() -> int {
feat_case("library/query", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "query.ludic (Query count/first/nearest/within — ECS spatial queries over the reflection ABI)") feat_case("library/query", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "query.ludic (Query count/first/nearest/within — ECS spatial queries over the reflection ABI)")
feat_case("library/reflect", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20", "reflect.ludic (Reflect prop/field enumeration + type + get/set/has/kind — runtime reflection over the world schema)") feat_case("library/reflect", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20", "reflect.ludic (Reflect prop/field enumeration + type + get/set/has/kind — runtime reflection over the world schema)")
feat_case("library/render", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "render.ludic (Screen pixel/oval/camera/clip/blend_mode/measure_text + Camera set/follow/shake, verified by pixel readback)") feat_case("library/render", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "render.ludic (Screen pixel/oval/camera/clip/blend_mode/measure_text + Camera set/follow/shake, verified by pixel readback)")
feat_case("library/lighting", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "lighting.ludic (Light ambient/point radial falloff + occluder hard shadows — 2D light accumulation, verified by pixel readback)")
feat_case("library/logging", "", "0 5 2 1", "logging.ludic (Log levels, set_level/level threshold, structured fields)") feat_case("library/logging", "", "0 5 2 1", "logging.ludic (Log levels, set_level/level threshold, structured fields)")
# Os known-folders/arch and Fs.list read the BSD utsname/dirent layout, so # Os known-folders/arch and Fs.list read the BSD utsname/dirent layout, so
# their asserted values are macOS-specific; skip off Darwin (see is_darwin). # their asserted values are macOS-specific; skip off Darwin (see is_darwin).