From 8b3c38fe56ebb7b3140eb089affbad0769f3e96b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 8 Nov 2026 17:41:19 +0100 Subject: [PATCH] debug wireframe: allowance areas option (off, floating spots, all edges) from an exact port of the engine floor test, capped by level memory --- CHANGELOG.md | 7 + docs/manual/18-mods.md | 16 +- notes/debugviz.md | 34 ++ .../llamamod/mods/debug/AllowanceAreas.java | 328 ++++++++++++++++++ .../llamamod/mods/debug/DebugWireframe.java | 83 ++++- .../mods/debug/DebugWireframeMod.java | 43 ++- .../llamamod/mods/debug/FloorTest.java | 187 ++++++++++ .../mods/debug/AllowanceAreasTest.java | 234 +++++++++++++ 8 files changed, 917 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/eliasmueller/llamamod/mods/debug/AllowanceAreas.java create mode 100644 src/main/java/com/eliasmueller/llamamod/mods/debug/FloorTest.java create mode 100644 src/test/java/com/eliasmueller/llamamod/mods/debug/AllowanceAreasTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 68cc65b..0e4c2e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.2.4 – unreleased + +- **Debug wireframe: new option "Allowance areas"** (off by default). The game counts the llama as + standing on a floor even a little outside its edges; at sharp corners this margin becomes a long + sliver where the llama can stand in the air. *Floating spots* marks those places in pink, *All edges* + draws the margin as a thin pink band along every floor edge. + ## 0.2.3 – 2026-11-07 - **Editor:** flying with the right mouse button + W A S D / Q E now works even when another part of diff --git a/docs/manual/18-mods.md b/docs/manual/18-mods.md index b07ef47..e5a895f 100644 --- a/docs/manual/18-mods.md +++ b/docs/manual/18-mods.md @@ -124,6 +124,12 @@ A minigame on a TestLevel chapter: alligators come in waves into a walled pit. E | Coins per hit | 0 – 5 | 1 | Coins that pop out per hit (5 more at the end). | | TestLevel chapter | 32 – 49 | 49 | Chapter of world 9 the arena replaces (32–49; chapter 1 is the Test World's). | +#### Jungle Day Chapter 2 (play-test) + +Temporary play-test copy from the level editor – removed when the play-test ends. Changes the original level t1l4m002. + +*Id* `playtest-jungle-day-chapter-2` + #### Llama Coaster A roller coaster ride on a TestLevel chapter, with the cart from the Catacombs coaster levels: drops, climbs, boost arrows, a spin pad that turns the cart backwards and a jump over a gap. Steer left and right to stay on the track and collect coins; the ride ends at the station. Unlock TestLevel on the Save games tab. @@ -144,12 +150,6 @@ Turns the hidden TestLevel (world 9, chapter 1) into a playground island: a gall *Id* `testworld` -#### Village Chapter 1 (play-test) - -Temporary play-test copy from the level editor – removed when the play-test ends. Changes the original level t1l1m001. - -*Id* `playtest-village-chapter-1` - ### Gameplay mods #### Boot to level @@ -255,6 +255,10 @@ Shows what is normally invisible: the walls and floors the llama collides with ( *Id* `debug-wireframe` +| Option | Values | Default | Description | +|---|---|---|---| +| Allowance areas | Off, Floating spots, All edges | Off | Pink marks where the game still counts the llama as standing on a floor although it is just beside it (the collision test accepts a small margin outside every floor edge). Off: nothing extra. Floating spots: only the places where that margin reaches noticeably far, past sharp corners, and the llama would stand in the air above the real floor. All edges: a thin pink band along every floor edge, also where it does not matter. Off by default because it adds geometry to every level. | + #### Free camera & noclip Press F7 to fly the camera freely around the level: arrow keys move, A/S turn, PgUp/PgDn look up and down, X/Z rise and sink, Shift or Space go faster. Press F9 for noclip: the llama flies through walls (arrow keys, X/Z up and down). Press the key again to switch off (the keys can be changed below). diff --git a/notes/debugviz.md b/notes/debugviz.md index e3d07cf..ed6142b 100644 --- a/notes/debugviz.md +++ b/notes/debugviz.md @@ -149,6 +149,40 @@ CutSceneTrigger camera moves only as points). (≤ 2000 extra vertices per tile model, on average ~1–2× as many triangles as the original). * Test level T1L9 (test world) has no debug version (the testlevel mod takes precedence). +## Allowance areas (LlamaMod option `debug-wireframe.allowance`, 2026-11-08) + +Choice `off` (default) / `tips` ("Floating spots") / `all` ("All edges"). Shows the floor test's edge +allowance: the engine accepts a floor polygon up to `tol = −0x40 − ((0x1000 − ny·32) >> 6)` (≈0.016–0.023) +outside each edge plane; at sharp corners this becomes a sliver up to tol / sin(θ/2) long +(geometry.md "Floor test: inclusion rule …"). Colour **pink 255,60,200**. + +* Code: `mods/debug/FloorTest` = bit-exact port of FUN_004036d0's floor branch (same as `tools/floortest.py`; + the sine table is `rint(sin(2πi/4096)·4096)`, identical to DAT_00574318), `mods/debug/AllowanceAreas`. + All geometry uses the engine's plane scale (n·p = d/32 with |n| = 127), not `Collision.decode`'s d/4096, + so the bands sit ~0.8 % further from the model origin than the green wireframe. +* **tips**: for every placed tile and every up-facing collision polygon (not material 17 = skipped for the + llama, not 30 = no floor) and every corner whose tolerance corner lies ≥ `MIN_REACH` (0.05) beyond the + true corner, the area beyond the corner (tolerance region ∩ half-plane past the corner) is sampled in up to + 40 rows × 7 points. A sample counts when the exact engine test, with the feet on that polygon's plane, + picks this polygon, the point is outside the polygon without tolerance, and no other passing floor of the + cell is less than `MIN_GAP` (0.05) below (material-30 floors count as none). Samples outside the tile's + cell are dropped (the engine only tests the cell's tiles). Kept when the winning samples reach ≥ 0.05 + outside the polygon: translucent strip (alpha 150) through the winning part of each row + an opaque + centre line (4 mm), at the plane height + 0.012, in the tile's marker cell model (no collision). + Strat object collision (crates as strats, platforms) is not considered. +* **all**: per TRAK model, every up-facing floor polygon (same material filter) gets an opaque ring from + its true corners to its tolerance corners (2n vertices, 2n triangles), appended to the model overlay like + the wireframe (tile transform, alt models free). Tolerance corners further than 1.5 out are clamped. +* Budget: if TEXT+TRAK+MAP+STPC+LGPC would exceed 12 MB (16 MB level arena) the level is rebuilt without + allowance geometry and the apply log says "allowance areas left out: level too large". Not reached in + any original level; a model whose overlay would pass 7900 vertices keeps its wireframe without bands. +* Numbers (all 33 levels): tips 0–1454 slivers per level (river t1l3m00x most, t1l4m002 394), + WAD +0–2.0 MB, ≤ 2 s per level; all: +0.02–2.9 MB, arena max 11.1 MB (t1l3m003). + t1l4m002 crate (tile 443): poly 15 corner 2 (reach 0.18, contains Kuzco's spot 57.204/43.733), + poly 11 corner 2 (0.175), polys 10/14 (0.11/0.13) on the other end, 12/13 barely (0.05). +* Tests: `AllowanceAreasTest` (exact port vs. 44 floortest.py points, sine table vs. groove.exe, + slivers of t1l4m002, no slivers on the flat start ground, band counts, process in each mode). + ## Statistics per level (build of 2026-11-06) Marker categories are the internal keys of `debugviz.py`: ausgang = exit, geheimnis = secret, diff --git a/src/main/java/com/eliasmueller/llamamod/mods/debug/AllowanceAreas.java b/src/main/java/com/eliasmueller/llamamod/mods/debug/AllowanceAreas.java new file mode 100644 index 0000000..ca0d714 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/mods/debug/AllowanceAreas.java @@ -0,0 +1,328 @@ +package com.eliasmueller.llamamod.mods.debug; + +import com.eliasmueller.llamamod.format.Bytes; +import com.eliasmueller.llamamod.format.Model; +import com.eliasmueller.llamamod.mods.debug.FloorTest.Cells; +import com.eliasmueller.llamamod.mods.debug.FloorTest.Hit; +import com.eliasmueller.llamamod.mods.debug.FloorTest.Tile; +import com.eliasmueller.llamamod.mods.debug.Palette.Rgb; +import com.eliasmueller.llamamod.mods.debug.StratMarkers.Cell; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * The floor test's edge allowance: the engine accepts a floor polygon up to {@link FloorTest#tolerance} + * outside each edge plane, which at sharp corners becomes a long sliver beyond the tip. TIPS draws + * the slivers where the polygon also beats the floor below (the llama stands in the air there), ALL + * draws the allowance band along every edge of every floor polygon. notes/debugviz.md. + */ +final class AllowanceAreas { + private AllowanceAreas() {} + + /** Slivers that reach less far beyond the polygon are not drawn. */ + static final double MIN_REACH = 0.05; + /** The next floor must be at least this far below the winning plane. */ + static final double MIN_GAP = 0.05; + /** Implausibly long tips (near-degenerate polygons) are cut here. */ + static final double MAX_REACH = 1.5; + + static final Rgb COLOR = new Rgb(255, 60, 200); + static final int ALPHA = 150; + static final double LIFT = 0.012; + private static final int ACROSS = 7; + private static final double ROW_STEP = 0.01; + private static final int MAX_ROWS = 40; + + /** One drawn sliver: quads in game space (z positive), reach = farthest winning sample outside the polygon. */ + record Tip(int tile, int poly, int corner, double reach, List quads) {} + + /** Polygon outline in tile-local units: plane height function and edge lines a·(x,z) ≥ c. */ + private static final class Shape { + final double n0x, n0y, n0z, d0; + final double[][] a; + final double[] c0, ct; + final double[][] v, w; + + private Shape(byte[] r) { + n0x = r[2]; + n0y = r[3]; + n0z = r[4]; + d0 = Bytes.i16(r, 6) / 32.0; + int n = (r[0] & 1) != 0 ? 4 : 3; + int tol = FloorTest.tolerance(r); + a = new double[n][]; + c0 = new double[n]; + ct = new double[n]; + for (int k = 0; k < n; k++) { + int o = 8 + 6 * k; + double nx = r[o], ny = r[o + 1], nz = r[o + 2]; + int d = Bytes.i16(r, o + 4); + a[k] = new double[] {nx - ny * n0x / n0y, nz - ny * n0z / n0y}; + c0[k] = d / 32.0 - ny * d0 / n0y; + ct[k] = (d + tol) / 32.0 - ny * d0 / n0y; + } + v = corners(c0); + w = corners(ct); + } + + /** {@code null} unless the record is an up-facing, plausible convex polygon. */ + static Shape of(byte[] r) { + if (r[3] <= 0) { + return null; + } + Shape s = new Shape(r); + if (s.v == null || s.w == null) { + return null; + } + double area = 0, cx = 0, cz = 0; + for (int k = 0; k < s.v.length; k++) { + double[] p = s.v[k], q = s.v[(k + 1) % s.v.length]; + area += p[0] * q[1] - q[0] * p[1]; + cx += p[0] / s.v.length; + cz += p[1] / s.v.length; + } + if (Math.abs(area) < 2e-6) { + return null; + } + for (int k = 0; k < s.a.length; k++) { + if (s.a[k][0] * cx + s.a[k][1] * cz < s.c0[k]) { + return null; + } + } + return s; + } + + /** Corner k between edge k and edge k+1 (like Collision.polygon). */ + private double[][] corners(double[] c) { + int n = a.length; + double[][] out = new double[n][]; + for (int k = 0; k < n; k++) { + double[] e1 = a[k], e2 = a[(k + 1) % n]; + double det = e1[0] * e2[1] - e1[1] * e2[0]; + if (Math.abs(det) < 1e-9) { + return null; + } + double x = (c[k] * e2[1] - e1[1] * c[(k + 1) % n]) / det; + double z = (e1[0] * c[(k + 1) % n] - c[k] * e2[0]) / det; + if (Math.abs(x) > 12 || Math.abs(z) > 12) { + return null; + } + out[k] = new double[] {x, z}; + } + return out; + } + + double y(double x, double z) { + return (d0 - n0x * x - n0z * z) / n0y; + } + + /** Horizontal distance from (x, z) to the polygon without tolerance (0 inside). */ + double outside(double x, double z) { + boolean in = true; + for (int k = 0; k < a.length; k++) { + in &= a[k][0] * x + a[k][1] * z >= c0[k]; + } + if (in) { + return 0; + } + double best = Double.MAX_VALUE; + for (int k = 0; k < v.length; k++) { + double[] p = v[k], q = v[(k + 1) % v.length]; + double ex = q[0] - p[0], ez = q[1] - p[1]; + double len2 = ex * ex + ez * ez; + double t = len2 > 0 ? Math.max(0, Math.min(1, ((x - p[0]) * ex + (z - p[1]) * ez) / len2)) : 0; + best = Math.min(best, Math.hypot(x - p[0] - t * ex, z - p[1] - t * ez)); + } + return best; + } + + /** Interval [t0, t1] of t where (x, z) + t·(qx, qz) lies inside the tolerance region; null if empty. */ + double[] span(double x, double z, double qx, double qz) { + double t0 = -1, t1 = 1; + for (int k = 0; k < a.length; k++) { + double aq = a[k][0] * qx + a[k][1] * qz; + double rhs = ct[k] - (a[k][0] * x + a[k][1] * z); + if (Math.abs(aq) < 1e-12) { + if (rhs > 1e-12) { + return null; + } + } else if (aq > 0) { + t0 = Math.max(t0, rhs / aq); + } else { + t1 = Math.min(t1, rhs / aq); + } + } + return t0 <= t1 ? new double[] {t0, t1} : null; + } + } + + private static boolean candidate(byte[] r) { + int mat = r[1] & 0xff; + return r[3] > 0 && mat != FloorTest.MAT_SKIP && mat != FloorTest.MAT_NO_FLOOR; + } + + /** All slivers of the level where the polygon wins in the air (TIPS mode). */ + static List tips(Cells cells) { + List out = new ArrayList<>(); + for (Tile t : cells.tiles) { + for (int i = 0; i < t.recs().size(); i++) { + byte[] r = t.recs().get(i); + Shape s = candidate(r) ? Shape.of(r) : null; + if (s == null) { + continue; + } + for (int k = 0; k < s.v.length; k++) { + Tip tip = tip(cells, t, i, r, s, k); + if (tip != null) { + out.add(tip); + } + } + } + } + return out; + } + + private static Tip tip(Cells cells, Tile t, int poly, byte[] r, Shape s, int k) { + double[] v = s.v[k], w = s.w[k]; + double len = Math.hypot(w[0] - v[0], w[1] - v[1]); + if (len < MIN_REACH) { + return null; + } + double bx = (w[0] - v[0]) / len, bz = (w[1] - v[1]) / len, qx = -bz, qz = bx; + len = Math.min(len, MAX_REACH); + int rows = (int) Math.max(3, Math.min(MAX_ROWS, Math.ceil(len / ROW_STEP))); + long cell = t.cell(); + double reach = 0; + double[][] rowCenter = new double[rows + 1][]; + double[][] rowSpan = new double[rows + 1][]; + for (int j = 0; j <= rows; j++) { + double cx = v[0] + bx * len * j / rows, cz = v[1] + bz * len * j / rows; + double[] sp = s.span(cx, cz, qx, qz); + if (sp == null) { + continue; + } + double step = (sp[1] - sp[0]) / ACROSS; + double lo = Double.NaN, hi = Double.NaN; + for (int m = 0; m < ACROSS; m++) { + double tt = sp[0] + (m + 0.5) * step; + double lx = cx + tt * qx, lz = cz + tt * qz; + if (wins(cells, t, poly, r, lx, lz, cell)) { + reach = Math.max(reach, s.outside(lx, lz)); + lo = Double.isNaN(lo) ? tt : lo; + hi = tt; + } + } + if (!Double.isNaN(lo)) { + rowCenter[j] = new double[] {cx, cz}; + rowSpan[j] = new double[] {Math.max(sp[0], lo - step / 2), Math.min(sp[1], hi + step / 2)}; + } + } + if (reach < MIN_REACH) { + return null; + } + List quads = new ArrayList<>(); + for (int j = 0; j < rows; j++) { + if (rowSpan[j] == null || rowSpan[j + 1] == null) { + continue; + } + double[][] q = new double[4][]; + q[0] = world(t, s, rowCenter[j], rowSpan[j][0], qx, qz); + q[1] = world(t, s, rowCenter[j], rowSpan[j][1], qx, qz); + q[2] = world(t, s, rowCenter[j + 1], rowSpan[j + 1][1], qx, qz); + q[3] = world(t, s, rowCenter[j + 1], rowSpan[j + 1][0], qx, qz); + quads.add(q); + } + return quads.isEmpty() ? null : new Tip(t.index(), poly, k, reach, quads); + } + + /** Exact engine check: standing on the polygon at local (lx, lz), outside it, it wins with air below. */ + private static boolean wins(Cells cells, Tile t, int poly, byte[] r, double lx, double lz, long cell) { + double[] wxz = FloorTest.toWorld(lx, lz, t); + int px = FloorTest.fx(wxz[0]), pz = FloorTest.fx(wxz[1]); + if (FloorTest.cellKey(px >> 12, pz >> 12) != cell) { + return false; + } + int[] l = FloorTest.toLocal(px, pz, t); + int[] own = FloorTest.test(r, l[0], 0, l[1]); + List hits = FloorTest.candidates(cells, px, t.y() + own[1], pz); + Hit win = FloorTest.winner(hits); + if (win == null || win.tile() != t.index() || win.poly() != poly || win.inside()) { + return false; + } + int gap = (int) Math.round(MIN_GAP * 4096); + for (Hit h : hits) { + if (h != win && h.passed() && h.mat() != FloorTest.MAT_NO_FLOOR && h.height() > win.height() - gap) { + return false; + } + } + return true; + } + + private static double[] world(Tile t, Shape s, double[] c, double tt, double qx, double qz) { + double lx = c[0] + tt * qx, lz = c[1] + tt * qz; + double[] wxz = FloorTest.toWorld(lx, lz, t); + return new double[] {wxz[0], t.y() / 4096.0 + s.y(lx, lz) + LIFT, wxz[1]}; + } + + /** Adds the slivers as translucent strips plus an opaque centre line, bucketed by the tile's cell. */ + static int addTips(Map cells, List tips, Cells level, Palette pal) { + int fill = pal.get(COLOR, ALPHA), line = pal.get(COLOR); + double[] up = {0.0, 1.0, 0.0}; + int quads = 0; + for (Tip tip : tips) { + int[] b = level.tiles.get(tip.tile()).b(); + Mesh m = cells.computeIfAbsent(new Cell(b[3] >> 12, b[5] >> 12), k -> new Mesh()); + for (double[][] q : tip.quads()) { + m.quad(new double[][] {export(q[0]), export(q[1]), export(q[2]), export(q[3])}, up, fill); + quads++; + } + double[][] first = tip.quads().get(0), + last = tip.quads().get(tip.quads().size() - 1); + m.beam(export(mid(first[0], first[1])), export(mid(last[2], last[3])), 0.004, line); + } + return quads; + } + + /** ALL mode: allowance band (true corners to tolerance corners) of every floor polygon, model space. */ + static void addBands(Mesh mesh, Model model, Palette pal) { + int col = pal.get(COLOR); + for (List grp : model.coll) { + for (byte[] r : grp) { + Shape s = candidate(r) ? Shape.of(r) : null; + if (s == null) { + continue; + } + double nl = Math.sqrt(s.n0x * s.n0x + s.n0y * s.n0y + s.n0z * s.n0z); + double[] n = {s.n0x / nl, s.n0y / nl, -s.n0z / nl}; + int m = s.v.length; + int[] in = new int[m], out = new int[m]; + for (int k = 0; k < m; k++) { + double[] wk = s.w[k]; + if (Math.hypot(wk[0] - s.v[k][0], wk[1] - s.v[k][1]) > MAX_REACH) { + wk = new double[] {s.v[k][0], s.v[k][1]}; + } + in[k] = mesh.v(vertex(s, s.v[k], n), n); + out[k] = mesh.v(vertex(s, wk, n), n); + } + for (int k = 0; k < m; k++) { + int k2 = (k + 1) % m; + mesh.tri(in[k], in[k2], out[k2], col); + mesh.tri(in[k], out[k2], out[k], col); + } + } + } + } + + private static double[] vertex(Shape s, double[] p, double[] n) { + return new double[] {p[0] + n[0] * LIFT, s.y(p[0], p[1]) + n[1] * LIFT, -p[1] + n[2] * LIFT}; + } + + private static double[] mid(double[] a, double[] b) { + return new double[] {(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2}; + } + + private static double[] export(double[] q) { + return new double[] {q[0], q[1], -q[2]}; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/mods/debug/DebugWireframe.java b/src/main/java/com/eliasmueller/llamamod/mods/debug/DebugWireframe.java index 164e450..aafc846 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/debug/DebugWireframe.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/debug/DebugWireframe.java @@ -23,7 +23,6 @@ import com.eliasmueller.llamamod.mods.debug.StratMarkers.Category; import com.eliasmueller.llamamod.mods.debug.StratMarkers.Cell; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.EnumMap; import java.util.List; import java.util.Map; @@ -38,11 +37,31 @@ import java.util.TreeMap; public final class DebugWireframe { private DebugWireframe() {} - public record Options( - boolean noColl, boolean noMarkers, boolean noFill, boolean opaque, double width, double offset) { - public static final Options DEFAULT = new Options(false, false, false, false, 0.007, 0.012); + /** Edge allowance of the floor test (see {@link AllowanceAreas}). */ + public enum Allowance { + OFF, + TIPS, + ALL } + public record Options( + boolean noColl, + boolean noMarkers, + boolean noFill, + boolean opaque, + double width, + double offset, + Allowance allowance) { + public static final Options DEFAULT = new Options(false, false, false, false, 0.007, 0.012, Allowance.OFF); + + public Options withAllowance(Allowance a) { + return new Options(noColl, noMarkers, noFill, opaque, width, offset, a); + } + } + + /** Level arena (16 MB) share left for TEXT+TRAK+MAP+STPC+LGPC; allowance geometry is dropped above it. */ + static final int ARENA_BUDGET = 12 << 20; + /** Counters for the report (debugviz --report). */ public static final class Stats { public int collPolys; @@ -57,6 +76,13 @@ public final class DebugWireframe { public int markersTruncated; public int newTextures; public int maxVerts; + public int allowanceTips; + public int allowanceQuads; + public int allowanceBandModels; + public int allowanceBandsTooLarge; + /** Allowance geometry left out because the level would exceed {@link #ARENA_BUDGET}. */ + public boolean allowanceDropped; + public final Map markers = new EnumMap<>(Category.class); public final Map materials = new TreeMap<>(); @@ -331,11 +357,24 @@ public final class DebugWireframe { return out; } + static long arenaBytes(Wad ch) { + long n = 0; + for (String tag : new String[] {"TEXT", "TRAK", "MAP", "STPC", "LGPC"}) { + byte[] d = ch.get(tag); + n += d == null ? 0 : d.length; + } + return n; + } + public static Result process(byte[] src) { return process(src, Options.DEFAULT); } public static Result process(byte[] src, Options opt) { + return process(src, opt, ARENA_BUDGET); + } + + static Result process(byte[] src, Options opt, long arenaBudget) { Wad ch = Wad.parse(src); int wf = ch.wfpcFlags(); byte[] origMap = ch.require("MAP"); @@ -381,8 +420,37 @@ public final class DebugWireframe { } } + if (opt.allowance() == Allowance.ALL) { + for (int mi = 0; mi < trak.size(); mi++) { + Mesh band = new Mesh(); + AllowanceAreas.addBands(band, trak.get(mi), pal); + if (band.verts.isEmpty()) { + continue; + } + Mesh ov = overlays[mi] != null ? overlays[mi] : new Mesh(); + if (trak.get(mi).verts.size() + ov.verts.size() + band.verts.size() > VERT_LIMIT) { + stats.allowanceBandsTooLarge++; + continue; + } + int off = ov.verts.size(); + ov.verts.addAll(band.verts); + for (int[] t : band.tris) { + ov.tri(t[0] + off, t[1] + off, t[2] + off, t[3]); + } + overlays[mi] = ov; + addV[mi] = ov.verts.size(); + stats.allowanceBandModels++; + } + } + Map cells = - opt.noMarkers() ? Collections.emptyMap() : StratMarkers.build(ch.require("STPC"), wf, mp, pal, stats); + opt.noMarkers() ? new TreeMap<>() : StratMarkers.build(ch.require("STPC"), wf, mp, pal, stats); + if (opt.allowance() == Allowance.TIPS) { + FloorTest.Cells level = new FloorTest.Cells(mp, trak); + List tips = AllowanceAreas.tips(level); + stats.allowanceTips = tips.size(); + stats.allowanceQuads = AllowanceAreas.addTips(cells, tips, level, pal); + } TextChunk.Appended text = TextChunk.appendFlats(ch.require("TEXT"), pal.entries); ch.set("TEXT", text.text()); @@ -470,6 +538,11 @@ public final class DebugWireframe { for (Model m : trak) { stats.maxVerts = Math.max(stats.maxVerts, m.verts.size()); } + if (opt.allowance() != Allowance.OFF && arenaBytes(ch) > arenaBudget) { + Result plain = process(src, opt.withAllowance(Allowance.OFF)); + plain.stats().allowanceDropped = true; + return plain; + } return new Result(ch.toBytes(), stats); } } diff --git a/src/main/java/com/eliasmueller/llamamod/mods/debug/DebugWireframeMod.java b/src/main/java/com/eliasmueller/llamamod/mods/debug/DebugWireframeMod.java index 97e8f21..7cb6b34 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/debug/DebugWireframeMod.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/debug/DebugWireframeMod.java @@ -5,6 +5,7 @@ import static com.eliasmueller.llamamod.mods.LevelWads.fileName; import com.eliasmueller.llamamod.mods.LevelWads; import com.eliasmueller.llamamod.mods.Mod; import com.eliasmueller.llamamod.mods.ModContext; +import com.eliasmueller.llamamod.mods.ModOption; import java.io.IOException; import java.io.UncheckedIOException; import java.util.ArrayDeque; @@ -41,7 +42,34 @@ public final class DebugWireframeMod implements Mod { + " ones) as coloured lines, and markers for cameras, triggers, exits and checkpoints."; } - private record Done(byte[] wad, long millis) {} + static final String ALLOWANCE = "allowance"; + + @Override + public List options() { + return List.of(ModOption.choice( + ALLOWANCE, + "Allowance areas", + "Pink marks where the game still counts the llama as standing on a floor although it is just" + + " beside it (the collision test accepts a small margin outside every floor edge)." + + " Off: nothing extra. Floating spots: only the places where that margin reaches noticeably" + + " far, past sharp corners, and the llama would stand in the air above the real floor." + + " All edges: a thin pink band along every floor edge, also where it does not matter." + + " Off by default because it adds geometry to every level.", + "off", + ModOption.of("off", "Off"), + ModOption.of("tips", "Floating spots"), + ModOption.of("all", "All edges"))); + } + + static DebugWireframe.Allowance allowance(String value) { + return switch (value == null ? "off" : value) { + case "tips" -> DebugWireframe.Allowance.TIPS; + case "all" -> DebugWireframe.Allowance.ALL; + default -> DebugWireframe.Allowance.OFF; + }; + } + + private record Done(byte[] wad, long millis, DebugWireframe.Stats stats) {} private record Job(String path, Future result) {} @@ -51,6 +79,8 @@ public final class DebugWireframeMod implements Mod { if (files.isEmpty()) { return; } + DebugWireframe.Options opt = DebugWireframe.Options.DEFAULT.withAllowance( + allowance(ctx.optionValues().get(ALLOWANCE))); // ModContext is not thread-safe: only this thread calls it; workers just transform bytes. int threads = Math.max(1, Math.min(files.size(), Runtime.getRuntime().availableProcessors())); ExecutorService pool = Executors.newFixedThreadPool(threads, r -> { @@ -66,8 +96,8 @@ public final class DebugWireframeMod implements Mod { byte[] in = ctx.read(f); pending.add(new Job(f, pool.submit(() -> { long t0 = System.nanoTime(); - byte[] out = DebugWireframe.process(in).wad(); - return new Done(out, (System.nanoTime() - t0) / 1_000_000); + DebugWireframe.Result out = DebugWireframe.process(in, opt); + return new Done(out.wad(), (System.nanoTime() - t0) / 1_000_000, out.stats()); }))); // bounded look-ahead keeps at most ~2 WADs per worker in memory while (pending.size() >= 2 * threads) { @@ -100,8 +130,13 @@ public final class DebugWireframeMod implements Mod { throw new IOException(fileName(job.path()), c); } ctx.write(job.path(), out.wad()); + DebugWireframe.Stats st = out.stats(); + String extra = st.allowanceDropped + ? ", allowance areas left out: level too large" + : st.allowanceTips > 0 ? ", " + st.allowanceTips + " floating spots" : ""; ctx.progress( (double) done / total, - "Debug wireframe: " + fileName(job.path()) + " (" + done + "/" + total + ", " + out.millis() + " ms)"); + "Debug wireframe: " + fileName(job.path()) + " (" + done + "/" + total + ", " + out.millis() + " ms" + + extra + ")"); } } diff --git a/src/main/java/com/eliasmueller/llamamod/mods/debug/FloorTest.java b/src/main/java/com/eliasmueller/llamamod/mods/debug/FloorTest.java new file mode 100644 index 0000000..21df242 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/mods/debug/FloorTest.java @@ -0,0 +1,187 @@ +package com.eliasmueller.llamamod.mods.debug; + +import com.eliasmueller.llamamod.format.Bytes; +import com.eliasmueller.llamamod.format.MapChunk; +import com.eliasmueller.llamamod.format.Model; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Bit-exact port of the engine's map floor test (FUN_004036d0, floor branch, strat flag 0x200000 off), + * same as tools/floortest.py. All values 20.12 fixed point, game space (z positive). See + * notes/geometry.md "Floor test: inclusion rule and the floating-next-to-the-log case". + */ +final class FloorTest { + /** DAT_0057dd98: probe height above the feet. */ + static final int PROBE = 0x400; + /** Skipped for the llama (0x12 instead for strats with flag 0x8000000). */ + static final int MAT_SKIP = 0x11; + /** Floor treated as "no floor". */ + static final int MAT_NO_FLOOR = 0x1e; + + /** DAT_00574318 equals round(sin(2πi/4096)·4096) for every entry. */ + private static final int[] SIN = new int[4096]; + + static { + for (int i = 0; i < SIN.length; i++) { + SIN[i] = (int) Math.rint(Math.sin(2 * Math.PI * i / 4096) * 4096); + } + } + + static int sin(int u) { + return SIN[u & 0xfff]; + } + + static int cos(int u) { + return SIN[(u + 0x400) & 0xfff]; + } + + /** One placed tile: B record (rx, ry, rz, x, y, z fixed) and its collision records (groups A+B+C). */ + record Tile(int index, int[] b, List recs) { + int x() { + return b[3]; + } + + int y() { + return b[4]; + } + + int z() { + return b[5]; + } + + int rot() { + return b[1]; + } + + long cell() { + return cellKey(b[3] >> 12, b[5] >> 12); + } + } + + /** Candidate plane: dist = probe height above the plane, height = world plane y under the probe. */ + record Hit(int tile, int poly, int mat, int dist, int height, boolean passed, boolean inside) {} + + /** Tiles grouped by the cell of their position (the tiles the engine tests for a probe in that cell). */ + static final class Cells { + final Map> byCell = new HashMap<>(); + final List tiles = new ArrayList<>(); + + Cells(MapChunk mp, List trak) { + Map> recs = new HashMap<>(); + for (int t = 0; t < mp.tilesFixed.size(); t++) { + int mi = mp.tileModels[t]; + List r = recs.computeIfAbsent(mi, k -> { + List all = new ArrayList<>(); + trak.get(k).coll.forEach(all::addAll); + return all; + }); + Tile tile = new Tile(t, mp.tilesFixed.get(t), r); + tiles.add(tile); + byCell.computeIfAbsent(tile.cell(), k -> new ArrayList<>()).add(tile); + } + } + + List at(int px, int pz) { + return byCell.getOrDefault(cellKey(px >> 12, pz >> 12), List.of()); + } + } + + static long cellKey(int cx, int cz) { + return ((long) cx << 32) ^ (cz & 0xffffffffL); + } + + /** World x/z (fixed) to tile-local, exactly like 0x4037b3..0x4037f1 (rotation by −ry). */ + static int[] toLocal(int px, int pz, Tile t) { + int lx = px - t.x(), lz = pz - t.z(); + int u = -t.rot(); + if (u != 0) { + int s = sin(u), c = cos(u); + int nx = (s * lz + c * lx) >> 12; + int nz = (c * lz - s * lx) >> 12; + lx = nx; + lz = nz; + } + return new int[] {lx, lz}; + } + + /** Inverse of {@link #toLocal} in floating point: local units to world units (game space). */ + static double[] toWorld(double lx, double lz, Tile t) { + double a = -t.rot() / 4096.0 * 2 * Math.PI; + double s = Math.sin(a), c = Math.cos(a); + return new double[] {t.x() / 4096.0 + c * lx - s * lz, t.z() / 4096.0 + s * lx + c * lz}; + } + + static int tolerance(byte[] r) { + return -0x40 - ((0x1000 - r[3] * 32) >> 6); + } + + /** + * One 32-byte record at the tile-local probe (lx, ly, lz); ly = feet − tile y + {@link #PROBE}. + * Returns {dist, plane y local, passed (1/0), inside without tolerance (1/0)} or {@code null} when + * the plane does not face up. + */ + static int[] test(byte[] r, int lx, int ly, int lz) { + int ny32 = r[3] * 32; + if (ny32 <= 0) { + return null; + } + long num = (long) Bytes.i16(r, 6) * 4096 - (long) r[4] * 32 * lz - (long) r[2] * 32 * lx - (long) ny32 * ly; + int i6 = (int) (num / ny32); + int y = ly + i6; + int tol = -0x40 - ((0x1000 - ny32) >> 6); + boolean pass = true, inside = true; + int n = (r[0] & 1) != 0 ? 4 : 3; + for (int k = 0; k < n; k++) { + int o = 8 + 6 * k; + long v = (((long) r[o + 1] * 32 * y + (long) r[o + 2] * 32 * lz + (long) r[o] * 32 * lx) >> 12) + - Bytes.i16(r, o + 4); + pass &= v >= tol; + inside &= v >= 0; + } + return new int[] {-i6, y, pass ? 1 : 0, inside ? 1 : 0}; + } + + /** Every floor candidate of the probe's cell (planes at most 1/32 above the probe), in engine order. */ + static List candidates(Cells cells, int px, int py, int pz) { + List out = new ArrayList<>(); + for (Tile t : cells.at(px, pz)) { + int[] l = toLocal(px, pz, t); + int ly = py - t.y() + PROBE; + for (int i = 0; i < t.recs().size(); i++) { + byte[] r = t.recs().get(i); + int mat = r[1] & 0xff; + if (mat == MAT_SKIP) { + continue; + } + int[] res = test(r, l[0], ly, l[1]); + if (res == null || res[0] < -0x80) { + continue; + } + out.add(new Hit(t.index(), i, mat, res[0], t.y() + res[1], res[2] != 0, res[3] != 0)); + } + } + return out; + } + + /** The engine's choice: the passing plane with the smallest dist (first one on ties); null = none. */ + static Hit winner(List hits) { + Hit best = null; + for (Hit h : hits) { + if (h.passed() && (best == null || h.dist() < best.dist())) { + best = h; + } + } + return best; + } + + static Hit floor(Cells cells, double x, double y, double z) { + return winner(candidates(cells, fx(x), fx(y), fx(z))); + } + + static int fx(double v) { + return (int) Math.round(v * 4096); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/mods/debug/AllowanceAreasTest.java b/src/test/java/com/eliasmueller/llamamod/mods/debug/AllowanceAreasTest.java new file mode 100644 index 0000000..cbd2100 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/mods/debug/AllowanceAreasTest.java @@ -0,0 +1,234 @@ +package com.eliasmueller.llamamod.mods.debug; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.eliasmueller.llamamod.format.Bytes; +import com.eliasmueller.llamamod.format.MapChunk; +import com.eliasmueller.llamamod.format.Model; +import com.eliasmueller.llamamod.format.Trak; +import com.eliasmueller.llamamod.format.Wad; +import com.eliasmueller.llamamod.mods.ModOption; +import com.eliasmueller.llamamod.mods.debug.DebugWireframe.Allowance; +import com.eliasmueller.llamamod.mods.debug.FloorTest.Hit; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** FloorTest against tools/floortest.py and the allowance regions of t1l4m002 (needs -Dllamamod.cd). */ +class AllowanceAreasTest { + private static byte[] wad; + private static FloorTest.Cells cells; + private static List tips; + + @BeforeAll + static void load() throws IOException { + Path p = Path.of(System.getProperty("llamamod.cd", ""), "wads", "t1l4m002.wad"); + if (!Files.isRegularFile(p)) { + return; + } + wad = Files.readAllBytes(p); + Wad ch = Wad.parse(wad); + List trak = Trak.parse(ch.require("TRAK")); + MapChunk mp = MapChunk.parse(ch.require("MAP"), ch.wfpcFlags(), Trak.vertCounts(trak)); + cells = new FloorTest.Cells(mp, trak); + tips = AllowanceAreas.tips(cells); + } + + @Test + void sineTableMatchesTheExe() throws IOException { + Path exe = Path.of("re", "groove.exe"); + assumeTrue(Files.isRegularFile(exe)); + byte[] d = Files.readAllBytes(exe); + for (int i = 0; i < 4096; i++) { + assertEquals(Bytes.i32(d, 0x174318 + 4 * i), FloorTest.sin(i), "sin " + i); + } + } + + /** {x, y, z, tile, poly, height fixed} from tools/floortest.py. */ + private static final double[][] REFERENCE = { + {57.204, 2.474, 43.733, 443, 15, 10135}, + {57.204, 2.27, 43.745, 443, 2, 9298}, + {57.204, 2.27, 43.736, 443, 15, 10113}, + {57.15, 2.27, 43.733, 443, 2, 9282}, + {52.094, 2.865, 50.583, 548, 6, 9365}, + {28.551, 2.223, 23.059, 92, 12, 8914}, + {52.639, 2.322, 52.372, 584, 0, 9372}, + {36.564, 2.12, 20.619, 64, 15, 9027}, + {32.362, 2.27, 46.248, 464, 16, 5080}, + {42.082, 2.243, 24.3, 249, 1, 9225}, + {36.512, 1.013, 29.165, 120, 0, 5129}, + {37.594, 2.184, 34.58, 358, 0, 9586}, + {31.945, 2.371, 23.474, 95, 0, 8899}, + {34.731, 2.242, 20.31, 62, 16, 8916}, + {50.285, 2.503, 45.386, 456, 10, 9385}, + {26.941, 2.291, 16.355, 23, 0, 2520}, + {42.398, 2.245, 25.917, 253, 5, 9225}, + {48.449, 2.825, 8.549, 170, 20, 9108}, + {39.864, 2.123, 43.278, 440, 0, 9284}, + {36.683, 1.846, 36.38, 367, 21, 8225}, + {48.419, 2.35, 5.369, 149, 13, 8192}, + {33.69, 1.426, 47.515, 128, 0, 5129}, + {26.457, 2.803, 20.871, 55, 7, 9447}, + {23.07, 1.939, 17.208, 26, 0, 2520}, + {37.849, 1.199, 28.993, 118, 17, 4918}, + {51.479, 2.275, 26.692, 271, 0, 9475}, + {44.951, 1.72, 15.528, 210, 16, 8192}, + {29.696, 1.925, 23.261, 93, 16, 2520}, + {45.356, 2.312, 9.223, 171, 17, 8192}, + {48.33, 2.717, 48.223, 514, 0, 9592}, + {26.79, 1.665, 17.472, 28, 4, 2520}, + {30.479, 0.699, 16.653, 1, 0, 1033}, + {33.835, 1.958, 22.12, 86, 23, 2520}, + {46.478, 2.684, 14.179, 204, 0, 8192}, + {42.401, 2.587, 46.947, 474, 1, 9281}, + {47.807, 2.74, 46.146, 476, 3, 9468}, + {32.657, 2.823, 47.35, 485, 6, 11164}, + {51.021, 2.59, 1.799, 134, 6, 9225}, + {26.527, 2.026, 25.934, 105, 0, 2520}, + {27.501, 1.989, 29.764, 299, 0, 9204}, + {29.419, 3.365, 51.131, 557, 2, 11324}, + {36.898, 2.723, 35.662, 362, 2, 9268}, + {48.421, 2.252, 49.918, 529, 28, 8192}, + {43.152, 2.809, 50.511, 544, 8, 9762} + }; + + @Test + void floorTestMatchesThePythonReimplementation() { + assumeTrue(cells != null); + for (double[] r : REFERENCE) { + Hit h = FloorTest.floor(cells, r[0], r[1], r[2]); + String at = r[0] + "/" + r[1] + "/" + r[2]; + assertNotNull(h, at); + assertEquals((int) r[3], h.tile(), at); + assertEquals((int) r[4], h.poly(), at); + assertEquals((int) r[5], h.height(), at); + } + } + + @Test + void kuzcosFloatingSpotIsInsidePoly15sSliver() { + assumeTrue(cells != null); + AllowanceAreas.Tip t = tip(443, 15); + assertNotNull(t, "poly 15 needle"); + assertTrue(t.reach() > 0.1, "reach " + t.reach()); + assertTrue(contains(t, 57.204, 43.733), "Kuzco's spot 57.204/43.733"); + assertFalse(contains(t, 57.204, 43.745), "on the path, poly 2 wins"); + assertNotNull(tip(443, 11), "needle on the crate's other side"); + } + + @Test + void noSliversOnPlainGround() { + assumeTrue(cells != null); + for (AllowanceAreas.Tip t : tips) { + assertTrue(t.reach() >= AllowanceAreas.MIN_REACH); + for (double[][] q : t.quads()) { + for (double[] p : q) { + // the big flat ground of the start area (tile 23 poly 0, floor 0.615) has no tips + assertFalse(p[0] > 26.0 && p[0] < 27.0 && p[2] > 16.0 && p[2] < 17.0, "tip at " + t); + } + } + } + assertTrue(tips.size() < 600, "tips " + tips.size()); + } + + @Test + void allModeDrawsABandPerFloorPolygon() { + assumeTrue(cells != null); + Model crate = null; + for (FloorTest.Tile t : cells.tiles) { + if (t.index() == 443) { + crate = Trak.parse(Wad.parse(wad).require("TRAK")).get(129); + } + } + assertNotNull(crate); + Mesh m = new Mesh(); + AllowanceAreas.addBands(m, crate, new Palette()); + int expect = 0; + for (List g : crate.coll) { + for (byte[] r : g) { + if (r[3] > 0 && (r[1] & 0xff) != 0x11 && (r[1] & 0xff) != 0x1e) { + expect += (r[0] & 1) != 0 ? 4 : 3; + } + } + } + assertTrue(expect > 0); + assertTrue(m.verts.size() <= 2 * expect && m.verts.size() >= expect, m.verts.size() + " vs " + expect); + assertEquals(m.verts.size(), m.tris.size()); + } + + @Test + void processWithEachMode() { + assumeTrue(wad != null); + DebugWireframe.Result off = DebugWireframe.process(wad); + DebugWireframe.Result t = + DebugWireframe.process(wad, DebugWireframe.Options.DEFAULT.withAllowance(Allowance.TIPS)); + DebugWireframe.Result all = + DebugWireframe.process(wad, DebugWireframe.Options.DEFAULT.withAllowance(Allowance.ALL)); + assertEquals(0, off.stats().allowanceTips); + assertEquals(tips.size(), t.stats().allowanceTips); + assertTrue(t.stats().allowanceQuads > 0); + assertTrue(t.wad().length > off.wad().length); + assertTrue(all.stats().allowanceBandModels > 0); + assertTrue(all.wad().length > t.wad().length); + assertFalse(t.stats().allowanceDropped || all.stats().allowanceDropped); + assertTrue(DebugWireframe.arenaBytes(Wad.parse(all.wad())) < DebugWireframe.ARENA_BUDGET); + // the game-relevant data stays untouched + assertTrue(java.util.Arrays.equals( + Wad.parse(off.wad()).require("STPC"), Wad.parse(all.wad()).require("STPC"))); + } + + @Test + void tooLargeLevelsGetNoAllowanceGeometry() { + assumeTrue(wad != null); + DebugWireframe.Result off = DebugWireframe.process(wad); + long plain = DebugWireframe.arenaBytes(Wad.parse(off.wad())); + DebugWireframe.Result capped = + DebugWireframe.process(wad, DebugWireframe.Options.DEFAULT.withAllowance(Allowance.ALL), plain + 1000); + assertTrue(capped.stats().allowanceDropped); + assertTrue(java.util.Arrays.equals(off.wad(), capped.wad())); + } + + @Test + void optionDefaultsToOff() { + DebugWireframeMod mod = new DebugWireframeMod(); + ModOption o = mod.options().get(0); + assertEquals(DebugWireframeMod.ALLOWANCE, o.key()); + assertEquals("off", o.defaultValue()); + assertEquals(Allowance.OFF, DebugWireframeMod.allowance(null)); + assertEquals(Allowance.TIPS, DebugWireframeMod.allowance("tips")); + assertEquals(Allowance.ALL, DebugWireframeMod.allowance("all")); + } + + private static AllowanceAreas.Tip tip(int tile, int poly) { + AllowanceAreas.Tip best = null; + for (AllowanceAreas.Tip t : tips) { + if (t.tile() == tile && t.poly() == poly && (best == null || t.reach() > best.reach())) { + best = t; + } + } + return best; + } + + private static boolean contains(AllowanceAreas.Tip t, double x, double z) { + for (double[][] q : t.quads()) { + boolean pos = false, neg = false; + for (int k = 0; k < 4; k++) { + double[] a = q[k], b = q[(k + 1) % 4]; + double c = (b[0] - a[0]) * (z - a[2]) - (b[2] - a[2]) * (x - a[0]); + pos |= c > 0; + neg |= c < 0; + } + if (!(pos && neg)) { + return true; + } + } + return false; + } +}