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:
1 parent
db08cf8bf3
commit
8b3c38fe56
8 files changed
+917
-15
No files matched your search
@@ -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
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user