Merge branch 'lang/foundations' into r3d/fog-wall

This commit is contained in:
Orkun ÇAKILKAYA 2026-09-29 17:43:14 +03:00
commit f8d811aa97
78 changed files with 43349 additions and 40047 deletions

View file

@ -0,0 +1,8 @@
bump: minor
type: feature
**Textures a game baked at build time are read before the PNG.** `png_decode` first takes
`assets/baked/png/<path>.tex` (a game's `ludic bake` output: the samples, ready to upload), and a cut-out
load (`tex_load_ex` with dilate) first takes `assets/baked/cutouts/<path>.bc7` (padded as `tex_dilate`
pads, then BC7 with its mips) - so a boot decodes, pads and converts nothing it can take ready-made. Both
are ludic.base's baked form, read by hand (baked_tex.ludic: the "LBAK" header, the key and version); a
missing or stale one falls back to the PNG as before. `tex_load_dds_at` reads a .dds at an offset.

View file

@ -0,0 +1,8 @@
bump: patch
type: performance
**Cut-out edge padding runs on every core.** `tex_dilate`'s passes hand their rows, sixteen at a time, to
`Job.parallel_for`: within a pass a row writes only its own still-masked texels and reads only
neighbours the mask already let go, so the bytes are the ones the single-threaded loop made. The worker
is handed plain buffers in a `DilateJob` and makes nothing. `tex_dilate_bytes` is the slice-taking
form (safe_api.ludic), and `examples/rendering/dilate.ludic` holds the result against the old loop
(prints DILATE OK). It was 206 ms of the main thread in a Maroon Lake boot.

View file

@ -0,0 +1,8 @@
bump: minor
type: feature
**Inflate in a caller-owned context.** `ZInflate` holds everything one inflate works in - the bit reader,
the RFC's length and distance tables, and every Huffman table and scratch list a block builds - made once
by `z_inflate_new()` and rebuilt in place, so an inflate allocates nothing and each thread that inflates
holds a context of its own. `z_inflate_in(z, src, len, out, cap)`, `z_uncompress_in` (zlib) and
`z_gunzip_in` (gzip) take the context; `z_inflate` / `z_uncompress` / `z_gunzip` keep their signatures,
working in the one context `RtInflateState` holds, so no caller changes.

10
changes/zones.md Normal file
View file

@ -0,0 +1,10 @@
bump: minor
type: feature
**`ludic.zones`: where a world is simulated.** A disc per player, `(x, z, r)` with `r = zones_radius(fog)`
(the fog distance plus 40 m, never under 150 m, 900 m with no fog), overlapping discs merged into
clusters with a bounding circle, and `zones_contains` / `zones_nearest` for everything that asks; fixed
arrays, nothing made per frame. The packages that simulate now ask their world port whether a place is
simulated, with a default of everywhere: `WildlifeWorld.active` (an animal outside is frozen - its
state kept, no dice drawn), `NpcWorld.active` (a walker outside is frozen, and nobody is born outside),
`VehicleWorld.active` (a moored boat outside is left where it lies) and `ThingsWorld.restocks` (a
morning restocks only inside).

View file

@ -0,0 +1,27 @@
# ui_first_show.ludic - a component shown for the first time once the fence is judging (a prompt's key
# hint mid-walk) makes its instance's props and model: declared, once per instance, and the failing
# fence lets it through; hidden and shown again, the instance is reused and nothing more is made:
# bad 0 shown 3
import "ludic.ui"
import "ui_first_show_parts/Cell.ludic"
import "ui_first_show_parts/Host.ludic"
program UiFirstShow {
numbers float
function frame(ui_st: mut UiState) -> UiNode {
let root: UiNode = ui_nodes(ui_st, "Host", null)
ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0)
return root
}
entry (ui_st: mut UiState) {
var shown = 0
for f in 0 .. 1400 {
let root = frame(ui_st)
if f == 700 or f == 900 or f == 1000 or f == 1100 or f == 1200 {
ui_press(ui_st, root.children[0].children[0])
if f != 900 and f != 1100 { shown += 1 }
}
Mem.frame()
}
print(`bad {Mem.bad_frames()} shown {shown}`)
}
}

View file

@ -0,0 +1 @@
.cell { width: 100px; height: 40px }

View file

@ -0,0 +1,3 @@
component Cell {
prop caption: string = ""
}

View file

@ -0,0 +1,3 @@
<div class="item">
<button class="cell" on-click="emit click"><p>{caption}</p></button>
</div>

View file

@ -0,0 +1 @@
.t { width: 60px; height: 30px }

View file

@ -0,0 +1,4 @@
component Host {
state on: bool = false
on flip() { on = not on }
}

View file

@ -0,0 +1,4 @@
<div>
<button class="t" on-click="flip()">t</button>
<if test="{on}"><Cell caption="a"/></if>
</div>

View file

@ -0,0 +1,55 @@
# ui_select_disabled.ludic - <option disabled="{...}">: a value on a disabled option is set to the
# nearest enabled one, the arrow toward disabled options is drawn disabled, and the arrows step past
# them (a host's cap on the fog): clamped 2 next-on 0 next 0 prev 2
import "ludic.ui"
program UiSelectDisabled {
numbers float
state SelState { v: int = 4 }
view V (sel_st: mut SelState) {
v = sel_st.v
on set(x: int) { sel_st.v = x }
}
const PAGE: string = "<ui><screen name='s'><select label='Fog' value='{v}' on-change='set(event.value)'><option value='{0}'>0</option><option value='{1}'>1</option><option value='{2}'>2</option><option value='{3}' disabled='{v >= 0}'>3</option><option value='{4}' disabled='{true}'>4</option></select></screen></ui>"
function frame(ui_st: mut UiState, x: float, y: float, down: bool) -> UiNode {
let i = new UiInput
i.x = x
i.y = y
i.down = down
ui_input(ui_st, i)
ui_show(ui_st, "s", view_v(), 0.0, 0.0, 400.0, 400.0)
let root: UiNode = ui_nodes(ui_st, "s", view_v())
ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0)
return root
}
function part(n: UiNode, cls: string) -> UiNode {
for i in 0 .. len(n.children) {
if List.contains(n.children[i].classes, cls) { return n.children[i] }
let f = part(n.children[i], cls)
if f != null { return f }
}
return null
}
function click(ui_st: mut UiState, cls: string) -> void {
let root = frame(ui_st, -1.0, -1.0, false)
let p = part(root, cls)
let x = p.x + p.cw / 2.0
let y = p.y + p.ch / 2.0
frame(ui_st, x, y, false)
frame(ui_st, x, y, true)
frame(ui_st, x, y, false)
frame(ui_st, x, y, false)
}
entry (ui_st: mut UiState, sel_st: SelState) {
ui_backend(ui_st, new UiBackend)
ui_load_text(ui_st, PAGE, "s.xml")
frame(ui_st, -1.0, -1.0, false)
let root = frame(ui_st, -1.0, -1.0, false)
let clamped = sel_st.v
let nx = part(root, "ui-next")
let at_cap = `{nx.enabled}`
click(ui_st, "ui-next")
let wrapped = sel_st.v
click(ui_st, "ui-prev")
print(`clamped {clamped} next-on {at_cap} next {wrapped} prev {sel_st.v}`)
}
}

View file

@ -0,0 +1,77 @@
# dilate.ludic - render3d's cut-out padding, now a pass's rows on every core, gives exactly the
# bytes the one-thread version gave: the old loop is kept here as the oracle, over a patterned atlas
#
# bin/ludic build examples/rendering/dilate.ludic --headless && ./build/dilate_headless (prints DILATE OK)
program Dilate {
numbers float
import "ludic.render3d/r3d.ludic"
# the dilate as it was, one row after another
function oracle(px: []byte, w: int, h: int, c: int, thresh: int, passes: int) -> void {
let mask = buffer(w * h)
for i in 0 .. w * h { let o = i * c; if px[o] + px[o + 1] + px[o + 2] < thresh { mask[i] = 1 } else { mask[i] = 0 } }
let next = buffer(w * h)
for pass in 0 .. passes {
for q in 0 .. w * h { next[q] = mask[q] }
for y in 0 .. h {
for x in 0 .. w {
let k = y * w + x
if mask[k] == 1 {
var r = 0; var g = 0; var b = 0; var n = 0
if x > 0 and mask[k - 1] == 0 { let o = (k - 1) * c; r += px[o]; g += px[o + 1]; b += px[o + 2]; n += 1 }
if x < w - 1 and mask[k + 1] == 0 { let o = (k + 1) * c; r += px[o]; g += px[o + 1]; b += px[o + 2]; n += 1 }
if y > 0 and mask[k - w] == 0 { let o = (k - w) * c; r += px[o]; g += px[o + 1]; b += px[o + 2]; n += 1 }
if y < h - 1 and mask[k + w] == 0 { let o = (k + w) * c; r += px[o]; g += px[o + 1]; b += px[o + 2]; n += 1 }
if n > 0 { let o = k * c; px[o] = r / n; px[o + 1] = g / n; px[o + 2] = b / n; next[k] = 0 }
}
}
}
for q in 0 .. w * h { mask[q] = next[q] }
}
}
# blades and blobs of colour on black, different in every row and column
function atlas(w: int, h: int, c: int) -> []byte {
let px = buffer(w * h * c)
for y in 0 .. h {
for x in 0 .. w {
let o = (y * w + x) * c
let lit = ((x * 7 + y * 13) % 29 < 6) or ((x / 9 + y / 11) % 5 == 0)
for k in 0 .. c { px[o + k] = 0 }
if lit {
px[o] = (x * 3 + y) % 256
px[o + 1] = (x + y * 5) % 256
px[o + 2] = (x * y) % 256
}
if c == 4 { px[o + 3] = 255 }
}
}
return px
}
handler Boot(render3d_st: mut Render3dState) phase Start {
var ok = true
for c in 3 .. 5 {
let w = 173
let h = 101
let a = atlas(w, h, c)
let b = atlas(w, h, c)
render3d_st.tex_w = w
render3d_st.tex_h = h
render3d_st.tex_channels = c
render3d_st.tex_depth = 8
tex_dilate_bytes(render3d_st, a, 60, 24)
oracle(b, w, h, c, 60, 24)
let orig = atlas(w, h, c)
var same = 0
var moved = 0
for i in 0 .. w * h * c {
if a[i] == b[i] { same += 1 }
if a[i] != orig[i] { moved += 1 }
}
if same != w * h * c or moved == 0 { ok = false } # the same bytes, and the padding did pad
}
if ok { print("DILATE OK") } else { print("DILATE FAILED") }
quit()
}
}

View file

@ -22,6 +22,7 @@ section. The rules are in [ludic.base](ludic.base/README.md).
| [ludic.crafting](ludic.crafting/README.md) | recipes: what goes in and out, where and how long, making, refunding, hold-to-make |
| [ludic.effects](ludic.effects/README.md) | timed modifiers that run down in game time (a meal's warmth, a drink's legs) |
| [ludic.fire](ludic.fire/README.md) | a camp fire: fuel, the rain on it, warmth at a distance, whether it will cook |
| [ludic.zones](ludic.zones/README.md) | where the world is simulated: a disc per player from their fog, merged into clusters |
| [ludic.fishing](ludic.fishing/README.md) | a rod at the water: cast, bite, the reel's fight, the landing; species as an open registry |
| [ludic.hints](ludic.hints/README.md) | a rail of what is true now, most urgent first, each a card that teaches it and can be muted by key |
| [ludic.i18n](ludic.i18n/README.md) | a game in any language: gettext `.po` files, patterns with holes, plurals, a mod folder, a font per language |

View file

@ -0,0 +1,66 @@
# baked.ludic - what is deterministic made at build time (`ludic bake`) by the game's own code, and
# read at run time. Every baked file: a 32-byte header - "LBAK", the header's format, the bake's
# generator version, the payload's offset, a hash of its inputs, the payload's length - its key, and
# the payload, whose layout is the bake's own. The runtime checks the header, never the inputs:
# `ludic bake --check` holds a baked file to its inputs at build time.
export const BAKE_FORMAT: int = 1
const BAKE_HEAD: int = 32
export state BakeState {
said: []string = new []string # the keys a dev build has said it made at run time
}
# a map's bake: assets/baked/maps/<map>/<file>, which ships in that map's own pack (.lmap), never the
# game's; anything else: assets/baked/<file>, in the game's pack
export function baked_path(map: string, file: string) -> string {
if len(map) == 0 { return "assets/baked/" + file }
return "assets/baked/maps/" + map + "/" + file
}
# the payload of a baked file that is this bake at this version, or null (missing, not a baked file,
# another format, another key or version: the caller makes it at run time)
export function baked_open(path: string, key: string, version: int) -> []byte {
let b = Fs.read_bytes(path)
if b == null or len(b) < BAKE_HEAD { return null }
let off = bk_head_ok(b, len(b), key, version)
if off < 0 or long(off) + bk_u64(b, 24) > long(len(b)) { return null }
return view(b, off, int(bk_u64(b, 24))) # the payload, in place: no copy of a large bake
}
# the payload's offset when `b` (its first `have` bytes) is this bake's header and key, else -1
function bk_head_ok(b: []byte, have: int, key: string, version: int) -> int {
if have < BAKE_HEAD or b[0] != 76 or b[1] != 66 or b[2] != 65 or b[3] != 75 { return -1 } # "LBAK"
if bk_u32(b, 4) != BAKE_FORMAT or bk_u32(b, 8) != version { return -1 }
let off = bk_u32(b, 12)
if off < BAKE_HEAD + len(key) + 1 or have < BAKE_HEAD + len(key) + 1 { return -1 }
for i in 0 .. len(key) { if b[BAKE_HEAD + i] != key[i] { return -1 } }
if b[BAKE_HEAD + len(key)] != 0 { return -1 }
return off
}
# a dev build's line, once a key: the bake is missing or stale, so it is made at run time
export function bake_missing(bake_st: mut BakeState, key: string) -> void {
for i in 0 .. len(bake_st.said) { if bake_st.said[i] == key { return } }
push(bake_st.said, key)
print(`bake: {key} missing or stale - made at run time (ludic bake)`)
}
# a baked file written: its header, key and `n` bytes of payload; the directories made
export function bake_write(path: string, key: string, version: int, inputs_hash: long, payload: []byte, n: int) -> bool {
let off = BAKE_HEAD + ((len(key) + 1 + 7) / 8) * 8
let b = buffer(off + n)
b[0] = 76
b[1] = 66
b[2] = 65
b[3] = 75
bk_put32(b, 4, BAKE_FORMAT)
bk_put32(b, 8, version)
bk_put32(b, 12, off)
bk_put64(b, 16, inputs_hash)
bk_put64(b, 24, long(n))
for i in 0 .. len(key) { b[BAKE_HEAD + i] = key[i] }
for i in 0 .. n { b[off + i] = payload[i] }
let slash = bk_last_slash(path)
if slash > 0 { Fs.mkdir(path[0 .. slash]) }
return Fs.write_bytes(path, b, off + n)
}

View file

@ -0,0 +1,40 @@
# baked_hash.ludic - a bake's inputs as one number, and the header's little-endian fields. The hash
# is FNV-1a 64 over each input in order: its path's bytes, a 0, then its bytes (tools/bake/baked.py
# and the check compute the same)
const BK_FNV_OFFSET: long = -3750763034362895579 # 0xcbf29ce484222325
const BK_FNV_PRIME: long = 1099511628211
# `inputs` space-separated, as a Bakes row lists them; a missing input hashes as its path alone
export function bake_inputs_hash(inputs: string) -> long {
var h = BK_FNV_OFFSET
var a = 0
while a < len(inputs) {
while a < len(inputs) and inputs[a] == 32 { a += 1 }
var e = a
while e < len(inputs) and inputs[e] != 32 { e += 1 }
if e > a {
let p = inputs[a .. e]
for i in 0 .. len(p) { h = bk_fnv(h, p[i]) }
h = bk_fnv(h, 0)
let b = Fs.read_bytes(p)
if b != null { for i in 0 .. len(b) { h = bk_fnv(h, b[i]) } }
}
a = e
}
return h
}
function bk_fnv(h: long, c: int) -> long { return (h ^ long(c & 255)) * BK_FNV_PRIME }
function bk_u32(b: []byte, at: int) -> int { return (b[at] & 255) | ((b[at + 1] & 255) << 8) | ((b[at + 2] & 255) << 16) | ((b[at + 3] & 255) << 24) }
function bk_u64(b: []byte, at: int) -> long { return long(bk_u32(b, at)) & 4294967295 | (long(bk_u32(b, at + 4)) << 32) }
function bk_put32(b: []byte, at: int, v: int) -> void {
for i in 0 .. 4 { b[at + i] = (v >> (i * 8)) & 255 }
}
function bk_put64(b: []byte, at: int, v: long) -> void {
for i in 0 .. 8 { b[at + i] = int((v >> long(i * 8)) & 255) }
}
function bk_last_slash(p: string) -> int {
var k = -1
for i in 0 .. len(p) { if p[i] == 47 { k = i } }
return k
}

View file

@ -0,0 +1,72 @@
# baked_stream.ludic - a large bake read a piece at a time (placements by chunk, terrain by tile): the
# header checked once, the file kept open, and each read into the caller's own buffer at a payload
# offset, so a ring streaming chunks in makes nothing per chunk. Pack-aware (file_open)
export property BakedFile {
f: pointer = null
off: int = 0 # where the payload starts in the file
n: long = 0 # the payload's length
}
# this bake's file kept open for reading, or null (missing, or another format, key or version)
@alloc_ok("once per baked file opened: its header and record")
export function baked_open_range(path: string, key: string, version: int) -> BakedFile {
let f = file_open(path, "rb")
if f == null { return null }
let want = 32 + len(key) + 1
let h = buffer(want)
if file_read(f, h, want) != want or bk_head_ok(h, want, key, version) < 0 {
file_close(f)
return null
}
let bf = new BakedFile
bf.f = f
bf.off = bk_u32(h, 12)
bf.n = bk_u64(h, 24)
return bf
}
# `n` bytes from payload offset `at` into `into` (from its start): the count read, 0 on a failure;
# held to the buffer and to the payload
export function baked_read(bf: BakedFile, at: int, n: int, into: []byte) -> int {
if bf == null or bf.f == null or at < 0 or n <= 0 { return 0 }
var k = n
if k > len(into) { k = len(into) }
if long(at) + long(k) > bf.n { k = int(bf.n - long(at)) }
if k <= 0 { return 0 }
if file_seek(bf.f, bf.off + at, 0) != 0 { return 0 }
return file_read(bf.f, into, k)
}
export function baked_len(bf: BakedFile) -> long {
if bf == null { return 0 }
return bf.n
}
export function baked_close(bf: BakedFile) -> void {
if bf == null or bf.f == null { return }
file_close(bf.f)
bf.f = null
}
# what a baked file says it is, its header alone read (the stale check: key, version, inputs hash)
export property BakedHead {
key: string = ""
version: int = 0
inputs: long = 0
}
@alloc_ok("the stale check: once a baked file")
export function baked_head(path: string) -> BakedHead {
let f = file_open(path, "rb")
if f == null { return null }
let h = buffer(288)
let got = file_read(f, h, 288)
file_close(f)
if got < 32 or h[0] != 76 or h[1] != 66 or h[2] != 65 or h[3] != 75 or bk_u32(h, 4) != BAKE_FORMAT { return null }
var e = 32
while e < got and h[e] != 0 { e += 1 }
let r = new BakedHead
r.key = intern(text_of(view(h, 32, e - 32), e - 32))
r.version = bk_u32(h, 8)
r.inputs = bk_u64(h, 16)
return r
}

View file

@ -10,6 +10,9 @@ import "queue_ring.ludic"
import "text_buf.ludic"
import "text_intern.ludic"
import "text_ring.ludic"
import "baked.ludic"
import "baked_hash.ludic"
import "baked_stream.ludic"
import "rng.ludic"
import "save.ludic"
import "save_fields.ludic"

View file

@ -0,0 +1,47 @@
# baked_test.ludic - a baked file round-trips its payload; another version or key reads as null; the
# inputs hash is FNV-1a 64 over path, 0 and bytes (tools/bake/baked.py computes the same)
import "ludic.base"
program BakedTest {
function dir() -> string { return Os.temp_dir() + "/ludic-baked-test" }
test "a baked file's payload comes back, for its key and version only" {
let p = buffer(5)
for i in 0 .. 5 { p[i] = 65 + i }
let path = dir() + "/a/b.bin"
expect(bake_write(path, "tiles", 3, 42, p, 5))
let b = baked_open(path, "tiles", 3)
expect(b != null)
expect_eq(len(b), 5)
expect_eq(b[4], 69)
expect(baked_open(path, "tiles", 4) == null)
expect(baked_open(path, "tile", 3) == null)
expect(baked_open(dir() + "/none.bin", "tiles", 3) == null)
}
test "a streamed bake reads a piece of its payload at an offset into the caller's buffer" {
let p = buffer(10)
for i in 0 .. 10 { p[i] = 48 + i }
let path = dir() + "/s.bin"
expect(bake_write(path, "chunks", 1, 0, p, 10))
let bf = baked_open_range(path, "chunks", 1)
expect(bf != null)
expect(baked_len(bf) == 10)
let into = buffer(4)
expect_eq(baked_read(bf, 6, 4, into), 4)
expect_eq(into[0], 54)
expect_eq(baked_read(bf, 8, 4, into), 2)
baked_close(bf)
expect(baked_open_range(path, "chunks", 2) == null)
}
test "the inputs hash follows the path and the bytes" {
Fs.write_text(dir() + "/in.txt", "abc")
let h1 = bake_inputs_hash(dir() + "/in.txt")
Fs.write_text(dir() + "/in.txt", "abd")
expect(bake_inputs_hash(dir() + "/in.txt") != h1)
expect(bake_inputs_hash("") == -3750763034362895579)
}
test "a dev build says a missing bake once" (bake_st: mut BakeState) {
bake_missing(bake_st, "k")
bake_missing(bake_st, "k")
expect_eq(len(bake_st.said), 1)
}
}

View file

@ -10,3 +10,4 @@ import "build.ludic"
import "query.ludic"
import "crowd.ludic"
import "resident.ludic"
import "resident_count.ludic"

View file

@ -51,6 +51,23 @@ export function nav_tiles_keep(nav_st: NavState, kind: int, xs: []float, zs: []f
}
return changed
}
# the same ring by a distance the game answers: near(x, z) is how far (x, z) is from what must be
# loaded (0 inside it). A tile comes in when its centre is within pad of that, and goes only past
# pad + hyst, so a player at an edge does not read one in and out; how many changed
export function nav_tiles_keep_near(nav_st: NavState, kind: int, near: fn(float, float) -> float, pad: float, hyst: float) -> int {
let h = nv_mesh(nav_st, kind)
let f = nav_st.nv_files[kind]
if h == null or f == null { return 0 }
var changed = 0
for i in 0 .. nvc_index_count(h) {
nvc_index_tile(h, i, nav_st.nv_tile)
let m = near(nav_st.nv_tile[0], nav_st.nv_tile[1]) - pad
let is_in = nav_st.nv_tile[2] > 0.5
if is_in and m > hyst { changed += nvc_index_out(h, i) }
if not is_in and m < 0.0 { changed += nv_tile_read(h, f, i) }
}
return changed
}
function nv_tile_read(h: pointer, f: pointer, i: int) -> int {
let size = nvc_index_size(h, i)
let buf = nvc_stage(size)
@ -62,24 +79,3 @@ function nv_tile_read(h: pointer, f: pointer, i: int) -> int {
}
return nvc_index_in(h, i, buf, size)
}
# how many of kind's tiles are in, and (nav_tiles_bytes) how many bytes those hold
export function nav_tiles_in(nav_st: NavState, kind: int) -> int { return nv_tiles_sum(nav_st, kind, false) }
export function nav_tiles_bytes(nav_st: NavState, kind: int) -> int { return nv_tiles_sum(nav_st, kind, true) }
function nv_tiles_sum(nav_st: NavState, kind: int, bytes: bool) -> int {
let h = nv_mesh(nav_st, kind)
if h == null { return 0 }
var n = 0
for i in 0 .. nvc_index_count(h) {
nvc_index_tile(h, i, nav_st.nv_tile)
if nav_st.nv_tile[2] > 0.5 and bytes { n += int(nav_st.nv_tile[3]) }
if nav_st.nv_tile[2] > 0.5 and not bytes { n += 1 }
}
return n
}
# how many tiles kind's file holds in all
export function nav_tiles_all(nav_st: NavState, kind: int) -> int {
let h = nv_mesh(nav_st, kind)
if h == null { return 0 }
return nvc_index_count(h)
}

View file

@ -0,0 +1,21 @@
# resident_count.ludic - what a file-backed mesh holds now: its tiles in, their bytes, and how many it has
# how many of kind's tiles are in, and (nav_tiles_bytes) how many bytes those hold
export function nav_tiles_in(nav_st: NavState, kind: int) -> int { return nv_tiles_sum(nav_st, kind, false) }
export function nav_tiles_bytes(nav_st: NavState, kind: int) -> int { return nv_tiles_sum(nav_st, kind, true) }
function nv_tiles_sum(nav_st: NavState, kind: int, bytes: bool) -> int {
let h = nv_mesh(nav_st, kind)
if h == null { return 0 }
var n = 0
for i in 0 .. nvc_index_count(h) {
nvc_index_tile(h, i, nav_st.nv_tile)
if nav_st.nv_tile[2] > 0.5 and bytes { n += int(nav_st.nv_tile[3]) }
if nav_st.nv_tile[2] > 0.5 and not bytes { n += 1 }
}
return n
}
# how many tiles kind's file holds in all
export function nav_tiles_all(nav_st: NavState, kind: int) -> int {
let h = nv_mesh(nav_st, kind)
if h == null { return 0 }
return nvc_index_count(h)
}

View file

@ -0,0 +1,47 @@
# keep_near_test.ludic - a file-backed mesh's tiles kept by a distance the game answers (its players'
# zones): a zone round one corner brings in its tile only, growing it brings in its neighbours, an
# edge nudged back and forth changes nothing, and a zone moved far away lets them all go
import "ludic.nav"
import "ludic.base"
import "fakes/meadow.ludic"
program KeepNearTest {
numbers float
state NearState {
x: float = 0.0
z: float = 0.0
r: float = 0.0
}
# metres from (x, z) to the zone's edge, 0 inside: what ludic.zones' zones_nearest answers
function near(near_st: NearState, x: float, z: float) -> float {
let d = Math.sqrt((x - near_st.x) * (x - near_st.x) + (z - near_st.z) * (z - near_st.z))
return Math.max(d - near_st.r, 0.0)
}
function in_zone(nav_st: NavState) -> int { return nav_tiles_keep_near(nav_st, NAV_PERSON, fn near, 46.0, 200.0) }
test "tiles come in within the zone, an edge nudged back and forth changes nothing, and a far zone lets them go" (nav_st: mut NavState, near_st: mut NearState) {
let g = ground(128, 0)
expect(nav_tiled(nav_st, NAV_PERSON, 0.0, 0.0, 64.0, 16, 4096))
for tz in 0 .. 2 { for tx in 0 .. 2 { expect(nav_tile_build(nav_st, NAV_PERSON, tx, tz, g, new NavConfig) > 0) } }
let path = Os.temp_dir() + "/ludic_nav_near.navmesh"
expect(nav_save_file(nav_st, NAV_PERSON, path))
nav_reset(nav_st)
expect(nav_load_index(nav_st, NAV_PERSON, path))
near_st.x = 10.0
near_st.z = 10.0
near_st.r = 5.0
expect_eq(in_zone(nav_st), 1)
expect_eq(nav_tiles_in(nav_st, NAV_PERSON), 1)
near_st.r = 80.0
expect_eq(in_zone(nav_st), 3)
expect_eq(nav_tiles_in(nav_st, NAV_PERSON), 4)
for k in 0 .. 20 {
near_st.r = 5.0 + float(k % 2) * 100.0
expect_eq(in_zone(nav_st), 0)
}
near_st.x = 2000.0
expect_eq(in_zone(nav_st), 4)
expect_eq(nav_tiles_in(nav_st, NAV_PERSON), 0)
nav_reset(nav_st)
}
}

View file

@ -27,6 +27,7 @@ export function npc_spawn(npc_st: mut NpcState, kind: int) -> NpcPerson {
let start = k.start
start(p)
} else { npc_start_near(npc_st, p, NpcWorld.origin_x(), NpcWorld.origin_z(), 6.0, 30.0) }
if not NpcWorld.active(p.x, p.z) { return null } # nobody is born outside every zone; the slot waits
p.active = true
npc_st.np_n += 1
NpcWorld.born(p)

View file

@ -5,6 +5,7 @@ export port NpcWorld {
water: fn(float, float) -> float = fn nw_dry # the water's surface there
slope: fn(float, float) -> float = fn nw_zero2
allowed: fn(float, float) -> bool = fn nw_yes2 # a person may stop here (not the player's camp)
active: fn(float, float) -> bool = fn nw_yes2 # simulated here: outside, a walker is frozen and none is born
# (x, z, r, feet, head): true when a still thing takes a body of radius r there; pushed_x / _z say where
push: fn(float, float, float, float, float) -> bool = fn nw_no_push
pushed_x: fn() -> float = fn nw_asked_x

View file

@ -6,7 +6,7 @@ export function npc_tick(npc_st: mut NpcState, dt: float) -> void {
np_census(npc_st)
for i in 0 .. len(npc_st.np_walkers) {
let p = npc_st.np_walkers[i]
if not p.active or np_leaves(npc_st, p) { continue }
if not p.active or not NpcWorld.active(p.x, p.z) or np_leaves(npc_st, p) { continue }
p.goal_speed = 0.0
if p.act == NPCA_WALK { npc_walk(npc_st, p, dt) } else { npc_act(npc_st, p, dt) }
npc_notice(npc_st, p)

View file

@ -16,7 +16,10 @@ export property PhysPose {
function ph_add(physics_st: mut PhysicsState, shape: int, x: float, y: float, z: float, yaw: float, motion: int, layer: int, mass: float) -> int {
let s = ph_shape(physics_st, shape)
if physics_st.ph_world == null or s == null { return -1 }
return jph_body_add(physics_st.ph_world, s, x, y, z, yaw, motion, layer, mass, 0.6, 0.1)
let id = jph_body_add(physics_st.ph_world, s, x, y, z, yaw, motion, layer, mass, 0.6, 0.1)
# 25.5a: a world full to the bodies it was opened for is a failure, never a thing quietly not there
if id < 0 and jph_world_bodies(physics_st.ph_world) >= physics_st.ph_max { Mem.over("a physics body past phys_open's max_bodies") }
return id
}
# the ground: a heightfield or a mesh at the world's origin

View file

@ -8,6 +8,7 @@ import "state.ludic"
import "fact_pool.ludic"
import "shape_ids.ludic"
import "owned.ludic"
import "world.ludic"
import "shapes.ludic"
import "bodies.ludic"
import "queries.ludic"

Binary file not shown.

View file

@ -1,6 +1,6 @@
# native.ludic - the shim's symbols (native/shim/jph_shim.cpp). A world and a shape are handles
# the package keeps; a body is an int id. None of this is exported.
extern function jph_world_new(max_bodies: int, threads: int) -> pointer = "jph_world_new"
extern function jph_world_new(max_bodies: int, threads: int, max_pairs: int, max_contacts: int, temp_kb: int) -> pointer = "jph_world_new"
extern function jph_world_free(w: pointer) -> void = "jph_world_free"
extern function jph_world_gravity(w: pointer, y: float) -> void = "jph_world_gravity"
extern function jph_world_step(w: pointer, dt: float, sub: int) -> int = "jph_world_step"

View file

@ -93,11 +93,11 @@ public:
struct World {
// pairs and contacts are for what MOVES: a valley of sixty thousand still trunks makes none
// between them, so these are Jolt's own sizes, not the body count's; the temporary memory falls
// back to the heap rather than aborting a step
World(int max_bodies, int threads)
: temp(32 * 1024 * 1024), jobs(cMaxPhysicsJobs, cMaxPhysicsBarriers, threads) {
sys.Init(max_bodies, 0, 65536, 20480, bpl, ovb, olp);
// between them, so they are sized for the moving things, not the body count; the temporary memory
// falls back to the heap rather than aborting a step. All four are the package's (phys_open)
World(int max_bodies, int threads, int max_pairs, int max_contacts, int temp_kb)
: temp(size_t(temp_kb) * 1024), jobs(cMaxPhysicsJobs, cMaxPhysicsBarriers, threads) {
sys.Init(max_bodies, 0, max_pairs, max_contacts, bpl, ovb, olp);
sys.SetContactListener(&contacts);
}
TempAllocatorImplWithMallocFallback temp;

View file

@ -1,13 +1,14 @@
// jph_world.inl - a physics world: made, stepped, emptied of its contacts, freed
// a world for up to max_bodies; threads 0 means the machine's cores less one, at most four
JPH_SHIM void *jph_world_new(int max_bodies, int threads) {
// a world for up to max_bodies, max_pairs touching pairs and max_contacts contact constraints, with a
// temp heap of temp_kb; threads 0 means the machine's cores less one, at most four
JPH_SHIM void *jph_world_new(int max_bodies, int threads, int max_pairs, int max_contacts, int temp_kb) {
if (jph_init() != 0) return nullptr;
if (threads <= 0) {
int hw = int(std::thread::hardware_concurrency()) - 1;
threads = hw < 1 ? 1 : (hw > 4 ? 4 : hw);
}
World *w = new World(max_bodies, threads);
World *w = new World(max_bodies, threads, max_pairs, max_contacts, temp_kb);
w->sys.SetGravity(Vec3(0.0f, -9.81f, 0.0f));
return w;
}

View file

@ -1,43 +1,5 @@
# shapes.ludic - the world opened and closed, and shapes: made in C, kept here, named by an int.
# A shape may be used by any number of bodies; phys_close frees them all.
# a world for up to max_bodies (threads 0: the cores less one, at most four); false if C refused
export function phys_open(physics_st: mut PhysicsState, max_bodies: int, threads: int) -> bool {
phys_close(physics_st)
ph_buffers()
physics_st.ph_world = jph_world_new(max_bodies, threads)
ph_own_reset(physics_st, max_bodies)
physics_st.ph_gen += 1
physics_st.ph_acc = 0.0
physics_st.ph_steps = 0
return physics_st.ph_world != null
}
export function phys_is_open(physics_st: PhysicsState) -> bool { return physics_st.ph_world != null }
# which world this is: a body, shape or walker id kept from an older one means nothing now
export function phys_generation(physics_st: PhysicsState) -> int { return physics_st.ph_gen }
# everything let go: the world, every body in it and every shape (a world swap, a quit); the lists
# that held them are emptied and kept, since nothing is given back
export function phys_close(physics_st: mut PhysicsState) -> void {
if physics_st.ph_walkers != null {
for i in 0 .. len(physics_st.ph_walkers) {
if physics_st.ph_walkers[i] != null { jph_char_free(physics_st.ph_walkers[i]) }
}
}
if physics_st.ph_walkers != null { List.clear(physics_st.ph_walkers) }
if physics_st.ph_world != null { jph_world_free(physics_st.ph_world) }
physics_st.ph_world = null
if physics_st.ph_shapes != null {
for i in 0 .. len(physics_st.ph_shapes) {
if physics_st.ph_shapes[i] != null { jph_shape_free(physics_st.ph_shapes[i]) }
}
}
if physics_st.ph_shapes != null { List.clear(physics_st.ph_shapes) }
List.clear(physics_st.ph_spare)
physics_st.ph_nf = 0
if physics_st.ph_facts != null { q_clear(physics_st.ph_facts) }
}
# shapes.ludic - shapes: made in C, kept here, named by an int. A shape may be used by any number of
# bodies; phys_close (world.ludic) frees them all.
# a crate: half extents in metres
@creates(PhysShape)

View file

@ -42,6 +42,7 @@ export state PhysicsState {
ph_step: float = 0.016666668 # seconds a step
ph_sub: int = 1 # collision steps in each
ph_max_steps: int = 4 # a slow frame catches up this far and no further
ph_max: int = 0 # the bodies the world was opened for: one more is a failure, not a -1
ph_acc: float = 0.0
ph_steps: int = 0
ph_gen: int = 0 # how many worlds have been opened: an id from an older one is stale
@ -75,3 +76,9 @@ export function phys_config(physics_st: mut PhysicsState, step: float, sub: int,
physics_st.ph_hard = hard
}
function ph_facts__new() -> Queue<PhysFact> { return queue_new("physics.facts") }
# what moves, sized: touching pairs, contact constraints and the step's temp heap (KB). A valley's still
# things make no pairs between them; drops, boats and the walkers do. Past a size a step fails loudly
export const PH_PAIRS: int = 4096
export const PH_CONTACTS: int = 2048
export const PH_TEMP_KB: int = 4096

View file

@ -23,7 +23,8 @@ export function phys_step(physics_st: mut PhysicsState) -> void {
if physics_st.ph_world == null { return }
ph_buffers()
ph_buoy_all(physics_st)
jph_world_step(physics_st.ph_world, physics_st.ph_step, physics_st.ph_sub)
# 25.5a: Jolt's pairs or contacts full (PH_PAIRS, PH_CONTACTS) is a failure: past it things pass through
if jph_world_step(physics_st.ph_world, physics_st.ph_step, physics_st.ph_sub) != 0 { Mem.over("a physics step past phys_open's pairs or contacts") }
physics_st.ph_steps += 1
ph_contacts(physics_st)
}

View file

@ -0,0 +1,25 @@
# jolt_floor_test.ludic - what an empty world costs in Jolt's own heap at the game's size (plan 25):
# the floor under every body, from the body table, the broad phase, the contact caches and the temp heap
import "ludic.physics"
import "ludic.base"
program JoltFloorTest {
numbers float
function floor_at(physics_st: mut PhysicsState, bodies: int) -> long {
expect(phys_open(physics_st, 64, 1))
phys_close(physics_st)
let before = phys_heap_bytes()
expect(phys_open(physics_st, bodies, 0))
let f = phys_heap_bytes() - before
phys_close(physics_st)
return f
}
# it was 51839 KB at 200000 bodies with 65536 pairs, 20480 contacts and a 32 MB temp heap
test "an empty world at the game's size costs under 16 MB" (physics_st: mut PhysicsState) {
let a = floor_at(physics_st, 200000)
let b = floor_at(physics_st, 98304)
print(`jolt floor: {a / long(1024)} KB at 200000 bodies, {b / long(1024)} KB at 98304`)
expect(b < long(16 * 1024 * 1024))
expect(a > b)
}
}

View file

@ -0,0 +1,43 @@
# world.ludic - the world opened and closed: its sizes, and everything in it let go at once
# a world for up to max_bodies (threads 0: the cores less one, at most four); false if C refused.
# Pairs, contacts and the temp heap are sized for the things that MOVE (PH_PAIRS, ...)
export function phys_open(physics_st: mut PhysicsState, max_bodies: int, threads: int) -> bool { return phys_open_sized(physics_st, max_bodies, threads, PH_PAIRS, PH_CONTACTS, PH_TEMP_KB) }
# the same with Jolt's other three sizes given: touching pairs, contact constraints, the temp heap in KB
export function phys_open_sized(physics_st: mut PhysicsState, max_bodies: int, threads: int, max_pairs: int, max_contacts: int, temp_kb: int) -> bool {
phys_close(physics_st)
ph_buffers()
physics_st.ph_world = jph_world_new(max_bodies, threads, max_pairs, max_contacts, temp_kb)
physics_st.ph_max = max_bodies
ph_own_reset(physics_st, max_bodies)
physics_st.ph_gen += 1
physics_st.ph_acc = 0.0
physics_st.ph_steps = 0
return physics_st.ph_world != null
}
export function phys_is_open(physics_st: PhysicsState) -> bool { return physics_st.ph_world != null }
# which world this is: a body, shape or walker id kept from an older one means nothing now
export function phys_generation(physics_st: PhysicsState) -> int { return physics_st.ph_gen }
# everything let go: the world, every body in it and every shape (a world swap, a quit); the lists
# that held them are emptied and kept, since nothing is given back
export function phys_close(physics_st: mut PhysicsState) -> void {
if physics_st.ph_walkers != null {
for i in 0 .. len(physics_st.ph_walkers) {
if physics_st.ph_walkers[i] != null { jph_char_free(physics_st.ph_walkers[i]) }
}
}
if physics_st.ph_walkers != null { List.clear(physics_st.ph_walkers) }
if physics_st.ph_world != null { jph_world_free(physics_st.ph_world) }
physics_st.ph_world = null
if physics_st.ph_shapes != null {
for i in 0 .. len(physics_st.ph_shapes) {
if physics_st.ph_shapes[i] != null { jph_shape_free(physics_st.ph_shapes[i]) }
}
}
if physics_st.ph_shapes != null { List.clear(physics_st.ph_shapes) }
List.clear(physics_st.ph_spare)
physics_st.ph_nf = 0
if physics_st.ph_facts != null { q_clear(physics_st.ph_facts) }
}

View file

@ -0,0 +1,75 @@
# baked_tex.ludic - textures a game baked at build time (`ludic bake`, assets/baked/), read before the PNG
# they came from: a boot decodes, pads and converts nothing it can take ready-made. The file is ludic.base's
# baked form (a 32-byte "LBAK" header, then the payload), read by hand here since render3d uses no package.
# Missing, or of another key or version, and the PNG is decoded as it always was.
# assets/baked/png/<path under assets/>.tex a PNG's samples: u32 w, h, channels, depth, then them
# assets/baked/cutouts/<path under assets/>.bc7 a cut-out atlas, padded as tex_dilate pads it: a .dds
const BAKED_PNG_KEY: string = "png_sheets" # the Bakes row (Maroon Lake's bakes.lres) and the key in its header
const BAKED_PNG_VERSION: int = 1
const BAKED_CUT_KEY: string = "cutouts"
const BAKED_CUT_VERSION: int = 1
function baked_u32(b: []byte, o: int) -> int { return b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24) }
# where the bake of `path` (a PNG under assets/) lives, or "" for any other path
@alloc_ok("a load: the baked file's name")
function baked_tex_path(path: string, dir: string, ext: string) -> string {
let n = len(path)
if n < 12 or path[0 .. 7] != "assets/" or path[n - 4 .. n] != ".png" { return "" }
return `assets/baked/{dir}/{path[7 .. n - 4]}{ext}`
}
# the payload's offset in a baked file of this key and version, or -1 (not one, or another's)
function baked_payload(b: []byte, key: string, version: int) -> int {
if b == null or len(b) < 40 { return -1 }
if b[0] != 76 or b[1] != 66 or b[2] != 65 or b[3] != 75 { return -1 } # "LBAK"
if baked_u32(b, 4) != 1 or baked_u32(b, 8) != version { return -1 }
let off = baked_u32(b, 12)
if off < 32 + len(key) or off > len(b) { return -1 }
for i in 0 .. len(key) { if b[32 + i] != key[i] { return -1 } }
if b[32 + len(key)] != 0 { return -1 }
return off
}
# a PNG's samples as the bake left them (tex_w .. tex_depth set, the caller frees them), or null
@alloc_ok("a load: the baked file read, its samples kept by the caller")
function baked_png(render3d_st: mut Render3dState, path: pointer) -> pointer {
let bp = baked_tex_path(path, "png", ".tex")
if bp == "" or not Fs.exists(bp) { return null }
let b = Fs.read_bytes(bp)
let off = baked_payload(b, BAKED_PNG_KEY, BAKED_PNG_VERSION)
if off < 0 or off + 16 > len(b) {
if b != null { free(b) }
print(`r3d: {bp} is not this build's bake; {path} is decoded`)
return null
}
let w = baked_u32(b, off)
let h = baked_u32(b, off + 4)
let ch = baked_u32(b, off + 8)
let depth = baked_u32(b, off + 12)
let n = w * h * ch * (depth / 8)
if w < 1 or h < 1 or ch < 1 or ch > 4 or (depth != 8 and depth != 16) or off + 16 + n > len(b) {
free(b)
return null
}
let out = bytes(n + 8)
mem_copy(out, mem_off(data_of(b), off + 16), n)
free(b)
render3d_st.tex_w = w; render3d_st.tex_h = h; render3d_st.tex_channels = ch; render3d_st.tex_depth = depth
return out
}
# a cut-out atlas as the bake padded and compressed it (a texture id), or 0
@alloc_ok("a load: the baked file read and let go")
function baked_cutout(render3d_st: mut Render3dState, path: pointer, srgb: bool) -> int {
if not render3d_st.gvk_has_bc { return 0 }
let bp = baked_tex_path(path, "cutouts", ".bc7")
if bp == "" or not Fs.exists(bp) { return 0 }
let b = Fs.read_bytes(bp)
let off = baked_payload(b, BAKED_CUT_KEY, BAKED_CUT_VERSION)
var id = 0
if off >= 0 { id = tex_load_dds_at(render3d_st, b, off, srgb) }
if b != null { free(b) }
if id == 0 { print(`r3d: {bp} is not this build's bake; {path} is padded here`) }
return id
}

View file

@ -20,6 +20,7 @@ import "prof.ludic"
import "drawstats.ludic"
import "programs.ludic"
import "texture.ludic"
import "baked_tex.ludic"
import "mesh.ludic"
import "gpu_vk_draw.ludic"
import "camera.ludic"

View file

@ -39,3 +39,8 @@ function png_decode_bytes(render3d_st: mut Render3dState, path: string, reuse: [
function tex_upload_bytes(render3d_st: mut Render3dState, px: []byte, srgb: bool, mips: bool) -> int {
return tex_upload(render3d_st, data_of(px), srgb, mips)
}
# cut-out edge padding over samples laid out as the last decode left them (tex_dilate)
function tex_dilate_bytes(render3d_st: Render3dState, px: []byte, thresh: int, passes: int) -> void {
if px == null or len(px) < render3d_st.tex_w * render3d_st.tex_h * render3d_st.tex_channels { return }
tex_dilate(render3d_st, data_of(px), thresh, passes)
}

View file

@ -115,6 +115,8 @@ function png_unfilter(raw: pointer, cur: int, prev: int, stride: int, fbpp: int,
@alloc_ok("loading a model, a texture or a font: a load, not a frame (a guest loading a teammate's look is one)")
function png_decode(render3d_st: mut Render3dState, path: pointer) -> pointer {
let bk = baked_png(render3d_st, path) # a build that baked it (baked_tex.ludic)
if bk != null { return bk }
let d = r3d_read_file(render3d_st, path)
if d == null { print(`png: cannot read {path}`); return null }
let size = render3d_st.tex_file_len
@ -198,30 +200,51 @@ function tex_dilate(render3d_st: Render3dState, px: pointer, thresh: int, passes
var i = 0
while i < w * h { let o = i * c; if px[o] + px[o + 1] + px[o + 2] < thresh { mask[i] = 1 } else { mask[i] = 0 }; i += 1 }
let next = bytes(w * h)
let j = new DilateJob
j.px = px; j.mask = mask; j.next = next; j.w = w; j.h = h; j.c = c
let blocks = (h + TEX_DILATE_ROWS - 1) / TEX_DILATE_ROWS
for pass in 0 .. passes {
mem_copy(next, mask, w * h)
var y = 0
while y < h {
var x = 0
while x < w {
let k = y * w + x
if mask[k] == 1 {
var r = 0; var g = 0; var b = 0; var n = 0
if x > 0 and mask[k - 1] == 0 { let o = (k - 1) * c; r += px[o]; g += px[o + 1]; b += px[o + 2]; n += 1 }
if x < w - 1 and mask[k + 1] == 0 { let o = (k + 1) * c; r += px[o]; g += px[o + 1]; b += px[o + 2]; n += 1 }
if y > 0 and mask[k - w] == 0 { let o = (k - w) * c; r += px[o]; g += px[o + 1]; b += px[o + 2]; n += 1 }
if y < h - 1 and mask[k + w] == 0 { let o = (k + w) * c; r += px[o]; g += px[o + 1]; b += px[o + 2]; n += 1 }
if n > 0 { let o = k * c; px[o] = r / n; px[o + 1] = g / n; px[o + 2] = b / n; next[k] = 0 }
}
x += 1
}
y += 1
}
Job.parallel_for(blocks, fn tex_dilate_rows, j)
mem_copy(mask, next, w * h)
}
free(mask); free(next)
}
# A pass's rows on every core. Within a pass a row writes only its own still-masked texels and reads
# only neighbours the mask already let go, which no row writes this pass - so rows are independent and
# the result is the one row-by-row order gave. The worker is handed plain buffers and makes nothing.
const TEX_DILATE_ROWS: int = 16
property DilateJob {
px: pointer = null
mask: pointer = null
next: pointer = null
w: int = 0
h: int = 0
c: int = 0
}
function tex_dilate_rows(b: int, j: DilateJob) -> void {
let w = j.w; let c = j.c; let px = j.px; let mask = j.mask; let next = j.next
var y = b * TEX_DILATE_ROWS
let y1 = min(j.h, y + TEX_DILATE_ROWS)
while y < y1 {
var x = 0
while x < w {
let k = y * w + x
if mask[k] == 1 {
var r = 0; var g = 0; var bl = 0; var n = 0
if x > 0 and mask[k - 1] == 0 { let o = (k - 1) * c; r += px[o]; g += px[o + 1]; bl += px[o + 2]; n += 1 }
if x < w - 1 and mask[k + 1] == 0 { let o = (k + 1) * c; r += px[o]; g += px[o + 1]; bl += px[o + 2]; n += 1 }
if y > 0 and mask[k - w] == 0 { let o = (k - w) * c; r += px[o]; g += px[o + 1]; bl += px[o + 2]; n += 1 }
if y < j.h - 1 and mask[k + w] == 0 { let o = (k + w) * c; r += px[o]; g += px[o + 1]; bl += px[o + 2]; n += 1 }
if n > 0 { let o = k * c; px[o] = r / n; px[o + 1] = g / n; px[o + 2] = bl / n; next[k] = 0 }
}
x += 1
}
y += 1
}
}
# Upload the last-decoded samples as a 2D texture. srgb: colour data (8-bit only).
function tex_upload(render3d_st: mut Render3dState, px: pointer, srgb: bool, mips: bool) -> int {
let id = gpu_tex_new(render3d_st)
@ -289,6 +312,13 @@ function tex_load_ex__t(render3d_st: mut Render3dState, path: pointer, srgb: boo
return t
}
}
if dilate > 0 {
let cut = baked_cutout(render3d_st, path, srgb) # padded and compressed at build time
if cut != 0 {
tex_note_size(render3d_st, cut)
return cut
}
}
let px = png_decode(render3d_st, path)
if px == null { return 0 }
if dilate > 0 { tex_dilate(render3d_st, px, 60, dilate) }

View file

@ -44,14 +44,16 @@ function tex_load_dds_bytes(render3d_st: mut Render3dState, b: []byte, srgb: boo
render3d_st.gvk_tag = was
return r
}
function tex_load_dds_bytes__t(render3d_st: mut Render3dState, b: []byte, srgb: bool) -> int {
if len(b) < 148 or b[0] != 68 or b[1] != 68 or b[2] != 83 or b[3] != 32 { return 0 }
if b[84] != 68 or b[85] != 88 or b[86] != 49 or b[87] != 48 { return 0 } # "DX10"
let h = dds_u32(b, 12)
let w = dds_u32(b, 16)
var levels = dds_u32(b, 28)
function tex_load_dds_bytes__t(render3d_st: mut Render3dState, b: []byte, srgb: bool) -> int { return tex_load_dds_at(render3d_st, b, 0, srgb) }
# a .dds that starts `at` bytes into b (a baked file's payload, baked_tex.ludic)
function tex_load_dds_at(render3d_st: mut Render3dState, b: []byte, at0: int, srgb: bool) -> int {
if len(b) < at0 + 148 or b[at0] != 68 or b[at0 + 1] != 68 or b[at0 + 2] != 83 or b[at0 + 3] != 32 { return 0 }
if b[at0 + 84] != 68 or b[at0 + 85] != 88 or b[at0 + 86] != 49 or b[at0 + 87] != 48 { return 0 } # "DX10"
let h = dds_u32(b, at0 + 12)
let w = dds_u32(b, at0 + 16)
var levels = dds_u32(b, at0 + 28)
if levels < 1 { levels = 1 }
let dxgi = dds_u32(b, 128)
let dxgi = dds_u32(b, at0 + 128)
var ifmt = 0
var block = 16
if dxgi == DDS_BC7 or dxgi == DDS_BC7_SRGB { ifmt = R3D_BC7; if srgb { ifmt = R3D_BC7_SRGB } }
@ -65,13 +67,13 @@ function tex_load_dds_bytes__t(render3d_st: mut Render3dState, b: []byte, srgb:
at += max((max(w >> l, 1) + 3) / 4, 1) * max((max(h >> l, 1) + 3) / 4, 1) * block
}
offs[levels] = at
if 148 + at > len(b) {
if at0 + 148 + at > len(b) {
free(offs)
return 0
}
let id = gpu_tex_new(render3d_st)
gpu_tex_bind(render3d_st, GPU_TEX2D, id)
let ok = gpu_tex_compressed(render3d_st, ifmt, w, h, levels, mem_off(data_of(b), 148), offs)
let ok = gpu_tex_compressed(render3d_st, ifmt, w, h, levels, mem_off(data_of(b), at0 + 148), offs)
free(offs)
if not ok {
gpu_tex_free(render3d_st, id)

View file

@ -70,7 +70,7 @@ export function things_restock(things_st: ThingsState) -> void {
let all = things_all(things_st)
for i in 0 .. len(all) {
let t = all[i]
if thing_is_put_down(t) { continue }
if thing_is_put_down(t) or not ThingsWorld.restocks(t.x, t.z) { continue }
if not ThingKinds[t.kind].keeps { t.used = 0 }
if not t.active { thing_show(t) }
}

View file

@ -5,6 +5,7 @@ export port ThingsWorld {
placed: fn(Thing) -> void = fn th_nothing # a Thing exists now: draw it
removed: fn(Thing) -> void = fn th_nothing # it is gone for good: stop drawing it
shown: fn(Thing) -> void = fn th_nothing # its `active` changed: show or hide it
restocks: fn(float, float) -> bool = fn th_yes2 # a morning restocks here (inside a zone)
moved: fn(Thing) -> void = fn th_nothing # it stands somewhere else now
keeps: fn(Thing) -> bool = fn th_kind_saves # the save's section carries it
put_down: fn(Thing) -> bool = fn th_kind_put_down # set down by someone: a restock leaves it be
@ -12,5 +13,6 @@ export port ThingsWorld {
}
function th_flat(x: float, z: float) -> float { return 0.0 }
function th_nothing(t: Thing) -> void { }
function th_yes2(x: float, z: float) -> bool { return true }
function th_kind_saves(t: Thing) -> bool { return thing_kind_ok(t.kind) and ThingKinds[t.kind].saved }
function th_kind_put_down(t: Thing) -> bool { return thing_kind_ok(t.kind) and ThingKinds[t.kind].put_down }

View file

@ -53,6 +53,8 @@ export state UiState {
nt_ev_at: int = 0
nt_nums: []Val = nt_nums_new() # fired.ludic: the numbers a scroll or a slider reports, a ring
nt_num_at: int = 0
ct_clamp_n: []UiNode = new []UiNode # controls_opts.ludic: selects whose value a build clamped
ct_clamp_i: []int = new []int
hd_ev: Val = hd_ev_new() # hold.ludic: the held control's event, filled in place
cv_texts: []string = new []string # units_pieces.ludic: a value's var() pieces, kept
tx_pieces: [][]string = new [][]string # eval_text.ludic: a text's pieces, a list a depth

View file

@ -5,17 +5,8 @@ function bd_class(ui_st: mut UiState, k: UiTpl, e: UiEnv, key: string, out: []Ui
let cls = k.cls
uc_load(ui_st, cls)
let it = in_get(ui_st, key, cls)
if it.props == null { it.props = Value.object() }
let props = it.props
Value.clear(props)
for i in 0 .. len(k.keys) {
let a = k.keys[i]
if a != "class" and a != "style" and a != "id" { Value.put(props, a, ui_eval(ui_st, k.vals[i], e)) }
}
cls.props(it.ptr, props)
let ce = env_new(ui_st, null)
if it.model == null { it.model = Value.object() }
ce.model = cls.model(it.ptr, it.model)
if it.fresh { ce.model = bd_first_fill(ui_st, it, k, e) } else { ce.model = bd_fill(ui_st, it, k, e) }
ce.inst = it
ce.ctx = e.ctx
ce.slot = k
@ -34,6 +25,26 @@ function bd_class(ui_st: mut UiState, k: UiTpl, e: UiEnv, key: string, out: []Ui
bd_kids(ui_st, root, ce, key, out)
if len(out) > at { bd_pass_down(ui_st, out[at], k, e) }
}
# an instance's props and model, filled in place into the objects it keeps (a reused one's are kept)
function bd_fill(ui_st: mut UiState, it: UiInst, k: UiTpl, e: UiEnv) -> Val {
if it.props == null { it.props = Value.object() }
let props = it.props
Value.clear(props)
for i in 0 .. len(k.keys) {
let a = k.keys[i]
if a != "class" and a != "style" and a != "id" { Value.put(props, a, ui_eval(ui_st, k.vals[i], e)) }
}
it.cls.props(it.ptr, props)
if it.model == null { it.model = Value.object() }
return it.cls.model(it.ptr, it.model)
}
# a new instance's first fill makes its props and model objects and every Value in them - once per
# instance, which in_reuse bounds (a prompt's key hint first shown mid-walk was judged as a frame's keep)
@alloc_ok("a new component instance's first build: its props and model, then kept or reused")
function bd_first_fill(ui_st: mut UiState, it: UiInst, k: UiTpl, e: UiEnv) -> Val {
it.fresh = false
return bd_fill(ui_st, it, k, e)
}
# <Card class="wide" style="margin: 4px" id="c"> puts those on the card's own root element; when that
# root is itself a component, each user in turn adds theirs (the outermost's id wins)
function bd_pass_down(ui_st: mut UiState, n: UiNode, k: UiTpl, e: UiEnv) -> void {

View file

@ -31,9 +31,9 @@ function ct_activate(ui_st: mut UiState, n: UiNode) -> void {
function ct_step(ui_st: mut UiState, n: UiNode, d: int) -> void {
let t = ct_type(n)
if t == "select" and n.opts != null and len(n.opts) > 0 {
let at = (ct_option_at(n, n.opts) + d + len(n.opts)) % len(n.opts)
let ov = ui_attr(n.opts[at], "value")
if Value.kind(ov) == 0 { ct_change(ui_st, n, Value.int(at)) } else { ct_change(ui_st, n, ov) }
let from = ct_option_at(n, n.opts)
let at = ct_opt_step(n.opts, from, d) # past a disabled option
if at != from { ct_opt_pick(ui_st, n, n.opts, at) }
}
if t == "number" { ct_num_step(ui_st, n, d) }
if t == "range" {

View file

@ -67,21 +67,6 @@ function ct_build_range(ui_st: mut UiState, n: UiNode) -> void {
ui_kept(n.children, track)
ui_kept(n.children, ct_part(ui_st, n, "ui-value", ct_value_shown(ui_st, n)))
}
function ct_build_select(ui_st: mut UiState, n: UiNode) -> void {
@alloc_ok("once per pooled node: its list is kept and cleared")
if n.opts == null { n.opts = new []UiNode } # its options moved to a list the node keeps, not a new one
let opts = n.opts
List.clear(opts)
for i in 0 .. len(n.children) { ui_kept(opts, n.children[i]) }
List.clear(n.children)
ct_label(ui_st, n)
var shown = ""
let at = ct_option_at(n, opts)
if at >= 0 { shown = opts[at].text }
ui_kept(n.children, ct_part(ui_st, n, "ui-prev", "<"))
ui_kept(n.children, ct_part(ui_st, n, "ui-value", shown))
ui_kept(n.children, ct_part(ui_st, n, "ui-next", ">"))
}
# which option the select's value names: an option's value="...", else its place
function ct_option_at(n: UiNode, opts: []UiNode) -> int {
let nv = ui_attr(n, "value")

View file

@ -0,0 +1,76 @@
# controls_opts.ludic - <option disabled="{...}">: a select steps past a disabled option (arrows, a
# press, the pad), a value naming one is shown and set as the nearest enabled option, and the arrow
# whose next option is disabled is drawn disabled - the options that way are off (a host's cap)
function ct_opt_on(opts: []UiNode, i: int) -> bool { return i >= 0 and i < len(opts) and opts[i].enabled }
# from `at` d steps at a time, wrapping, to the first enabled option; `at` itself when none is
function ct_opt_step(opts: []UiNode, at: int, d: int) -> int {
let n = len(opts)
var i = at
for k in 0 .. n {
i = (i + d + n) % n
if opts[i].enabled { return i }
}
return at
}
# the enabled option nearest `at` (the lower of two as near); -1 when none is
function ct_opt_nearest(opts: []UiNode, at: int) -> int {
if ct_opt_on(opts, at) { return at }
for r in 1 .. len(opts) {
if ct_opt_on(opts, at - r) { return at - r }
if ct_opt_on(opts, at + r) { return at + r }
}
return -1
}
# a select's value on a disabled option: the nearest enabled one is what it shows, and is set
# (set once the build is done: an event fired while the tree is built is cleared with the frame's)
function ct_opt_clamp(ui_st: mut UiState, n: UiNode, opts: []UiNode, at: int) -> int {
if at < 0 or ct_opt_on(opts, at) { return at }
let to = ct_opt_nearest(opts, at)
if to >= 0 {
ui_kept(ui_st.ct_clamp_n, n)
ui_kept(ui_st.ct_clamp_i, to)
}
return to
}
# the clamps a build found, set now that the frame's events are cleared; stale ones are dropped
function ct_opt_flush(ui_st: mut UiState) -> void {
for i in 0 .. len(ui_st.ct_clamp_n) {
let n = ui_st.ct_clamp_n[i]
if n.opts != null and ui_st.ct_clamp_i[i] < len(n.opts) { ct_opt_pick(ui_st, n, n.opts, ui_st.ct_clamp_i[i]) }
}
ct_opt_forget(ui_st)
}
function ct_opt_forget(ui_st: mut UiState) -> void {
List.clear(ui_st.ct_clamp_n)
List.clear(ui_st.ct_clamp_i)
}
function ct_opt_pick(ui_st: mut UiState, n: UiNode, opts: []UiNode, at: int) -> void {
let ov = ui_attr(opts[at], "value")
if Value.kind(ov) == 0 { ct_change(ui_st, n, Value.int(at)) } else { ct_change(ui_st, n, ov) }
}
# a select: its label, < and >, and the option its value names between them (<option>s kept aside)
function ct_build_select(ui_st: mut UiState, n: UiNode) -> void {
@alloc_ok("once per pooled node: its list is kept and cleared")
if n.opts == null { n.opts = new []UiNode } # its options moved to a list the node keeps, not a new one
let opts = n.opts
List.clear(opts)
for i in 0 .. len(n.children) { ui_kept(opts, n.children[i]) }
List.clear(n.children)
ct_label(ui_st, n)
var shown = ""
let at = ct_opt_clamp(ui_st, n, opts, ct_option_at(n, opts))
if at >= 0 { shown = opts[at].text }
let prev = ct_part(ui_st, n, "ui-prev", "<")
let next = ct_part(ui_st, n, "ui-next", ">")
if at >= 0 and len(opts) > 1 {
prev.enabled = ct_opt_on(opts, (at - 1 + len(opts)) % len(opts))
next.enabled = ct_opt_on(opts, (at + 1) % len(opts))
}
ui_kept(n.children, prev)
ui_kept(n.children, ct_part(ui_st, n, "ui-value", shown))
ui_kept(n.children, next)
}

View file

@ -5,6 +5,7 @@
export function ui_show(ui_st: mut UiState, screen: string, view: UiView, x: float, y: float, w: float, h: float) -> void {
in_read(ui_st)
ct_opt_forget(ui_st) # a clamp found by a build this frame did not show
dv_before(ui_st)
ui_st.an_frame += 1
ui_viewport(ui_st, w, h)
@ -12,6 +13,7 @@ export function ui_show(ui_st: mut UiState, screen: string, view: UiView, x: flo
if root == null { return }
ui_place(ui_st, root, x, y, w, h)
nt_fired_clear(ui_st)
ct_opt_flush(ui_st) # a select's value on a disabled option, set now
sc_undo(root)
sc_update(ui_st, root)
fr_pointer(ui_st, root)

View file

@ -29,6 +29,7 @@ import "fired.ludic"
import "native_room.ludic"
import "controls.ludic"
import "controls_build.ludic"
import "controls_opts.ludic"
import "controls_parts.ludic"
import "controls_key.ludic"
import "controls_key_mouse.ludic"

View file

@ -7,6 +7,7 @@ property UiInst {
seen: int = 0
props: Val = null # its props, filled again each frame into the same object
model: Val = null # and its model, filled in place by the class's model()
fresh: bool = false # made, not reused: its first build makes its props and model
}
# builds before an unmounted instance is mounted again: the tree that last named it is good for two
const IN_SPARE_AFTER: int = 2
@ -41,6 +42,7 @@ function in_reuse(ui_st: mut UiState, k: UiClass) -> UiInst {
let it = new UiInst
it.cls = k
it.ptr = k.make()
it.fresh = true
return it
}
# after a build of `screen`: its instances the build did not reach are unmounted

View file

@ -11,6 +11,7 @@ export port VehicleWorld {
made: fn(Vehicle) -> void = fn ve__nothing # a new vehicle: draw it
placed: fn(Vehicle) -> void = fn ve__nothing # it moved: put its model there
gone: fn(Vehicle) -> void = fn ve__nothing # its owner left: take it away
active: fn(float, float) -> bool = fn ve__yes2 # simulated here: outside, a moored boat is left as it is
}
# a boat's hull in the world's physics (Maroon Lake: a buoyant Jolt body), named by the vehicle's
@ -70,6 +71,7 @@ export state VehiclesState {
function ve__dry(x: float, z: float) -> float { return -10000.0 }
function ve__calm(x: float, z: float, fx: float, fz: float) -> float { return 0.0 }
function ve__zero() -> float { return 0.0 }
function ve__yes2(x: float, z: float) -> bool { return true }
function ve__next(vehicles_st: mut VehiclesState) -> int {
vehicles_st.ve_ids += 1
return vehicles_st.ve_ids

View file

@ -64,6 +64,7 @@ export function vehicles_tick(vehicles_st: VehiclesState, dt: float) -> void {
if w > 0.0 { VehicleHull.drift(v.nid, -Math.sin(wy) * w, -Math.cos(wy) * w) }
continue
}
if not VehicleWorld.active(v.x, v.z) { continue } # outside every zone: left where it lies
ve_hull_read(v)
ve_hold(v)
VehicleWorld.placed(v)

View file

@ -38,6 +38,7 @@ export port WildlifeWorld {
taught: fn() -> bool = fn wl_no0 # food carries further, feeders mind less
about: fn(WildAnimal) -> int = fn wl_about # WILD_ABOUT, _AWAY or _GONE
ranges: fn(int) -> int = fn wl_two # ranges a species gets on this map
active: fn(float, float) -> bool = fn wl_yes2 # simulated here: outside, an animal is frozen
per_range: fn(int) -> int = fn wl_three # and how many live in each
spot: fn(WildAnimal, bool, WildSpot) -> bool = fn wl_no_spot # water (true) or forage for it
lures: fn() -> int = fn wl_none0 # how many things to ask `lure` about
@ -62,6 +63,7 @@ function wl_no0() -> bool { return false }
function wl_no1(p: int) -> bool { return false }
function wl_no2(x: float, z: float) -> bool { return false }
function wl_yes1(u: int) -> bool { return true }
function wl_yes2(x: float, z: float) -> bool { return true }
function wl_clear(ax: float, ay: float, az: float, bx: float, by: float, bz: float) -> bool { return true }
function wl_no_push(x: float, z: float, r: float, y0: float, y1: float) -> bool { return false }
function wl_asked_x(wildlife_st: WildlifeState) -> float { return wildlife_st.wl_ask_x }

View file

@ -1,5 +1,5 @@
# run.ludic - one step of every animal: the port says whether it is about at this hour; anything
# within 900 m of a player thinks; the gait follows the ground covered; player 0 sees what is near
# run.ludic - one step of every animal inside a zone (WildlifeWorld.active): the port says whether it
# is about at this hour; within 900 m of a player it thinks; player 0 sees what is near
export function wildlife_tick(wildlife_st: mut WildlifeState, dt: float, gh: float) -> void {
wl_seed_scan(wildlife_st, dt)
let all = wildlife_all(wildlife_st)
@ -9,6 +9,7 @@ export function wildlife_tick(wildlife_st: mut WildlifeState, dt: float, gh: flo
function wl_tick_one(wildlife_st: mut WildlifeState, a: WildAnimal, dt: float, gh: float) -> void {
if not a.alive { return }
if not WildlifeWorld.active(a.x, a.z) { return } # outside every zone: its state kept, no dice drawn
let about = WildlifeWorld.about(a)
if about == WILD_AWAY {
a.shown = false

View file

@ -0,0 +1,16 @@
# ludic.zones
Where a world is simulated: a disc per player - `(x, z, r)`, `r = zones_radius(fog)`, that is the
fog distance plus 40 m, never under 150 m nor over 900 m, and 900 m with no fog - and the discs that overlap merged
into clusters with a bounding circle. Uses `ludic.base` and nothing else.
- `zones_set(pid, x, z, r)`, `zones_clear(pid)`, `zones_reset()`; `zones_merge()` once a frame
after the discs are set, for the clusters.
- `zones_contains(x, z)`: inside any disc. `zones_nearest(x, z)`: metres to the nearest disc's
edge, 0 inside. With no disc set, everywhere is inside (0) - a world before its first frame
freezes nothing.
- `zones_clusters()`, `zones_cluster_x / _z / _r / _n(c)`, `zones_cluster_of(pid)`.
The rules it keeps: fixed arrays of `ZONES_MAX` (16) slots, nothing made after the state; the
questions allocate nothing. A world gates its ticks on `zones_contains` so what is outside every
zone keeps its state and draws no dice.

View file

@ -0,0 +1,41 @@
# ask.ludic - the questions: inside any disc, how far outside the nearest, and the clusters. With no
# disc set yet (a world before its first frame) everywhere is inside, so nothing freezes by accident
export function zones_any(zones_st: ZonesState) -> bool {
for i in 0 .. ZONES_MAX { if zones_st.zn_on[i] != 0 { return true } }
return false
}
export function zones_contains(zones_st: ZonesState, x: float, z: float) -> bool {
var any = false
for i in 0 .. ZONES_MAX {
if zones_st.zn_on[i] == 0 { continue }
any = true
let dx = x - zones_st.zn_x[i]
let dz = z - zones_st.zn_z[i]
if dx * dx + dz * dz <= zones_st.zn_r[i] * zones_st.zn_r[i] { return true }
}
return not any
}
# metres from (x, z) to the edge of the nearest disc: 0 inside one (and 0 with no disc set)
export function zones_nearest(zones_st: ZonesState, x: float, z: float) -> float {
var best = -1.0
for i in 0 .. ZONES_MAX {
if zones_st.zn_on[i] == 0 { continue }
let dx = x - zones_st.zn_x[i]
let dz = z - zones_st.zn_z[i]
let d = Math.max(Math.sqrt(dx * dx + dz * dz) - zones_st.zn_r[i], 0.0)
if best < 0.0 or d < best { best = d }
}
if best < 0.0 { return 0.0 }
return best
}
export function zones_on(zones_st: ZonesState, pid: int) -> bool { return pid >= 0 and pid < ZONES_MAX and zones_st.zn_on[pid] != 0 }
export function zones_r(zones_st: ZonesState, pid: int) -> float { return zones_st.zn_r[pid] }
export function zones_cluster_of(zones_st: ZonesState, pid: int) -> int { return zones_st.zn_cl[pid] }
export function zones_clusters(zones_st: ZonesState) -> int { return zones_st.zn_ncl }
export function zones_cluster_x(zones_st: ZonesState, c: int) -> float { return zones_st.zn_cx[c] }
export function zones_cluster_z(zones_st: ZonesState, c: int) -> float { return zones_st.zn_cz[c] }
export function zones_cluster_r(zones_st: ZonesState, c: int) -> float { return zones_st.zn_cr[c] }
export function zones_cluster_n(zones_st: ZonesState, c: int) -> int { return zones_st.zn_cn[c] }

View file

@ -0,0 +1,8 @@
# ludic.zones - a disc per player (x, z, r), the discs that overlap merged into clusters with a
# bounding circle, and the questions a world asks of them; fixed arrays, nothing made per frame
module ludic_zones uses ludic_base
numbers float
import "ludic.base"
import "state.ludic"
import "merge.ludic"
import "ask.ludic"

View file

@ -0,0 +1,70 @@
# merge.ludic - discs that overlap (or touch) are one cluster, transitively; a cluster's bounding
# circle is centred on its discs' box and reaches the farthest edge
export function zones_merge(zones_st: mut ZonesState) -> void {
for i in 0 .. ZONES_MAX { zones_st.zn_up[i] = i }
for i in 0 .. ZONES_MAX {
if zones_st.zn_on[i] == 0 { continue }
for j in i + 1 .. ZONES_MAX { if zones_st.zn_on[j] != 0 and zn_touch(zones_st, i, j) { zn_join(zones_st, i, j) } }
}
zones_st.zn_ncl = 0
for i in 0 .. ZONES_MAX { zones_st.zn_cl[i] = -1 }
for i in 0 .. ZONES_MAX {
if zones_st.zn_on[i] == 0 { continue }
let root = zn_root(zones_st, i)
if zones_st.zn_cl[root] < 0 {
zones_st.zn_cl[root] = zones_st.zn_ncl
zones_st.zn_ncl += 1
}
zones_st.zn_cl[i] = zones_st.zn_cl[root]
}
for c in 0 .. zones_st.zn_ncl { zn_bound(zones_st, c) }
}
function zn_touch(zones_st: ZonesState, i: int, j: int) -> bool {
let dx = zones_st.zn_x[i] - zones_st.zn_x[j]
let dz = zones_st.zn_z[i] - zones_st.zn_z[j]
let rr = zones_st.zn_r[i] + zones_st.zn_r[j]
return dx * dx + dz * dz <= rr * rr
}
function zn_root(zones_st: ZonesState, i: int) -> int {
var k = i
while zones_st.zn_up[k] != k { k = zones_st.zn_up[k] }
return k
}
function zn_join(zones_st: mut ZonesState, i: int, j: int) -> void {
let a = zn_root(zones_st, i)
let b = zn_root(zones_st, j)
if a < b { zones_st.zn_up[b] = a }
if b < a { zones_st.zn_up[a] = b }
}
function zn_bound(zones_st: mut ZonesState, c: int) -> void {
var x0 = 1000000000.0
var x1 = -1000000000.0
var z0 = 1000000000.0
var z1 = -1000000000.0
var n = 0
for i in 0 .. ZONES_MAX {
if zones_st.zn_on[i] == 0 or zones_st.zn_cl[i] != c { continue }
x0 = Math.min(x0, zones_st.zn_x[i] - zones_st.zn_r[i])
x1 = Math.max(x1, zones_st.zn_x[i] + zones_st.zn_r[i])
z0 = Math.min(z0, zones_st.zn_z[i] - zones_st.zn_r[i])
z1 = Math.max(z1, zones_st.zn_z[i] + zones_st.zn_r[i])
n += 1
}
let cx = (x0 + x1) * 0.5
let cz = (z0 + z1) * 0.5
var r = 0.0
for i in 0 .. ZONES_MAX {
if zones_st.zn_on[i] == 0 or zones_st.zn_cl[i] != c { continue }
let dx = zones_st.zn_x[i] - cx
let dz = zones_st.zn_z[i] - cz
r = Math.max(r, Math.sqrt(dx * dx + dz * dz) + zones_st.zn_r[i])
}
zones_st.zn_cx[c] = cx
zones_st.zn_cz[c] = cz
zones_st.zn_cr[c] = r
zones_st.zn_cn[c] = n
}

View file

@ -0,0 +1,5 @@
# ludic.zones - where the world is simulated: a disc per player, overlapping discs merged into
# clusters, asked whether a point is inside any. Uses ludic.base and nothing else.
package "ludic.zones"
version "0.1.0"
kind source

View file

@ -0,0 +1,41 @@
# state.ludic - the discs by player slot and the clusters the last merge made
export const ZONES_MAX: int = 16 # player slots a world can hold (at least PLY_MAX)
export const ZONES_NO_FOG: float = 900.0 # the radius of a player who sees to the horizon
export const ZONES_PAST_FOG: float = 40.0 # simulated this far past what a player can see
export const ZONES_LEAST: float = 150.0 # and never less than this
export state ZonesState {
zn_on: words = words(ZONES_MAX)
zn_x: []float = floats(ZONES_MAX)
zn_z: []float = floats(ZONES_MAX)
zn_r: []float = floats(ZONES_MAX)
zn_cl: words = words(ZONES_MAX) # each disc's cluster, after zones_merge
zn_up: words = words(ZONES_MAX) # the merge's union-find
zn_ncl: int = 0
zn_cx: []float = floats(ZONES_MAX) # each cluster's bounding circle
zn_cz: []float = floats(ZONES_MAX)
zn_cr: []float = floats(ZONES_MAX)
zn_cn: words = words(ZONES_MAX) # how many discs it holds
}
# a player's radius from how far they see: fog_dist <= 0 is no fog; never past no fog's, so a far fog
# never keeps more of the world than none does
export function zones_radius(fog_dist: float) -> float {
if fog_dist <= 0.0 { return ZONES_NO_FOG }
return Math.min(Math.max(fog_dist + ZONES_PAST_FOG, ZONES_LEAST), ZONES_NO_FOG)
}
export function zones_set(zones_st: mut ZonesState, pid: int, x: float, z: float, r: float) -> void {
if pid < 0 or pid >= ZONES_MAX { return }
zones_st.zn_on[pid] = 1
zones_st.zn_x[pid] = x
zones_st.zn_z[pid] = z
zones_st.zn_r[pid] = r
}
export function zones_clear(zones_st: mut ZonesState, pid: int) -> void { if pid >= 0 and pid < ZONES_MAX { zones_st.zn_on[pid] = 0 } }
export function zones_reset(zones_st: mut ZonesState) -> void {
for i in 0 .. ZONES_MAX { zones_st.zn_on[i] = 0 }
zones_st.zn_ncl = 0
}

View file

@ -0,0 +1,54 @@
# zones_test.ludic - discs merged into clusters (near players one, far ones two, a player leaving
# splits the chain), inside or not, how far to the nearest edge, and the radius a fog gives
import "ludic.zones"
import "ludic.base"
program ZonesTest {
numbers float
test "two near players are one cluster, two far apart are two" (zones_st: mut ZonesState) {
zones_reset(zones_st)
zones_set(zones_st, 0, 0.0, 0.0, 200.0)
zones_set(zones_st, 1, 300.0, 0.0, 150.0)
zones_merge(zones_st)
expect_eq(zones_clusters(zones_st), 1)
expect_eq(zones_cluster_of(zones_st, 0), zones_cluster_of(zones_st, 1))
expect_eq(zones_cluster_n(zones_st, 0), 2)
expect(zones_cluster_r(zones_st, 0) >= 325.0)
zones_set(zones_st, 1, 1000.0, 0.0, 150.0)
zones_merge(zones_st)
expect_eq(zones_clusters(zones_st), 2)
expect(zones_cluster_of(zones_st, 0) != zones_cluster_of(zones_st, 1))
}
test "a player leaving the middle of a chain splits it" (zones_st: mut ZonesState) {
zones_reset(zones_st)
zones_set(zones_st, 0, 0.0, 0.0, 200.0)
zones_set(zones_st, 3, 350.0, 0.0, 200.0)
zones_set(zones_st, 5, 700.0, 0.0, 200.0)
zones_merge(zones_st)
expect_eq(zones_clusters(zones_st), 1)
zones_clear(zones_st, 3)
zones_merge(zones_st)
expect_eq(zones_clusters(zones_st), 2)
expect(zones_cluster_of(zones_st, 0) != zones_cluster_of(zones_st, 5))
}
test "inside, the distance to the nearest edge, and nothing set is everywhere" (zones_st: mut ZonesState) {
zones_reset(zones_st)
expect(zones_contains(zones_st, 5000.0, 5000.0))
expect(zones_nearest(zones_st, 5000.0, 5000.0) == 0.0)
zones_set(zones_st, 2, 100.0, 100.0, 150.0)
expect(zones_contains(zones_st, 200.0, 100.0))
expect(not zones_contains(zones_st, 300.0, 100.0))
expect(zones_nearest(zones_st, 200.0, 100.0) == 0.0)
expect(Math.abs(zones_nearest(zones_st, 350.0, 100.0) - 100.0) < 0.001)
}
test "a player's radius: past the fog, never under the least nor over no fog's" (zones_st: mut ZonesState) {
expect(zones_radius(0.0) == ZONES_NO_FOG)
expect(zones_radius(30.0) == ZONES_LEAST)
expect(zones_radius(500.0) == 540.0)
expect(zones_radius(1000.0) == ZONES_NO_FOG) # a kilometre's fog keeps no more than none
expect(zones_radius(860.0) == ZONES_NO_FOG)
}
}

View file

@ -15,54 +15,93 @@
# megabytes of texture at load, and there the table is worth its 2 KB.
# ============================================================================
# ---- bit reader (DEFLATE packs bits least-significant-first) ---------------
export state RtInflateState {
z_src: pointer = null
z_len: int = 0
z_pos: int = 0
z_bitbuf: int = 0
z_bitcnt: int = 0
z_err: int = 0
z_lbase: words = null
z_lext: words = null
z_dbase: words = null
z_dext: words = null
# ---- the context ------------------------------------------------------------
# Everything one inflate works in: the bit reader, the RFC's length and distance tables, and every
# table and scratch list a block builds, made once with the context (z_inflate_new) and rebuilt in
# place - so an inflate allocates nothing, and each thread that inflates holds a context of its own.
# RtInflateState keeps one for the callers that inflate on the program's own thread.
export property ZInflate {
src: pointer = null
len: int = 0
pos: int = 0
bitbuf: int = 0
bitcnt: int = 0
err: int = 0
lbase: words = null
lext: words = null
dbase: words = null
dext: words = null
lit: words = null
dist: words = null
clen: words = null
lengths: words = null
dl: words = null
offs: words = null
firstc: words = null
}
function z_start(rt_inflate_st: mut RtInflateState, src: pointer, len: int) -> void {
z_tables_once(rt_inflate_st)
rt_inflate_st.z_src = src
rt_inflate_st.z_len = len
rt_inflate_st.z_pos = 0
rt_inflate_st.z_bitbuf = 0
rt_inflate_st.z_bitcnt = 0
rt_inflate_st.z_err = 0
export state RtInflateState {
z_ctx: ZInflate = z_inflate_new()
}
@alloc_ok("once per context: every table an inflate works in, rebuilt in place ever after")
export function z_inflate_new() -> ZInflate {
let z = new ZInflate
z.lbase = words(29)
z.lext = words(29)
for s in 0 .. 29 {
z.lbase[s] = z_len_base(s)
z.lext[s] = z_len_extra(s)
}
z.dbase = words(30)
z.dext = words(30)
for s in 0 .. 30 {
z.dbase[s] = z_dist_base(s)
z.dext[s] = z_dist_extra(s)
}
z.lit = z_table_new(288)
z.dist = z_table_new(30)
z.clen = z_table_new(19)
z.lengths = words(320)
z.dl = words(32)
z.offs = words(16)
z.firstc = words(17)
return z
}
function z_start(z: ZInflate, src: pointer, len: int) -> void {
z.src = src
z.len = len
z.pos = 0
z.bitbuf = 0
z.bitcnt = 0
z.err = 0
}
# Fill the bit buffer to at least `n` bits without consuming any (n <= 16, so the
# buffer never shifts a byte past bit 15 and cannot reach the sign bit).
function z_need(rt_inflate_st: mut RtInflateState, n: int) -> void {
while rt_inflate_st.z_bitcnt < n {
if rt_inflate_st.z_pos >= rt_inflate_st.z_len { return }
rt_inflate_st.z_bitbuf = (rt_inflate_st.z_bitbuf | (rt_inflate_st.z_src[rt_inflate_st.z_pos] << rt_inflate_st.z_bitcnt))
rt_inflate_st.z_pos += 1
rt_inflate_st.z_bitcnt += 8
function z_need(z: ZInflate, n: int) -> void {
while z.bitcnt < n {
if z.pos >= z.len { return }
z.bitbuf = (z.bitbuf | (z.src[z.pos] << z.bitcnt))
z.pos += 1
z.bitcnt += 8
}
}
function z_bits(rt_inflate_st: mut RtInflateState, need: int) -> int {
var val = rt_inflate_st.z_bitbuf
while rt_inflate_st.z_bitcnt < need {
if rt_inflate_st.z_pos >= rt_inflate_st.z_len {
rt_inflate_st.z_err = 1
function z_bits(z: ZInflate, need: int) -> int {
var val = z.bitbuf
while z.bitcnt < need {
if z.pos >= z.len {
z.err = 1
return 0
}
val = (val | (rt_inflate_st.z_src[rt_inflate_st.z_pos] << rt_inflate_st.z_bitcnt))
rt_inflate_st.z_pos += 1
rt_inflate_st.z_bitcnt += 8
val = (val | (z.src[z.pos] << z.bitcnt))
z.pos += 1
z.bitcnt += 8
}
rt_inflate_st.z_bitbuf = (val >> need)
rt_inflate_st.z_bitcnt -= need
z.bitbuf = (val >> need)
z.bitcnt -= need
return (val & ((1 << need) - 1))
}
@ -81,7 +120,7 @@ function z_table_new(nsym: int) -> words {
}
# lengths[i] = code length of symbol i (0 = symbol unused)
function z_table_build(table: words, lengths: words, n: int) -> void {
function z_table_build(z: ZInflate, table: words, lengths: words, n: int) -> void {
for i in 0 .. 16 {
table[i] = 0
}
@ -91,7 +130,7 @@ function z_table_build(table: words, lengths: words, n: int) -> void {
}
table[0] = 0 # length 0 means "not present"
# offset of each length's first symbol
let offs: words = words(16)
let offs = z.offs
offs[1] = 0
for l in 1 .. 15 {
offs[l + 1] = offs[l] + table[l]
@ -103,14 +142,13 @@ function z_table_build(table: words, lengths: words, n: int) -> void {
offs[l] += 1
}
}
free(offs)
# ---- the fast lookup ----
for i in 0 .. Z_FASTSZ {
table[16 + i] = 0
}
# first canonical code of each length
let firstc: words = words(17)
let firstc = z.firstc
var code = 0
for l in 1 .. 16 {
code = ((code + table[l - 1]) << 1)
@ -143,17 +181,16 @@ function z_table_build(table: words, lengths: words, n: int) -> void {
k += 1
}
}
free(firstc)
}
function z_decode(rt_inflate_st: mut RtInflateState, table: words) -> int {
z_need(rt_inflate_st, Z_FAST)
if rt_inflate_st.z_bitcnt >= Z_FAST {
let e = table[16 + (rt_inflate_st.z_bitbuf & (Z_FASTSZ - 1))]
function z_decode(z: ZInflate, table: words) -> int {
z_need(z, Z_FAST)
if z.bitcnt >= Z_FAST {
let e = table[16 + (z.bitbuf & (Z_FASTSZ - 1))]
if e != 0 {
let l = (e >> 16)
rt_inflate_st.z_bitbuf = (rt_inflate_st.z_bitbuf >> l)
rt_inflate_st.z_bitcnt -= l
z.bitbuf = (z.bitbuf >> l)
z.bitcnt -= l
return (e & 65535)
}
}
@ -162,7 +199,7 @@ function z_decode(rt_inflate_st: mut RtInflateState, table: words) -> int {
var first = 0
var index = 0
for len in 1 .. 16 {
code = (code | z_bits(rt_inflate_st, 1))
code = (code | z_bits(z, 1))
let count = table[len]
if code - first < count {
return table[Z_SYMS + index + (code - first)]
@ -171,7 +208,7 @@ function z_decode(rt_inflate_st: mut RtInflateState, table: words) -> int {
first = ((first + count) << 1)
code = (code << 1)
}
rt_inflate_st.z_err = 1
z.err = 1
return -1
}
@ -202,49 +239,33 @@ function z_dist_extra(sym: int) -> int {
return (sym - 2) / 2
}
# The RFC tables above are pure functions of the symbol; compute them once rather
# than dividing per match.
function z_tables_once(rt_inflate_st: mut RtInflateState) -> void {
if rt_inflate_st.z_lbase != null { return }
rt_inflate_st.z_lbase = words(29)
rt_inflate_st.z_lext = words(29)
for s in 0 .. 29 {
rt_inflate_st.z_lbase[s] = z_len_base(s)
rt_inflate_st.z_lext[s] = z_len_extra(s)
}
rt_inflate_st.z_dbase = words(30)
rt_inflate_st.z_dext = words(30)
for s in 0 .. 30 {
rt_inflate_st.z_dbase[s] = z_dist_base(s)
rt_inflate_st.z_dext[s] = z_dist_extra(s)
}
}
# The RFC tables above are pure functions of the symbol, computed once per context (z_inflate_new)
# rather than divided per match.
# ---- block decoders -------------------------------------------------------
# `out` is the destination window; returns the new write position, or -1.
function z_stored(rt_inflate_st: mut RtInflateState, out: pointer, at: int, cap: int) -> int {
rt_inflate_st.z_bitbuf = 0
rt_inflate_st.z_bitcnt = 0 # stored blocks are byte-aligned
if rt_inflate_st.z_pos + 4 > rt_inflate_st.z_len { return -1 }
let n = rt_inflate_st.z_src[rt_inflate_st.z_pos] + (rt_inflate_st.z_src[rt_inflate_st.z_pos + 1] << 8)
rt_inflate_st.z_pos += 4 # LEN then its one's complement
function z_stored(z: ZInflate, out: pointer, at: int, cap: int) -> int {
z.bitbuf = 0
z.bitcnt = 0 # stored blocks are byte-aligned
if z.pos + 4 > z.len { return -1 }
let n = z.src[z.pos] + (z.src[z.pos + 1] << 8)
z.pos += 4 # LEN then its one's complement
var w = at
for i in 0 .. n {
if rt_inflate_st.z_pos >= rt_inflate_st.z_len { return -1 }
if z.pos >= z.len { return -1 }
if w >= cap { return -1 }
out[w] = rt_inflate_st.z_src[rt_inflate_st.z_pos]
out[w] = z.src[z.pos]
w += 1
rt_inflate_st.z_pos += 1
z.pos += 1
}
return w
}
function z_codes(rt_inflate_st: mut RtInflateState, out: pointer, at: int, cap: int, lit: words, dist: words) -> int {
function z_codes(z: ZInflate, out: pointer, at: int, cap: int, lit: words, dist: words) -> int {
var w = at
var sym = z_decode(rt_inflate_st, lit)
var sym = z_decode(z, lit)
while sym != 256 {
if rt_inflate_st.z_err != 0 { return -1 }
if z.err != 0 { return -1 }
if sym < 0 { return -1 }
if sym < 256 {
if w >= cap { return -1 }
@ -254,11 +275,11 @@ function z_codes(rt_inflate_st: mut RtInflateState, out: pointer, at: int, cap:
if sym > 256 {
let s = sym - 257
if s >= 29 { return -1 }
let length = rt_inflate_st.z_lbase[s] + z_bits(rt_inflate_st, rt_inflate_st.z_lext[s])
let d = z_decode(rt_inflate_st, dist)
let length = z.lbase[s] + z_bits(z, z.lext[s])
let d = z_decode(z, dist)
if d < 0 { return -1 }
if d >= 30 { return -1 }
let distance = rt_inflate_st.z_dbase[d] + z_bits(rt_inflate_st, rt_inflate_st.z_dext[d])
let distance = z.dbase[d] + z_bits(z, z.dext[d])
if distance > w { return -1 }
if w + length > cap { return -1 } # bounds once, not per byte
var sp = w - distance
@ -270,44 +291,43 @@ function z_codes(rt_inflate_st: mut RtInflateState, out: pointer, at: int, cap:
k += 1
}
}
sym = z_decode(rt_inflate_st, lit)
sym = z_decode(z, lit)
}
return w
}
function z_fixed_tables(lit: words, dist: words) -> void {
let lengths: words = words(288)
function z_fixed_tables(z: ZInflate, lit: words, dist: words) -> void {
let lengths = z.lengths
for i in 0 .. 144 { lengths[i] = 8 }
for i in 144 .. 256 { lengths[i] = 9 }
for i in 256 .. 280 { lengths[i] = 7 }
for i in 280 .. 288 { lengths[i] = 8 }
z_table_build(lit, lengths, 288)
z_table_build(z, lit, lengths, 288)
for i in 0 .. 30 { lengths[i] = 5 }
z_table_build(dist, lengths, 30)
free(lengths)
z_table_build(z, dist, lengths, 30)
}
function z_dynamic_tables(rt_inflate_st: mut RtInflateState, lit: words, dist: words) -> int {
let nlen = z_bits(rt_inflate_st, 5) + 257
let ndist = z_bits(rt_inflate_st, 5) + 1
let ncode = z_bits(rt_inflate_st, 4) + 4
function z_dynamic_tables(z: ZInflate, lit: words, dist: words) -> int {
let nlen = z_bits(z, 5) + 257
let ndist = z_bits(z, 5) + 1
let ncode = z_bits(z, 4) + 4
if nlen > 286 { return 0 }
if ndist > 30 { return 0 }
let lengths: words = words(320)
let lengths = z.lengths
for i in 0 .. 19 { lengths[i] = 0 }
# the code-length alphabet is transmitted in this fixed permutation
# 16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15 — biased by '0' so it is one literal
let order = "@AB08796:5;4<3=2>1?"
for i in 0 .. ncode {
lengths[order[i] - 48] = z_bits(rt_inflate_st, 3)
lengths[order[i] - 48] = z_bits(z, 3)
}
let clen = z_table_new(19)
z_table_build(clen, lengths, 19)
let clen = z.clen
z_table_build(z, clen, lengths, 19)
var n = 0
while n < nlen + ndist {
let sym = z_decode(rt_inflate_st, clen)
let sym = z_decode(z, clen)
if sym < 0 { return 0 }
if sym < 16 {
lengths[n] = sym
@ -319,10 +339,10 @@ function z_dynamic_tables(rt_inflate_st: mut RtInflateState, lit: words, dist: w
if sym == 16 {
if n == 0 { return 0 }
prev = lengths[n - 1]
rep = 3 + z_bits(rt_inflate_st, 2)
rep = 3 + z_bits(z, 2)
}
if sym == 17 { rep = 3 + z_bits(rt_inflate_st, 3) }
if sym == 18 { rep = 11 + z_bits(rt_inflate_st, 7) }
if sym == 17 { rep = 3 + z_bits(z, 3) }
if sym == 18 { rep = 11 + z_bits(z, 7) }
for k in 0 .. rep {
if n < 320 {
lengths[n] = prev
@ -331,51 +351,51 @@ function z_dynamic_tables(rt_inflate_st: mut RtInflateState, lit: words, dist: w
}
}
}
z_table_build(lit, lengths, nlen)
z_table_build(z, lit, lengths, nlen)
# the distance lengths follow the literal ones in the same buffer
let dl: words = words(32)
let dl = z.dl
for i in 0 .. ndist { dl[i] = lengths[nlen + i] }
z_table_build(dist, dl, ndist)
free(dl)
free(lengths)
free(clen)
z_table_build(z, dist, dl, ndist)
return 1
}
# Inflate a raw DEFLATE stream. Returns bytes written, or -1.
function z_inflate(rt_inflate_st: mut RtInflateState, src: pointer, len: int, out: pointer, cap: int) -> int {
z_start(rt_inflate_st, src, len)
let lit = z_table_new(288)
let dist = z_table_new(30)
# Inflate a raw DEFLATE stream in context z. Returns bytes written, or -1.
export function z_inflate_in(z: ZInflate, src: pointer, len: int, out: pointer, cap: int) -> int {
z_start(z, src, len)
let lit = z.lit
let dist = z.dist
var w = 0
var final = 0
while final == 0 {
final = z_bits(rt_inflate_st, 1)
let btype = z_bits(rt_inflate_st, 2)
if rt_inflate_st.z_err != 0 { return -1 }
if btype == 0 { w = z_stored(rt_inflate_st, out, w, cap) }
final = z_bits(z, 1)
let btype = z_bits(z, 2)
if z.err != 0 { return -1 }
if btype == 0 { w = z_stored(z, out, w, cap) }
if btype == 1 {
z_fixed_tables(lit, dist)
w = z_codes(rt_inflate_st, out, w, cap, lit, dist)
z_fixed_tables(z, lit, dist)
w = z_codes(z, out, w, cap, lit, dist)
}
if btype == 2 {
if z_dynamic_tables(rt_inflate_st, lit, dist) == 0 { return -1 }
w = z_codes(rt_inflate_st, out, w, cap, lit, dist)
if z_dynamic_tables(z, lit, dist) == 0 { return -1 }
w = z_codes(z, out, w, cap, lit, dist)
}
if btype == 3 { return -1 }
if w < 0 { return -1 }
}
free(lit)
free(dist)
return w
}
# the program thread's inflate, in the context its state holds
function z_inflate(rt_inflate_st: RtInflateState, src: pointer, len: int, out: pointer, cap: int) -> int { return z_inflate_in(rt_inflate_st.z_ctx, src, len, out, cap) }
function z_uncompress(rt_inflate_st: RtInflateState, src: pointer, len: int, out: pointer, cap: int) -> int { return z_uncompress_in(rt_inflate_st.z_ctx, src, len, out, cap) }
function z_gunzip(rt_inflate_st: RtInflateState, src: pointer, len: int, out: pointer, cap: int) -> int { return z_gunzip_in(rt_inflate_st.z_ctx, src, len, out, cap) }
# zlib wrapper (RFC 1950): two header bytes, then DEFLATE, then Adler-32.
function z_uncompress(rt_inflate_st: mut RtInflateState, src: pointer, len: int, out: pointer, cap: int) -> int {
export function z_uncompress_in(z: ZInflate, src: pointer, len: int, out: pointer, cap: int) -> int {
if len < 2 { return -1 }
let cmf = src[0]
if (cmf & 15) != 8 { return -1 }
return z_inflate(rt_inflate_st, offset(src, 2), len - 2, out, cap)
return z_inflate_in(z, offset(src, 2), len - 2, out, cap)
}
# gzip framing (RFC 1952): a 10-byte header (magic 1f 8b, CM=8, FLG, 4-byte MTIME,
@ -384,7 +404,7 @@ function z_uncompress(rt_inflate_st: mut RtInflateState, src: pointer, len: int,
# the header + optional fields, inflate the body, and ignore the trailer — the
# CRC is a redundancy check, not needed to decode (PNG likewise ignores ancillary
# CRCs). Returns bytes written, or -1.
function z_gunzip(rt_inflate_st: mut RtInflateState, src: pointer, len: int, out: pointer, cap: int) -> int {
export function z_gunzip_in(z: ZInflate, src: pointer, len: int, out: pointer, cap: int) -> int {
if len < 18 { return -1 } # 10 header + 8 trailer minimum
if src[0] != 31 { return -1 } # 0x1f
if src[1] != 139 { return -1 } # 0x8b
@ -406,5 +426,5 @@ function z_gunzip(rt_inflate_st: mut RtInflateState, src: pointer, len: int, out
}
if (flg & 2) != 0 { pos += 2 } # FHCRC: 2-byte header CRC
if pos + 8 > len { return -1 }
return z_inflate(rt_inflate_st, offset(src, pos), len - pos - 8, out, cap)
return z_inflate_in(z, offset(src, pos), len - pos - 8, out, cap)
}

View file

@ -39,6 +39,7 @@ function is_fs_ns(meth: pointer) -> bool {
if (meth == "exists") or (meth == "is_dir") or (meth == "read_text") { return true }
if (meth == "write_text") or (meth == "append_text") or (meth == "remove") { return true }
if (meth == "size") or (meth == "mkdir") or (meth == "copy") or (meth == "list") { return true }
if (meth == "mount") or (meth == "unmount") { return true }
return false
}
function is_path_ns(meth: pointer) -> bool {
@ -55,6 +56,9 @@ function emit_fs_ns(meth: pointer, e: Node) -> Val {
use_pak()
let a = emit_expr(e.kids[0])
if (meth == "exists") { return val(emit_bind(`call i32 @lp_fs_exists(ptr {a.code})`), "bool") }
# a pack (.lpak / .lmap) mounted at run time over the packs.index ones, searched first; and let go
if (meth == "mount") { return val(emit_bind(`call i32 @lp_pak_mount(ptr {a.code})`), "bool") }
if (meth == "unmount") { return val(emit_bind(`call i32 @lp_pak_unmount(ptr {a.code})`), "bool") }
if (meth == "is_dir") { return val(emit_bind(`call i32 @lp_fs_is_dir(ptr {a.code})`), "bool") }
if (meth == "read_text") { return val(emit_bind(`call ptr @lp_fs_read_text(ptr {a.code})`), "string") }
if (meth == "remove") { return val(emit_bind(`call i32 @lp_fs_remove(ptr {a.code})`), "bool") }

View file

@ -96,6 +96,7 @@ function emit_pak_prelude() -> void {
emith("declare i64 @lseek(i32, i64, i32)\n")
if not g_mmap_declared { emith("declare ptr @mmap(ptr, i64, i32, i32, i32, i64)\n"); g_mmap_declared = true }
emith("declare ptr @fmemopen(ptr, i64, ptr)\n")
emith("declare i32 @munmap(ptr, i64)\n")
emith("declare i32 @chdir(ptr)\n")
emith("declare i32 @_NSGetExecutablePath(ptr, ptr)\n")
}
@ -109,6 +110,8 @@ function emit_pak_prelude() -> void {
# up to 8 packs may be mounted; the base pointer of each mapping, in mount order
emith("@L_pak_base = internal global [8 x ptr] zeroinitializer\n")
emith("@L_pak_n = internal global i32 0\n")
emith("@L_pak_len = internal global [8 x i64] zeroinitializer\n") # each mapping's length, for unmount
emith("@L_pak_path = internal global [8 x ptr] zeroinitializer\n") # and the path it was mounted from
# the splash, named by packs.index and served out of the pack like any asset
emith("@L_pak_splash = internal global ptr null\n")
emith("@L_pak_splashbg = internal global i32 0\n")
@ -128,6 +131,7 @@ function emit_pak_prelude() -> void {
}
emit_pak_mount()
emit_pak_unmount()
emit_pak_lookup()
emit_pak_open()
emit_pak_boot()
@ -157,11 +161,36 @@ function emit_pak_mount() -> void {
# "LPAK" read as a little-endian i32
emith("chk:\n %m = load i32, ptr %base\n %okm = icmp eq i32 %m, 1262571596\n br i1 %okm, label %keep, label %clo\n")
emith("keep:\n %slot = getelementptr [8 x ptr], ptr @L_pak_base, i32 0, i32 %n\n store ptr %base, ptr %slot\n")
# its length and path too, so Fs.unmount can find it and give the mapping back
emith(" %lslot = getelementptr [8 x i64], ptr @L_pak_len, i32 0, i32 %n\n store i64 %end, ptr %lslot\n")
emith(" %pcopy = call ptr @lp_fs_strdup(ptr %path)\n %pslot = getelementptr [8 x ptr], ptr @L_pak_path, i32 0, i32 %n\n store ptr %pcopy, ptr %pslot\n")
emith(" %n1 = add i32 %n, 1\n store i32 %n1, ptr @L_pak_n\n %c1 = call i32 @close(i32 %fd)\n ret i32 1\n")
emith("clo:\n %c2 = call i32 @close(i32 %fd)\n ret i32 0\n")
emith("no:\n ret i32 0\n}\n")
}
# unmount(path): the pack mounted from exactly that path given back and taken out of the search, the
# ones after it moving up a slot; 1 when it was mounted. A map pack downloaded at run time comes and
# goes (Fs.mount / Fs.unmount); a FILE* still open over one of its entries must be closed first
function emit_pak_unmount() -> void {
emith("define i32 @lp_pak_unmount(ptr %path) {\n")
emith("entry:\n %n = load i32, ptr @L_pak_n\n %ip = alloca i32\n store i32 0, ptr %ip\n br label %find\n")
emith("find:\n %i = load i32, ptr %ip\n %end = icmp sge i32 %i, %n\n br i1 %end, label %no, label %cmp\n")
emith("cmp:\n %pp = getelementptr [8 x ptr], ptr @L_pak_path, i32 0, i32 %i\n %p = load ptr, ptr %pp\n %isnull = icmp eq ptr %p, null\n br i1 %isnull, label %next, label %str\n")
emith("str:\n %c = call i32 @strcmp(ptr %p, ptr %path)\n %same = icmp eq i32 %c, 0\n br i1 %same, label %drop, label %next\n")
emith("next:\n %i1 = add i32 %i, 1\n store i32 %i1, ptr %ip\n br label %find\n")
emith("drop:\n %bp = getelementptr [8 x ptr], ptr @L_pak_base, i32 0, i32 %i\n %base = load ptr, ptr %bp\n %lp = getelementptr [8 x i64], ptr @L_pak_len, i32 0, i32 %i\n %len = load i64, ptr %lp\n")
if g_target_win { emith(" %u = call i32 @UnmapViewOfFile(ptr %base)\n") } else { emith(" %u = call i32 @munmap(ptr %base, i64 %len)\n") }
emith(" call void @lp_free(ptr %p)\n %jp = alloca i32\n store i32 %i, ptr %jp\n br label %shift\n")
emith("shift:\n %j = load i32, ptr %jp\n %j1 = add i32 %j, 1\n %more = icmp slt i32 %j1, %n\n br i1 %more, label %mv, label %done\n")
emith("mv:\n %sb = getelementptr [8 x ptr], ptr @L_pak_base, i32 0, i32 %j1\n %vb = load ptr, ptr %sb\n %db = getelementptr [8 x ptr], ptr @L_pak_base, i32 0, i32 %j\n store ptr %vb, ptr %db\n")
emith(" %sl = getelementptr [8 x i64], ptr @L_pak_len, i32 0, i32 %j1\n %vl = load i64, ptr %sl\n %dl = getelementptr [8 x i64], ptr @L_pak_len, i32 0, i32 %j\n store i64 %vl, ptr %dl\n")
emith(" %sp = getelementptr [8 x ptr], ptr @L_pak_path, i32 0, i32 %j1\n %vp = load ptr, ptr %sp\n %dp = getelementptr [8 x ptr], ptr @L_pak_path, i32 0, i32 %j\n store ptr %vp, ptr %dp\n")
emith(" store i32 %j1, ptr %jp\n br label %shift\n")
emith("done:\n %n1 = sub i32 %n, 1\n store i32 %n1, ptr @L_pak_n\n %last = getelementptr [8 x ptr], ptr @L_pak_path, i32 0, i32 %n1\n store ptr null, ptr %last\n ret i32 1\n")
emith("no:\n ret i32 0\n}\n")
}
# ---- lookup -----------------------------------------------------------------
function emit_pak_lookup() -> void {

View file

@ -62,6 +62,7 @@ function emit_win_prelude() -> void {
emith("declare i32 @FindClose(ptr)\n")
emith("declare ptr @CreateFileMappingA(ptr, ptr, i32, i32, i32, ptr)\n")
emith("declare ptr @MapViewOfFile(ptr, i32, i32, i32, i64)\n")
emith("declare i32 @UnmapViewOfFile(ptr)\n")
emith("declare i32 @CloseHandle(ptr)\n")
emith("declare i32 @GetModuleFileNameA(ptr, ptr, i32)\n")
emith("declare i32 @GetTempPathA(i32, ptr)\n")

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -280,7 +280,11 @@ entry {
# -Wno-override-module: the emitted IR carries no target triple, so clang
# substitutes the host's and warns about it on every single build. There is
# nothing to act on — the host default is exactly what we want — so it is off.
var cmd = `{cc} -O2 -Wno-override-module {ll}`
# $LUDIC_OPT=1 is a developer's faster build (never a bundle's: ludic bundle sets 2)
let opt = getenv_or("LUDIC_OPT", "2")
let objs = split_compile(ll, out, opt) # on every core when it can (split_build.ludic)
var cmd = `{cc} -O{opt} -Wno-override-module {ll}`
if objs != null { cmd = `{cc} -O{opt} -Wno-override-module{objs}` }
if g_target_win {
# The Windows window: win32.ll (user32, gdi32 for the software present, xinput
# for pads), and audio_win.ll when the program uses Audio.*, as on macOS.
@ -389,6 +393,7 @@ entry {
var line = cmd
if host_is_windows() { line = `"{cmd}"` }
let rc = run(line)
if objs != null { split_clean(out) }
if not save { remove_file(ll) }
if rc != 0 { die("ludicc: link failed\n") }
if natives_out != null {

View file

@ -0,0 +1,87 @@
# split_build.ludic - the program's IR compiled on every core: llvm-split cuts it into N parts, one
# clang each runs at once, and the link takes their objects where it took the one .ll. Most of a
# build was one clang -O2 on one file (the game: 32 s of 41). Needs an llvm-split and a clang of
# the same LLVM (a newer LLVM's parts do not read in an older clang): $LUDIC_LLVM (a bin directory),
# else Homebrew's. Anything missing, or a part that does not compile, falls back to the one clang.
function split_there(p: pointer) -> bool {
let f = file_open(p, "rb")
if f == null { return false }
file_close(f)
return true
}
# the LLVM bin directory to split with, or null (Windows' shell cannot run the parts at once yet)
function split_dir() -> pointer {
if host_is_windows() { return null }
if getenv_or("LUDIC_SPLIT", "1") == "0" { return null }
let d = getenv_or("LUDIC_LLVM", "/opt/homebrew/opt/llvm/bin")
if not split_there(`{d}/llvm-split`) or not split_there(`{d}/clang`) { return null }
return d
}
# how many parts: $LUDIC_JOBS, else the cores, no more than the free memory allows at 1.5 GB a clang
function split_jobs(tmp: pointer) -> int {
let j = getenv("LUDIC_JOBS")
if j != null { return split_int(j) }
let probe = `{tmp}.cores`
let aw = "awk '/Pages free/{f=$3} /Pages inactive/{i=$3} END{print int((f+i)*p/1073741824)}'"
run(`echo $(sysctl -n hw.ncpu) $(vm_stat | {aw} p=$(sysctl -n hw.pagesize)) > "{probe}" 2>/dev/null`)
let f = file_open(probe, "rb")
if f == null { return 1 }
let buf = bytes(64)
let n = file_read(f, buf, 63)
file_close(f)
remove_file(`"{probe}"`)
if n <= 0 { return 1 }
buf[n] = 0
var cores = 0
var i = 0
while i < n and buf[i] >= '0' and buf[i] <= '9' { cores = cores * 10 + (buf[i] - '0'); i += 1 }
while i < n and buf[i] == ' ' { i += 1 }
var gb = 0
while i < n and buf[i] >= '0' and buf[i] <= '9' { gb = gb * 10 + (buf[i] - '0'); i += 1 }
let by_mem = (gb * 2) / 3
if by_mem < cores { return by_mem }
return cores
}
function split_int(s: pointer) -> int {
var v = 0
var i = 0
while i < len(s) and s[i] >= '0' and s[i] <= '9' { v = v * 10 + (s[i] - '0'); i += 1 }
return v
}
# the IR at `ll` compiled in parts at -O`opt`: their objects, quoted and space-separated, for the
# link to take in place of the .ll; null when it was not split (the caller compiles the .ll whole)
function split_compile(ll: pointer, out: pointer, opt: pointer) -> pointer {
let d = split_dir()
if d == null { return null }
let n = split_jobs(out)
if n < 2 { return null }
let pre = `{out}.part`
if run(`"{d}/llvm-split" -j {n} -o "{pre}" "{ll}"`) != 0 { return null }
var sh = ""
var i = 0
while i < n {
sh = sh + `"{d}/clang" -x ir -O{opt} -mmacosx-version-min=11.0 -Wno-override-module -Wno-missing-sysroot -c "{pre}{itoa(i)}" -o "{pre}{itoa(i)}.o" & `
i += 1
}
run(`/bin/sh -c '{sh}wait'`)
var objs = ""
var ok = true
i = 0
while i < n {
remove_file(`"{pre}{itoa(i)}"`)
if not split_there(`{pre}{itoa(i)}.o`) { ok = false }
objs = objs + ` "{pre}{itoa(i)}.o"`
i += 1
}
if not ok {
split_clean(out)
return null
}
return objs
}
# the parts' objects, once linked (or when a part failed)
function split_clean(out: pointer) -> void { run(`rm -f "{out}".part*.o`) }

View file

@ -54,7 +54,12 @@ function compile_app(src: pointer, out: pointer, mode: int, save: bool) -> bool
if mode == 2 {
if not shq(`{ludicc()} --headless{title_flag()}{unsafe_flag()} {src} --emit-llvm -o {ll}`) { return false }
if not shq(`{cc()} -O2 {ll}{gl_link_flags(ll)}{vk_link_flags(ll)}{http_link_flags(ll)}{udp_link_flags(ll)}{process_link_flags(ll)}{threads_link_flags(ll)}{native_link_flags(ll)}{pbf} -o {out}`) { return false }
let objs = split_objs(ll, out) # on every core when it can (split.ludic)
var input = ll
if objs != "" { input = objs }
let linked = shq(`{cc()} -O{split_opt()} {input}{gl_link_flags(ll)}{vk_link_flags(ll)}{http_link_flags(ll)}{udp_link_flags(ll)}{process_link_flags(ll)}{threads_link_flags(ll)}{native_link_flags(ll)}{pbf} -o {out}`)
if objs != "" { shell(`rm -f {objs}`) }
if not linked { return false }
g_built_natives = native_libs_of(ll)
if not save { shell(`rm -f {ll}`) }
return true
@ -65,7 +70,12 @@ function compile_app(src: pointer, out: pointer, mode: int, save: bool) -> bool
# canonical `ludicc -o` path links it only when Audio.* is used.
let cocoa = `{home}runtime/native/cocoa.ll`
let audio = `{home}runtime/native/audio.ll`
if not shq(`{cc()} -O2 {ll} {cocoa} {audio} -framework Cocoa -Wl,-needed_framework,GameController -Wl,-needed_framework,AVFoundation -Wl,-rpath,@loader_path{gl_link_flags(ll)}{vk_link_flags(ll)}{http_link_flags(ll)}{udp_link_flags(ll)}{process_link_flags(ll)}{threads_link_flags(ll)}{native_link_flags(ll)}{pbf} -o {out}`) { return false }
let objs = split_objs(ll, out)
var input = ll
if objs != "" { input = objs }
let linked = shq(`{cc()} -O{split_opt()} {input} {cocoa} {audio} -framework Cocoa -Wl,-needed_framework,GameController -Wl,-needed_framework,AVFoundation -Wl,-rpath,@loader_path{gl_link_flags(ll)}{vk_link_flags(ll)}{http_link_flags(ll)}{udp_link_flags(ll)}{process_link_flags(ll)}{threads_link_flags(ll)}{native_link_flags(ll)}{pbf} -o {out}`)
if objs != "" { shell(`rm -f {objs}`) }
if not linked { return false }
g_built_natives = native_libs_of(ll)
if not save { shell(`rm -f {ll}`) }
return true

View file

@ -26,6 +26,7 @@
# app category "public.app-category.adventure-games"
# app copyright "(c) 2026 Workshopsoft"
# app min_macos "12.0"
# app env "MallocLargeCache=0" (repeatable: LSEnvironment, what Launch Services starts it with)
#
# Only `app name` is really needed; everything else has a defensible default
# derived from the manifest.
@ -187,6 +188,7 @@ function info_plist(m: Manifest, exe: pointer, has_icon: bool) -> pointer {
# A game renders at the display's real resolution; without this the window is
# upscaled from 1x and everything drawn in it is soft on any Retina screen.
s = s + plist_bool("NSHighResolutionCapable", true)
s = s + plist_env(m)
s = s + "</dict>" + nl()
s = s + "</plist>" + nl()
return s
@ -204,6 +206,24 @@ function plist_str(k: pointer, v: pointer) -> pointer {
return ` <key>{k}</key>` + nl() + ` <string>{xml_escape(v)}</string>` + nl()
}
# every `app env "K=V"`: the environment Launch Services starts the app with (Finder, `open`, the
# Dock) - a switch a library reads only as a process starts, such as libmalloc's MallocLargeCache
function plist_env(m: Manifest) -> pointer {
var body = ""
var i = 0
while i + 1 < len(m.app) {
if m.app[i] == "env" {
let kv = m.app[i + 1]
var eq = 0
while eq < len(kv) and kv[eq] != '=' { eq += 1 }
if eq > 0 and eq < len(kv) { body = body + plist_str(kv[0..eq], kv[eq + 1..len(kv)]) }
}
i += 2
}
if body == "" { return "" }
return " <key>LSEnvironment</key>" + nl() + " <dict>" + nl() + body + " </dict>" + nl()
}
function plist_bool(k: pointer, v: bool) -> pointer {
var t = "<false/>"
if v { t = "<true/>" }
@ -337,6 +357,7 @@ function cmd_bundle_windows() -> int {
let staged = tmp_path("bundle_exe")
var flags = "--windowed --gui"
if res != "" { flags = flags + ` --link "{res}"` }
Os.set_env("LUDIC_OPT", "2") # a shipped game is -O2, whatever a developer's shell says
if not shq(`{ludicc()} {flags}{title_flag()} {entry} --natives-out {staged}.natives -o {staged}`) { return 1 }
if not shq(`cp "{staged}.exe" "{root}/{name}.exe"`) {
err("ludic bundle: could not place the executable\n")

View file

@ -20,6 +20,7 @@
program LudicDev {
import "prelude.ludic"
import "build.ludic"
import "split.ludic"
import "toolchain.ludic"
import "selfhost.ludic"
import "tools.ludic"

View file

@ -22,6 +22,7 @@
program Ludic {
import "prelude.ludic"
import "build.ludic"
import "split.ludic"
import "project.ludic"
import "pkg.ludic"
import "assets.ludic"

View file

@ -109,6 +109,7 @@ function selfhost_frags() -> []pointer {
push(f, "selfhost/backend/stdlib/emit_net.ludic")
push(f, "selfhost/backend/game/emit_ui.ludic")
push(f, "selfhost/backend/emit_decl.ludic")
push(f, "selfhost/split_build.ludic")
push(f, "selfhost/main.ludic")
return f
}

View file

@ -0,0 +1,49 @@
# split.ludic - `ludic build` on every core: the program's IR cut into N parts (llvm-split), a clang
# each at once, their objects linked where the one .ll was (the game: clang was 32 s of a 41 s
# build, on one core). ludicc's own link does the same (selfhost/split_build.ludic). It needs an
# llvm-split and a clang of one LLVM - $LUDIC_LLVM, else Homebrew's - or it compiles the .ll whole.
function split_opt() -> pointer { return getenv_or("LUDIC_OPT", "2") } # 1: a developer's faster build
# " a.o b.o ..." for the link, or "" when not split (the caller compiles the .ll as before)
function split_objs(ll: pointer, out: pointer) -> pointer {
if host_windows() or getenv_or("LUDIC_SPLIT", "1") == "0" { return "" }
let d = getenv_or("LUDIC_LLVM", "/opt/homebrew/opt/llvm/bin")
if not is_exec(`"{d}/llvm-split"`) or not is_exec(`"{d}/clang"`) { return "" }
let n = split_n()
if n < 2 { return "" }
let pre = tmp_path(`{file_of_path(out)}.part`)
if not shq(`"{d}/llvm-split" -j {n} -o "{pre}" "{ll}"`) { return "" }
var sh = ""
for i in 0 .. n {
sh = sh + `"{d}/clang" -x ir -O{split_opt()} -mmacosx-version-min=11.0 -Wno-override-module -Wno-missing-sysroot -c "{pre}{i}" -o "{pre}{i}.o" & `
}
shell(`{sh}wait`)
var objs = ""
var ok = true
for i in 0 .. n {
shell(`rm -f "{pre}{i}"`)
if not file_exists(`"{pre}{i}.o"`) { ok = false }
objs = objs + ` "{pre}{i}.o"`
}
if ok { return objs }
shell(`rm -f {objs}`)
return ""
}
# $LUDIC_JOBS, else the cores - no more than the free memory allows at 1.5 GB a clang
function split_n() -> int {
let j = getenv("LUDIC_JOBS")
if j != null { return s_to_int(j) }
let aw = "awk '/Pages free/{f=$3} /Pages inactive/{i=$3} END{print int((f+i)*p/1073741824)}'"
let line = capture_line(`echo $(sysctl -n hw.ncpu) $(vm_stat | {aw} p=$(sysctl -n hw.pagesize))`)
var cores = 0
var i = 0
while i < len(line) and line[i] >= '0' and line[i] <= '9' { cores = cores * 10 + (line[i] - '0'); i += 1 }
while i < len(line) and line[i] == ' ' { i += 1 }
var gb = 0
while i < len(line) and line[i] >= '0' and line[i] <= '9' { gb = gb * 10 + (line[i] - '0'); i += 1 }
let by_mem = (gb * 2) / 3
if by_mem < cores { return by_mem }
return cores
}

View file

@ -370,6 +370,9 @@ function bundle_case() -> void {
# the identifier is derived from the package path when the manifest omits it
let id = capture_line(`plutil -extract CFBundleIdentifier raw "{app}/Contents/Info.plist" 2>/dev/null`)
if id != "io.workshopsoft.probe-app" { bad2(lbl, `bundle id came out [{id}]`); return }
# `app env` is what Launch Services starts it with (a Finder or `open` start)
let env = capture_line(`plutil -extract LSEnvironment.MallocLargeCache raw "{app}/Contents/Info.plist" 2>/dev/null`)
if env != "0" { bad2(lbl, `LSEnvironment.MallocLargeCache came out [{env}]`); return }
if not shq(`codesign --verify "{app}" > /dev/null 2>&1`) { bad2(lbl, "the signature does not verify"); return }
# and it runs from a directory with none of its assets, reading them from the pack
let got = s_trim(capture(`cd / && "{app}/Contents/MacOS/Probe App" 2>&1`))
@ -388,6 +391,7 @@ function bundle_manifest_src() -> pointer {
s = s + "app name \"Probe App\"" + nl()
s = s + "app icon \"assets/icon.png\"" + nl()
s = s + "app copyright \"(c) 2026 A & B\"" + nl()
s = s + "app env \"MallocLargeCache=0\"" + nl()
s = s + "pack \"assets\"" + nl()
return s
}
@ -1248,6 +1252,8 @@ function cmd_dev_test() -> int {
controller_case("library/ui_emit_click", "", "got 2 | pressed: got 1", "ui_emit_click.ludic (L11: a component's `emit click` reaches its user's on-click, by the mouse and by a press)")
controller_case("library/ui_select_arrows", "", "prev 0 next next 2", "ui_select_arrows.ludic (L11: a select's < steps back and its > forward)")
controller_case("library/ui_pointer_through", "", "pressed 1, hovered #4a5058; > hovered 1", "ui_pointer_through.ludic (L11: the pointer looks through a root with no size to a positioned panel's button; a select's > answers :hover)")
controller_case("library/ui_first_show", "", "bad 0 shown 3", "ui_first_show.ludic (L11: a component first shown once the fence is judging makes its props and model declared, once per instance; shown again, it is reused)")
controller_case("library/ui_select_disabled", "", "clamped 2 next-on 0 next 0 prev 2", "ui_select_disabled.ludic (L11: <option disabled>: a value on one is set to the nearest enabled, the arrow toward them drawn disabled, the arrows step past them)")
controller_case("library/ui_submit", "", "[hi] chat '' | [hi][yo][Ada] chat '' name 'Ada'", "ui_submit.ludic (L11: on-submit on a text field by Enter or A, clear-on-submit, the focus kept)")
controller_case("library/ui_zoom", "", "plain 100 pad 10 text 16 h 20 | half 50 pad 5 text 8 h 10 | half of twice 100 | fit 50 em 16", "ui_zoom.ludic (L11: zoom scales a subtree's lengths, text and layout, multiplies, and takes min() and a length over a length)")
controller_case("library/ui_hold", "", "31 frames 500 ms t 500 | 121 frames 500 ms t 500 | pad 16 frames 250 ms t 250 | dududu", "ui_hold.ludic (L11: on-hold every frame a button is held, by the pointer or A, with dt and t on the clock at any rate)")