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

@@ -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;
}
}