debug wireframe: allowance areas option (off, floating spots, all edges) from an exact port of the engine floor test, capped by level memory

This commit is contained in:
MineTec committed 2026-11-08 17:41:19 +01:00
1 parent db08cf8bf3
commit 8b3c38fe56
8 files changed
+917 -15

No files matched your search

+7
View File
@@ -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
+10 -6
View File
@@ -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).
+34
View File
@@ -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,
@@ -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<double[][]> 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<Tip> tips(Cells cells) {
List<Tip> 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<double[][]> 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<Hit> 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<Cell, Mesh> cells, List<Tip> 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<byte[]> 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]};
}
}
@@ -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<Category, Integer> markers = new EnumMap<>(Category.class);
public final Map<Integer, Integer> 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<Cell, Mesh> 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<AllowanceAreas.Tip> 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);
}
}
@@ -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<ModOption> 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<Done> 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 + ")");
}
}
@@ -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<byte[]> 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<Long, List<Tile>> byCell = new HashMap<>();
final List<Tile> tiles = new ArrayList<>();
Cells(MapChunk mp, List<Model> trak) {
Map<Integer, List<byte[]>> recs = new HashMap<>();
for (int t = 0; t < mp.tilesFixed.size(); t++) {
int mi = mp.tileModels[t];
List<byte[]> r = recs.computeIfAbsent(mi, k -> {
List<byte[]> 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<Tile> 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<Hit> candidates(Cells cells, int px, int py, int pz) {
List<Hit> 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<Hit> 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);
}
}
@@ -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<AllowanceAreas.Tip> 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<Model> 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<byte[]> 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;
}
}