From 54e267dfa6cbcbe28f42072e9fd1130b86ad5b19 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Wed, 30 Sep 2026 15:24:23 +0300 Subject: [PATCH] render3d: ground_fill's conformance test and the generator of its golden tests/ground_fill_test.ludic compares gg_json (gf_hash over fixed inputs, Math.lerp cases whose order matters, the yaw as model.vert turns by it, a hand-written density, one cover chunk with a clearing and one solid chunk) line for line with tests/ground_fill_golden.json, and checks ground_fill draws exactly the candidates and a solid layer's far bands a subset of band 0. The golden is written by tests/gen/ground_fill_golden.ludic, run from the repository root; it is not committed here. Co-Authored-By: Claude Opus 5.5 --- .../tests/fakes/ground_golden.ludic | 96 +++++++++++++++++++ .../tests/fakes/ground_golden_check.ludic | 21 ++++ .../tests/fakes/ground_golden_json.ludic | 66 +++++++++++++ .../tests/fakes/ground_golden_things.ludic | 65 +++++++++++++ .../tests/gen/ground_fill_golden.ludic | 21 ++++ .../tests/ground_fill_test.ludic | 93 ++++++++++++++++++ 6 files changed, 362 insertions(+) create mode 100644 packages/ludic.render3d/tests/fakes/ground_golden.ludic create mode 100644 packages/ludic.render3d/tests/fakes/ground_golden_check.ludic create mode 100644 packages/ludic.render3d/tests/fakes/ground_golden_json.ludic create mode 100644 packages/ludic.render3d/tests/fakes/ground_golden_things.ludic create mode 100644 packages/ludic.render3d/tests/gen/ground_fill_golden.ludic create mode 100644 packages/ludic.render3d/tests/ground_fill_test.ludic diff --git a/packages/ludic.render3d/tests/fakes/ground_golden.ludic b/packages/ludic.render3d/tests/fakes/ground_golden.ludic new file mode 100644 index 00000000..87d76cde --- /dev/null +++ b/packages/ludic.render3d/tests/fakes/ground_golden.ludic @@ -0,0 +1,96 @@ +# fakes/ground_golden.ludic - ground_fill's conformance fixture: one density layer written by hand (R and G, a +# block of raw tiles round the chunk, every other tile empty), a cover layer with a clearing and a solid layer +# grown over it, and chunk (3, -2) of a 32 m grid from world zero - across four tiles, at negative z. +const GG_CX: int = 3 +const GG_CZ: int = -2 +const GG_BAND: int = 1 # the cover layer's band: a step that does not divide the chunk, and band_grow +const GG_T0: int = 64 # the raw tiles: x 64..67, z 61..64 (the chunk reads x 65..66, z 62..63) +const GG_Z0: int = 61 +const GG_TN: int = 4 + +# the density's texels as written, 0..255: R ramps through empty and full, G is a weave +function gg_r(tx: int, tz: int) -> int { return min(max((tx * 53 + tz * 97) % 320 - 32, 0), 255) } +function gg_g(tx: int, tz: int) -> int { return (tx * 29 + tz * 13) & 255 } + +function gg_put(b: []byte, at: int, v: int) -> void { + for k in 0 .. 4 { b[at + k] = (v >> (k * 8)) & 255 } +} + +# an LGD2 payload: one layer of two channels, the raw tiles after the index +function gg_payload() -> []byte { + let n = TERRAIN_RES / TT_TEX + let tb = GD_SIDE * GD_SIDE * 2 + let at0 = GD_HEADW * 4 + n * n * 8 + let size = at0 + GG_TN * GG_TN * tb + let b = buffer(size) + for k in 0 .. size { b[k] = 0 } + gg_put(b, 0, GD_MAGIC) + gg_put(b, 4, GD_VERSION) + gg_put(b, 8, n) + gg_put(b, 12, GD_SIDE) + gg_put(b, 16, 1) + gg_put(b, 32, 2) + gg_put(b, 36, GD_HEADW * 4) + for q in 0 .. GG_TN * GG_TN { + let tx = GG_T0 + q % GG_TN + let tz = GG_Z0 + q / GG_TN + let at = at0 + q * tb + gg_put(b, GD_HEADW * 4 + (tz * n + tx) * 8, at) + gg_put(b, GD_HEADW * 4 + (tz * n + tx) * 8 + 4, tb) + for p in 0 .. GD_SIDE * GD_SIDE { + let x = tx * GD_SIDE + p % GD_SIDE + let z = tz * GD_SIDE + p / GD_SIDE + b[at + p * 2] = gg_r(x, z) + b[at + p * 2 + 1] = gg_g(x, z) + } + } + return b +} + +# an 8 km map at world zero with no terrain (heights 0), the densities above, and the one clearing +function gg_open(render3d_st: mut Render3dState, tag: string) -> bool { + render3d_st.TERRAIN_HALF = 4096 + render3d_st.ter_ox = 0.0 + render3d_st.ter_oz = 0.0 + render3d_st.gd_trample_cb = null + r3d_ground_clearings_reset(render3d_st) + r3d_ground_clearing(render3d_st, 112.0, -48.0, 3.0, 7.5, 0.25) + let path = Os.temp_dir() + "/r3d_ground_golden_" + tag + ".lgd2" + let b = gg_payload() + return Fs.write_bytes(path, b, len(b)) and gd_open_at(render3d_st, path, 0) +} + +function gg_cover() -> GroundFill { + let g = new GroundFill + g.step0 = 2.0 + g.step1 = 2.5 + g.scale_lo = 0.6 + g.scale_hi = 1.4 + g.wind_lo = 0.5 + g.scale_var = 0.2 + g.band_grow = 0.15 + return g +} + +function gg_solid() -> GroundFill { + let g = gg_cover() + g.step0 = 4.0 + g.step1 = 5.0 + g.step2 = 8.0 + g.step3 = 12.0 + g.jitter = 0.8 + g.scale_lo = 0.8 + g.scale_hi = 1.6 + g.band_grow = 0.3 + g.trample = false + g.solid = true + return g +} + +function gg_stream(render3d_st: mut Render3dState) -> Stream { + if render3d_st.stream_scratch == null { render3d_st.stream_scratch = floats(4096 * INST_FLOATS) } + let s = new Stream + s.size = 32.0 + s.cur = new Chunk + return s +} diff --git a/packages/ludic.render3d/tests/fakes/ground_golden_check.ludic b/packages/ludic.render3d/tests/fakes/ground_golden_check.ludic new file mode 100644 index 00000000..289e95bb --- /dev/null +++ b/packages/ludic.render3d/tests/fakes/ground_golden_check.ludic @@ -0,0 +1,21 @@ +# fakes/ground_golden_check.ludic - what ground_fill_test compares drawn instances by + +# how far drawn instance k is from candidate c: 0 when every number is the same bits +function gft_off(render3d_st: Render3dState, k: int, c: GroundCand) -> int { + let o = k * INST_FLOATS + let st = render3d_st.stream_scratch + if st[o] != c.x or st[o + 2] != c.z or st[o + 3] != c.scale or st[o + 6] != c.seed or st[o + 7] != c.wind { return 1 } + if st[o + 4] != Math.sin(c.yaw) or st[o + 5] != Math.cos(c.yaw) { return 1 } + return 0 +} + +# drawn instances not at a listed thing's place +function gft_subset(render3d_st: Render3dState, n: int, l: GroundSolids) -> int { + var bad = 0 + for k in 0 .. n { + var hit = false + for q in 0 .. l.n { hit = hit or (render3d_st.stream_scratch[k * INST_FLOATS] == l.xs[q] and render3d_st.stream_scratch[k * INST_FLOATS + 2] == l.zs[q]) } + if not hit { bad += 1 } + } + return bad +} diff --git a/packages/ludic.render3d/tests/fakes/ground_golden_json.ludic b/packages/ludic.render3d/tests/fakes/ground_golden_json.ludic new file mode 100644 index 00000000..229846e5 --- /dev/null +++ b/packages/ludic.render3d/tests/fakes/ground_golden_json.ludic @@ -0,0 +1,66 @@ +# fakes/ground_golden_json.ludic - the golden's first half: every float is its IEEE single bits as an int, so +# the file round-trips exactly; Math.lerp's order, the yaw the model shader turns by, gf_hash and the density. + +function gg_b(x: float) -> string { return `{float_bits(x)}` } + +# Math.lerp is a + (b - a) * t; each case here is one a * (1 - t) + b * t would round differently +function gg_lerps() -> string { + let a = [1.0, 2.0, 6.04, -2.19] + let b = [1.7, 7.0, 1.72, 3.62] + let t = [0.9, 0.71, 0.639, 0.868] + var s = " \"lerp_order\": \"a + (b - a) * t\",\n \"lerp\": [\n" + for k in 0 .. 4 { + let other = a[k] * (1.0 - t[k]) + b[k] * t[k] + s = s + ` [{gg_b(a[k])}, {gg_b(b[k])}, {gg_b(t[k])}, {gg_b(Math.lerp(a[k], b[k], t[k]))}, {gg_b(other)}]` + if k < 3 { s = s + "," } + s = s + "\n" + } + return s + " ],\n" +} + +# model.vert: p' = (c x + s z, y, -s x + c z) with (s, c) = (sin yaw, cos yaw) as stream_emit stores them +function gg_yaw() -> string { + let y = 0.5 + let sn = Math.sin(y) + let cs = Math.cos(y) + var s = ` "yaw": {{"yaw": {gg_b(y)}, "sin": {gg_b(sn)}, "cos": {gg_b(cs)}, "rule": "p' = (c*x + s*z, y, -s*x + c*z)",\n` + s = s + ` "x_axis": {gg_turn(sn, cs, 1.0, 0.0)}, "z_axis": {gg_turn(sn, cs, 0.0, 1.0)}}},\n` + return s +} + +# where local (x, 0, z) lands, as the shader turns it +function gg_turn(sn: float, cs: float, x: float, z: float) -> string { return `[{gg_b(cs * x + sn * z)}, {gg_b(0.0)}, {gg_b(-sn * x + cs * z)}]` } + +function gg_hashes() -> string { + let v = [0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 0, 3, -2, 1, 7 * 65536 + 9, 2, 13, -128, 127, 3, 32767, 5, + 63, -1, -1, 0, 21 * 65536 + 21, 7, -5, 100000, -100000, 2, 123456789, 1, 2147483647, -2147483647, 65536, 3, -1, 4] + var s = " \"hash\": [\n" + let n = len(v) / 6 + for r in 0 .. n { + let o = r * 6 + s = s + ` [{v[o]}, {v[o + 1]}, {v[o + 2]}, {v[o + 3]}, {v[o + 4]}, {v[o + 5]}, {gg_b(gf_hash(v[o], v[o + 1], v[o + 2], v[o + 3], v[o + 4], v[o + 5]))}]` + if r < n - 1 { s = s + "," } + s = s + "\n" + } + return s + " ],\n" +} + +# the texels the chunk's bilinear reads (and a border), R and G row by row from (tx0, tz0); none outside is read +function gg_density() -> string { + let tx0 = 2094 + let tz0 = 2014 + let w = 20 + var r = "" + var g = "" + for k in 0 .. w * w { + if k > 0 { + r = r + "," + g = g + "," + } + r = r + `{gg_r(tx0 + k % w, tz0 + k / w)}` + g = g + `{gg_g(tx0 + k % w, tz0 + k / w)}` + } + var s = ` "density": {{"layer": 0, "half": 4096, "ter_ox": 0, "ter_oz": 0, "texel_m": 2, "rule": "fx = (x - ter_ox + half) / texel - 0.5, bilinear, / 255",\n` + s = s + ` "tx0": {tx0}, "tz0": {tz0}, "w": {w},\n "r": [{r}],\n "g": [{g}]}},\n` + return s +} diff --git a/packages/ludic.render3d/tests/fakes/ground_golden_things.ludic b/packages/ludic.render3d/tests/fakes/ground_golden_things.ludic new file mode 100644 index 00000000..6e31d0a3 --- /dev/null +++ b/packages/ludic.render3d/tests/fakes/ground_golden_things.ludic @@ -0,0 +1,65 @@ +# fakes/ground_golden_things.ludic - the golden's second half: the clearing, the layers' numbers and what each +# grows in the chunk (a cover layer at GG_BAND, a solid layer at band 0 with what band 2 still draws) + +function gg_fill(g: GroundFill) -> string { + var s = `{{"layer": {g.layer}, "steps": [{gg_b(g.step0)}, {gg_b(g.step1)}, {gg_b(g.step2)}, {gg_b(g.step3)}], "jitter": {gg_b(g.jitter)}, ` + s = s + `"scale_lo": {gg_b(g.scale_lo)}, "scale_hi": {gg_b(g.scale_hi)}, "wind_lo": {gg_b(g.wind_lo)}, "wind_hi": {gg_b(g.wind_hi)}, ` + s = s + `"scale_var": {gg_b(g.scale_var)}, "band_grow": {gg_b(g.band_grow)}, "sink": {gg_b(g.sink)}, "trample": {g.trample}, "solid": {g.solid}}}` + return s +} + +function gg_clearings(render3d_st: Render3dState) -> string { + var s = " \"clearings_rule\": \"floor + (1 - floor) * smoothstep((d - r_in) / (r_out - r_in)), 1 past r_out\",\n \"clearings\": [" + for c in 0 .. render3d_st.gd_clear_n { + let o = c * 5 + let cl = render3d_st.gd_clear + if c > 0 { s = s + ", " } + s = s + `[{gg_b(cl[o])}, {gg_b(cl[o + 1])}, {gg_b(cl[o + 2])}, {gg_b(cl[o + 3])}, {gg_b(cl[o + 4])}]` + } + return s + "],\n" +} + +function gg_thing(c: GroundCand) -> string { return `{gg_b(c.x)}, {gg_b(c.z)}, {gg_b(c.scale)}, {gg_b(c.yaw)}, {gg_b(c.seed)}, {gg_b(c.wind)}` } + +# the cover layer's things in cell order: [cell, x, z, scale, yaw, seed, wind] +function gg_cover_json(render3d_st: mut Render3dState) -> string { + let g = gg_cover() + let size = 32.0 + let cells = int(Math.ceil(size / gf_step(g, GG_BAND))) + let c = new GroundCand + var s = ` "cover": {{"fill": {gg_fill(g)}, "ox": 0, "oz": 0, "size": {gg_b(size)}, "cx": {GG_CX}, "cz": {GG_CZ}, "band": {GG_BAND}, "things": [\n` + var first = true + for j in 0 .. cells { + for i in 0 .. cells { + if not ground_candidate(render3d_st, g, GG_CX, GG_CZ, GG_BAND, i, j, float(GG_CX) * size, float(GG_CZ) * size, size, c) { continue } + if not first { s = s + ",\n" } + first = false + s = s + ` [{c.cell}, {gg_thing(c)}]` + } + } + return s + "\n ]}},\n" +} + +# the solid layer's things: [id, cell, x, z, scale, yaw, seed, wind, drawn at band 2] +function gg_solid_json(render3d_st: mut Render3dState) -> string { + let g = gg_solid() + let l = ground_solids_new(256) + ground_solid_list(render3d_st, g, 0.0, 0.0, 32.0, GG_CX, GG_CZ, l) + let c = new GroundCand + var s = ` "solid": {{"fill": {gg_fill(g)}, "ox": 0, "oz": 0, "size": {gg_b(32.0)}, "cx": {GG_CX}, "cz": {GG_CZ}, "far_band": 2, "things": [\n` + for k in 0 .. l.n { + ground_solid_at(render3d_st, g, 0.0, 0.0, 32.0, l.ids[k], c) + let far = gf_hash(g.layer, GG_CX, GG_CZ, 0, c.cell, 7) < gs_thin(g, 2) + if k > 0 { s = s + ",\n" } + s = s + ` [{c.id}, {c.cell}, {gg_thing(c)}, {far}]` + } + return s + "\n ]}}\n" +} + +# the whole golden, as ground_fill_golden.json holds it +function gg_json(render3d_st: mut Render3dState) -> string { + var s = "{\n \"about\": \"ludic.render3d ground_fill conformance (tests/ground_fill_test.ludic; regenerate with tests/gen/ground_fill_golden.ludic)\",\n" + s = s + " \"numbers\": \"every float is its IEEE-754 single-precision bits as a signed 32-bit int\",\n" + s = s + gg_lerps() + gg_yaw() + gg_hashes() + gg_density() + gg_clearings(render3d_st) + return s + gg_cover_json(render3d_st) + gg_solid_json(render3d_st) + "}\n" +} diff --git a/packages/ludic.render3d/tests/gen/ground_fill_golden.ludic b/packages/ludic.render3d/tests/gen/ground_fill_golden.ludic new file mode 100644 index 00000000..4a6941f0 --- /dev/null +++ b/packages/ludic.render3d/tests/gen/ground_fill_golden.ludic @@ -0,0 +1,21 @@ +# gen/ground_fill_golden.ludic - writes tests/ground_fill_golden.json from what ground_fill grows today. Run it +# from the repository root after a deliberate change to the fill, and commit the file with the change. +import "ludic.render3d/r3d.ludic" +program GroundFillGolden { + numbers float + import "../fakes/ground_golden.ludic" + import "../fakes/ground_golden_json.ludic" + import "../fakes/ground_golden_things.ludic" + # never called: naming Input links the engine's runtime, which render3d's textures stand on + function links_runtime() -> bool { return Input.key_pressed(0) } + + entry (render3d_st: mut Render3dState) { + if not gg_open(render3d_st, "write") { + print("ground_fill_golden: the density could not be written") + return + } + let path = "packages/ludic.render3d/tests/ground_fill_golden.json" + let text = gg_json(render3d_st) + if Fs.write_text(path, text) { print(`ground_fill_golden: wrote {path}`) } else { print(`ground_fill_golden: could not write {path}`) } + } +} diff --git a/packages/ludic.render3d/tests/ground_fill_test.ludic b/packages/ludic.render3d/tests/ground_fill_test.ludic new file mode 100644 index 00000000..ee749368 --- /dev/null +++ b/packages/ludic.render3d/tests/ground_fill_test.ludic @@ -0,0 +1,93 @@ +# ground_fill_test.ludic - ground_fill against its golden (ground_fill_golden.json, which the studio's own test +# reads too): the hash, Math.lerp's order, the yaw, one chunk of a cover layer and of a solid layer over a +# hand-written density - and ground_fill drawing exactly the candidates, a solid layer's far band a subset +import "ludic.render3d/r3d.ludic" +program GroundFillTest { + numbers float + import "fakes/ground_golden.ludic" + import "fakes/ground_golden_json.ludic" + import "fakes/ground_golden_things.ludic" + import "fakes/ground_golden_check.ludic" + # never called: naming Input links the engine's runtime, which render3d's textures stand on + function links_runtime() -> bool { return Input.key_pressed(0) } + + test "the golden is what the generator writes today, number for number" (render3d_st: mut Render3dState) { + expect(gg_open(render3d_st, "golden")) + let want = Fs.read_text("packages/ludic.render3d/tests/ground_fill_golden.json") + expect(want != null) + if want == null { return } + let got = Text.split(gg_json(render3d_st), "\n") + let had = Text.split(want, "\n") + expect_eq(len(got), len(had)) + for k in 0 .. min(len(got), len(had)) { + if got[k] != had[k] { print(`line {k + 1}: got {got[k]}`) } + expect(got[k] == had[k]) + } + } + + test "Math.lerp is a + (b - a) * t" { + let a = [1.0, 2.0, 6.04, -2.19] + let b = [1.7, 7.0, 1.72, 3.62] + let t = [0.9, 0.71, 0.639, 0.868] + for k in 0 .. 4 { expect_eq(float_bits(Math.lerp(a[k], b[k], t[k])), float_bits(a[k] + (b[k] - a[k]) * t[k])) } + } + + test "ground_fill draws exactly the cover layer's candidates, in cell order" (render3d_st: mut Render3dState) { + expect(gg_open(render3d_st, "cover")) + let g = gg_cover() + let s = gg_stream(render3d_st) + ground_fill(render3d_st, s, GG_CX, GG_CZ, GG_BAND, g) + let cells = int(Math.ceil(32.0 / gf_step(g, GG_BAND))) + let c = new GroundCand + var n = 0 + var off = 0 + for j in 0 .. cells { + for i in 0 .. cells { + if not ground_candidate(render3d_st, g, GG_CX, GG_CZ, GG_BAND, i, j, 96.0, -64.0, 32.0, c) { continue } + off += gft_off(render3d_st, n, c) + n += 1 + } + } + expect(n > 20) + expect_eq(s.cur.count, n) + expect_eq(off, 0) + } + + test "a solid layer stands still: every band draws a subset of band 0, listed and answered by id" (render3d_st: mut Render3dState) { + expect(gg_open(render3d_st, "solid")) + let g = gg_solid() + let l = ground_solids_new(256) + let n = ground_solid_list(render3d_st, g, 0.0, 0.0, 32.0, GG_CX, GG_CZ, l) + expect(n > 8) + expect_eq(l.n, n) + let c = new GroundCand + var off = 0 + var far = 0 + for k in 0 .. l.n { + expect(ground_solid_at(render3d_st, g, 0.0, 0.0, 32.0, l.ids[k], c)) + if c.x != l.xs[k] or c.z != l.zs[k] or c.scale != l.scales[k] or c.yaw != l.yaws[k] { off += 1 } + if gf_hash(g.layer, GG_CX, GG_CZ, 0, c.cell, 7) < gs_thin(g, 2) { far += 1 } + } + expect_eq(off, 0) + for band in 0 .. 4 { + let s = gg_stream(render3d_st) + ground_fill(render3d_st, s, GG_CX, GG_CZ, band, g) + expect_eq(gft_subset(render3d_st, s.cur.count, l), 0) + if band == 0 { expect_eq(s.cur.count, n) } + if band == 2 { expect_eq(s.cur.count, far) } + } + expect(far < n) + expect(not ground_solid_at(render3d_st, g, 0.0, 0.0, 32.0, ground_solid_id(g.layer + 1, GG_CX, GG_CZ, 0, 0, 8), c)) + } + + test "a solid id holds its layer and chunk, and refuses what it cannot hold" { + let id = ground_solid_id(63, -128, 127, 21, 21, 22) + expect(id > 0) + expect_eq(ground_solid_layer(id), 63) + expect_eq(ground_solid_cx(id), -128) + expect_eq(ground_solid_cz(id), 127) + expect_eq(ground_solid_id(0, 128, 0, 0, 0, 8), -1) + expect_eq(ground_solid_id(0, 0, 0, 0, 0, 23), -1) + expect_eq(ground_solid_id(64, 0, 0, 0, 0, 8), -1) + } +}