diff --git a/src/SiegeFX.Core/Assets/PropBindPose.cs b/src/SiegeFX.Core/Assets/PropBindPose.cs
new file mode 100644
index 00000000..0699c22c
--- /dev/null
+++ b/src/SiegeFX.Core/Assets/PropBindPose.cs
@@ -0,0 +1,80 @@
+using System.Numerics;
+
+namespace SiegeFX.Core.Assets;
+
+/// How a static prop's mesh is rooted in the world: bone 0's bind
+/// pose (the Z-up to Y-up stand-up) for rigid props. The game composes a
+/// map prop as scale × × placement × node, and so do the
+/// tools that mark the same props on the nav mesh.
+public static class PropBindPose
+{
+ /// Phase 21c — bone-0's world bind pose for a static prop, used to
+ /// rotate Z-up authoring space into the engine's Y-up convention. Returns
+ /// identity for unrigged meshes (no BindPose entries) AND for genuinely
+ /// multi-bone skinned meshes (V2.5+ props whose geometry ships in
+ /// world-bind space across several bones).
+ ///
+ /// SC-PROP-ATTACH-BONE — the gate was originally "exactly 1 bone", but many
+ /// rigid props ship EXTRA attach bones (a wall torch carries an AP_light
+ /// socket for its flame) while all their geometry still binds bone 0. Those
+ /// tripped the old count gate, got no root correction, and rendered in raw
+ /// Z-up — the torch cantilevering off the wall. The correct test is whether
+ /// any GEOMETRY binds past bone 0: attach bones carry no verts, so a rigid
+ /// prop reads max-geometry-bone 0 regardless of how many sockets it has.
+ public static Matrix4x4 Root(AspMesh asp)
+ {
+ if (asp.BindPose.Length == 0) return Matrix4x4.Identity;
+ if (!IsRigidRoot(asp)) return Matrix4x4.Identity;
+ var bp = asp.BindPose[0];
+ return Matrix4x4.CreateFromQuaternion(bp.Rotation) *
+ Matrix4x4.CreateTranslation(bp.Translation);
+ }
+
+ /// A prop is "rigid-root" — safe to apply bone-0's bind
+ /// correction to — when ALL geometry binds bone 0 AND every additional
+ /// bone is an attach socket (AP_light for a torch/candlestand flame,
+ /// AP_* generally), not a real animation bone.
+ ///
+ /// SC-PROP-ATTACH-BONE — verified by a tank-wide asp sweep: of the
+ /// multi-bone props that bind all geometry to bone 0, two shapes exist —
+ /// light props (candlestands, torches) whose extra bones are AP_light
+ /// sockets and DO need the Z-up->Y-up correction, and hinged props
+ /// (grates) whose extra bone is a real pivot (Bone04) and currently
+ /// render right WITHOUT it. Gating on attach-socket names fixes the
+ /// former and leaves the latter (and every skinned mesh) untouched, so
+ /// this can't flip a prop that already sits correctly.
+ public static bool IsRigidRoot(AspMesh asp)
+ {
+ if (MaxGeometryBone(asp) != 0) return false;
+ for (int i = 1; i < asp.BoneNames.Count; i++)
+ {
+ var n = asp.BoneNames[i];
+ if (string.IsNullOrEmpty(n) ||
+ !n.StartsWith("ap_", StringComparison.OrdinalIgnoreCase))
+ return false;
+ }
+ return true;
+ }
+
+ /// Highest bone index that any vertex is actually weighted to
+ /// (its highest-weight/primary bone). Unrigged geometry is implicitly on
+ /// the root, so returns 0. Attach bones (AP_light, AP_*) carry no geometry
+ /// and never raise this, so a rigid prop with sockets still reads 0.
+ public static int MaxGeometryBone(AspMesh asp)
+ {
+ if (!asp.HasSkin) return 0;
+ int max = 0;
+ for (int c = 0; c < asp.SkinWeights.Length; c++)
+ {
+ var w = asp.SkinWeights[c];
+ uint b = asp.SkinBones[c];
+ int primary = 0; float pw = -1f;
+ if (w.X > pw) { primary = (int)( b & 0xFF); pw = w.X; }
+ if (w.Y > pw) { primary = (int)((b >> 8) & 0xFF); pw = w.Y; }
+ if (w.Z > pw) { primary = (int)((b >> 16) & 0xFF); pw = w.Z; }
+ if (w.W > pw) { primary = (int)((b >> 24) & 0xFF); pw = w.W; }
+ if (primary > max) max = primary;
+ }
+ return max;
+ }
+}
diff --git a/src/SiegeFX.Core/Nav/NavMesh.cs b/src/SiegeFX.Core/Nav/NavMesh.cs
index 36311996..008cab71 100644
--- a/src/SiegeFX.Core/Nav/NavMesh.cs
+++ b/src/SiegeFX.Core/Nav/NavMesh.cs
@@ -96,6 +96,38 @@ public sealed class NavMesh
public bool IsBlocked(int tri) => Blocked is not null && tri >= 0 && tri < Blocked.Length && Blocked[tri];
+ /// Every triangle whose XZ bounds may overlap the box, each once,
+ /// from the lookup grid: the obstacle stamps used to test all ~50k
+ /// triangles for each of ~100 props, re-run on every break (a krug
+ /// smashing a barrel), a ~200ms hitch each time.
+ private List TrianglesNear(float minX, float minZ, float maxX, float maxZ)
+ {
+ _nearBuf.Clear();
+ if (TriangleCount == 0) return _nearBuf;
+ if (_nearStamp.Length != TriangleCount) { _nearStamp = new int[TriangleCount]; _nearEpoch = 0; }
+ if (++_nearEpoch == int.MaxValue) { Array.Clear(_nearStamp); _nearEpoch = 1; }
+ int cx0 = Math.Clamp((int)MathF.Floor((minX - _gridMinX) / GridCellSize), 0, _gridCellsX - 1);
+ int cx1 = Math.Clamp((int)MathF.Floor((maxX - _gridMinX) / GridCellSize), 0, _gridCellsX - 1);
+ int cz0 = Math.Clamp((int)MathF.Floor((minZ - _gridMinZ) / GridCellSize), 0, _gridCellsZ - 1);
+ int cz1 = Math.Clamp((int)MathF.Floor((maxZ - _gridMinZ) / GridCellSize), 0, _gridCellsZ - 1);
+ for (int cz = cz0; cz <= cz1; cz++)
+ for (int cx = cx0; cx <= cx1; cx++)
+ {
+ var bucket = _grid[cz * _gridCellsX + cx];
+ if (bucket is null) continue;
+ foreach (int t in bucket)
+ {
+ if (_nearStamp[t] == _nearEpoch) continue;
+ _nearStamp[t] = _nearEpoch;
+ _nearBuf.Add(t);
+ }
+ }
+ return _nearBuf;
+ }
+ private readonly List _nearBuf = new();
+ private int[] _nearStamp = Array.Empty();
+ private int _nearEpoch;
+
/// SC-DOORS-BLOCK — reset every obstacle mark so the map can be
/// re-stamped from live state (door opened, prop destroyed). Cheap:
/// one Array.Clear; the caller re-marks everything that still blocks.
@@ -206,6 +238,21 @@ public int MarkObstacle(float worldX, float worldZ, float radius)
/// prop's world top so a floor tri right at the top edge still counts.
private const float ObstacleAboveTopTol = 0.5f;
+ /// How far above its base a prop blocks: the ground it stands on,
+ /// with room for slope under the footprint. Up to its top (+0.5) the old
+ /// band let a 1.4u cart beside a farmhouse block the house floor and
+ /// stair raised a meter above it on the far side of the wall.
+ private const float ObstacleAboveBaseTol = 0.9f;
+
+ /// Top of the vertical band a rectangle obstacle blocks: 0.9u
+ /// above its base, and never above its own top (+0.5). Disc obstacles
+ /// (long fences, big wagons) keep the top-based band: their single
+ /// center base would leave a hillside fence's uphill end unblocked.
+ private static float ObstacleBandTop(float baseY, float topY) =>
+ float.IsFinite(baseY)
+ ? MathF.Min(topY + ObstacleAboveTopTol, baseY + ObstacleAboveBaseTol)
+ : topY + ObstacleAboveTopTol;
+
/// SC-NAV-OBSTACLE-YGATE — Y-aware obstacle marking. Same XZ
/// footprint test as the 3-arg overload, but a triangle is only blocked
/// when its centroid Y falls within
@@ -234,7 +281,7 @@ public int MarkObstacle(float worldX, float worldZ, float radius, float baseY, f
float loY = baseY - ObstacleBelowFloorTol;
float hiY = topY + ObstacleAboveTopTol;
int marked = 0;
- for (int t = 0; t < TriangleCount; t++)
+ foreach (int t in TrianglesNear(worldX - radius, worldZ - radius, worldX + radius, worldZ + radius))
{
if (Blocked[t]) continue;
// Y-gate first — cheapest reject, and the whole point of this
@@ -288,6 +335,116 @@ public int MarkObstacle(float worldX, float worldZ, float radius, float baseY, f
return marked;
}
+ /// SC-NAV-OBSTACLE-RECT — block a prop's own oriented footprint
+ /// rectangle instead of the disc around it. The rectangle starts at
+ /// and spans along
+ /// and along
+ /// (unit XZ axes, at right angles), grown by
+ /// on every side. Same rules as the disc
+ /// overload otherwise: the vertical band gate, centroid containment for
+ /// any triangle, an edge crossing only for small ones. A bed's bounding
+ /// disc reached a third of a meter past its foot onto the stair top
+ /// beside it and sealed a farmhouse's upper floor. The margin applies
+ /// to centroid containment only; an edge crossing seals a small
+ /// triangle only when of the edge
+ /// lies inside the exact footprint.
+ public int MarkObstacleRect(Vector3 corner, Vector3 axisU, float lenU, Vector3 axisV, float lenV,
+ float baseY, float topY, string? tag)
+ {
+ Blocked ??= new bool[TriangleCount];
+ BlockedTag ??= new string?[TriangleCount];
+ if (lenU <= 0f || lenV <= 0f) return 0;
+ float m = ObstacleRectMargin;
+ float u0 = -m, u1 = lenU + m, v0 = -m, v1 = lenV + m;
+ float loY = baseY - ObstacleBelowFloorTol;
+ float hiY = ObstacleBandTop(baseY, topY);
+ // Local footprint coordinates of a world point.
+ (float U, float V) Local(Vector3 p)
+ {
+ float dx = p.X - corner.X, dz = p.Z - corner.Z;
+ return (dx * axisU.X + dz * axisU.Z, dx * axisV.X + dz * axisV.Z);
+ }
+ bool Inside((float U, float V) q) => q.U >= u0 && q.U <= u1 && q.V >= v0 && q.V <= v1;
+ // Liang-Barsky: does the segment p->q cross the rectangle?
+ // The edge test uses the exact footprint and needs a real crossing:
+ // at least ObstacleRectEdgeDepth of the edge inside it. The margin
+ // is for bodies standing on a tile, and slivers merely grazing a
+ // bed's or cart's corner at a farmhouse stair foot (the stair's only
+ // link to the floor) sealed the stair.
+ float InsideLength((float U, float V) p, (float U, float V) q)
+ {
+ const float u0 = 0f, v0 = 0f;
+ float u1 = lenU, v1 = lenV;
+ float t0 = 0f, t1 = 1f, du = q.U - p.U, dv = q.V - p.V;
+ bool Clip(float den, float num)
+ {
+ if (MathF.Abs(den) < 1e-9f) return num >= 0f;
+ float r = num / den;
+ if (den < 0f) { if (r > t1) return false; if (r > t0) t0 = r; }
+ else { if (r < t0) return false; if (r < t1) t1 = r; }
+ return true;
+ }
+ if (!(Clip(-du, p.U - u0) && Clip(du, u1 - p.U) && Clip(-dv, p.V - v0) && Clip(dv, v1 - p.V)))
+ return 0f;
+ return MathF.Max(0f, t1 - t0) * MathF.Sqrt(du * du + dv * dv);
+ }
+ string size = $"{lenU:F2}x{lenV:F2}";
+ int marked = 0;
+ // The footprint's XZ box (with its margin) bounds every triangle the
+ // centroid or edge tests below can hit.
+ var cU = axisU * lenU; var cV = axisV * lenV;
+ float bx0 = corner.X + MathF.Min(0f, cU.X) + MathF.Min(0f, cV.X) - m;
+ float bx1 = corner.X + MathF.Max(0f, cU.X) + MathF.Max(0f, cV.X) + m;
+ float bz0 = corner.Z + MathF.Min(0f, cU.Z) + MathF.Min(0f, cV.Z) - m;
+ float bz1 = corner.Z + MathF.Max(0f, cU.Z) + MathF.Max(0f, cV.Z) + m;
+ foreach (int t in TrianglesNear(bx0, bz0, bx1, bz1))
+ {
+ if (Blocked[t]) continue;
+ float cy = Centroids[t].Y;
+ if (cy < loY || cy > hiY) continue;
+ if (Inside(Local(Centroids[t])))
+ {
+ Blocked[t] = true;
+ BlockedTag[t] = $"{tag}|rect {size} centroid";
+ marked++;
+ continue;
+ }
+ var a = Vertices[Indices[3 * t + 0]];
+ var b = Vertices[Indices[3 * t + 1]];
+ var c = Vertices[Indices[3 * t + 2]];
+ var la = Local(a); var lb = Local(b); var lc = Local(c);
+ float depth = MathF.Max(InsideLength(la, lb), MathF.Max(InsideLength(lb, lc), InsideLength(lc, la)));
+ if (depth < ObstacleRectEdgeDepth) continue;
+ // ...and only a triangle that lies mostly in it: its center within
+ // ObstacleRectEdgeReach of the footprint. A sliver merely clipping
+ // a corner (the farmhouse stair foot) stays walkable.
+ var cc = Local(Centroids[t]);
+ float ou = MathF.Max(0f, MathF.Max(-cc.U, cc.U - lenU));
+ float ov = MathF.Max(0f, MathF.Max(-cc.V, cc.V - lenV));
+ if (ou * ou + ov * ov > ObstacleRectEdgeReach * ObstacleRectEdgeReach) continue;
+ float abx = b.X - a.X, abz = b.Z - a.Z;
+ float acx = c.X - a.X, acz = c.Z - a.Z;
+ float areaXZ = MathF.Abs(abx * acz - abz * acx) * 0.5f;
+ if (areaXZ > EdgeSealMaxAreaXZ) continue;
+ Blocked[t] = true;
+ BlockedTag[t] = $"{tag}|rect {size} edge a={areaXZ:F2} in={depth:F2}";
+ marked++;
+ }
+ return marked;
+ }
+
+ /// How far past its own footprint a rectangle obstacle blocks
+ /// (see ): a little clearance for bodies.
+ public const float ObstacleRectMargin = 0.1f;
+
+ /// How much of a small triangle's edge must lie inside a
+ /// rectangle obstacle's footprint for the edge test to seal it.
+ public const float ObstacleRectEdgeDepth = 0.4f;
+
+ /// How far outside a rectangle obstacle's footprint a small
+ /// triangle's center may lie and still be sealed by an edge crossing.
+ public const float ObstacleRectEdgeReach = 0.3f;
+
/// SC-NAV-EDGE-SEAL-AREA — largest XZ triangle area (m²) the
/// edge-clip test may fully seal. Calibrated from live blame data
/// (session 2026-07-13): genuine sliver-seals (storm-door stairwell)
diff --git a/src/SiegeFX.Core/Nav/PropObstacle.cs b/src/SiegeFX.Core/Nav/PropObstacle.cs
new file mode 100644
index 00000000..92402661
--- /dev/null
+++ b/src/SiegeFX.Core/Nav/PropObstacle.cs
@@ -0,0 +1,135 @@
+using System.Numerics;
+
+namespace SiegeFX.Core.Nav;
+
+/// A static prop's ground footprint in world space: a rectangle in
+/// XZ ( plus ·[0, ]
+/// and ·[0, ]) and the vertical band
+/// the prop occupies.
+public readonly record struct PropFootprint(
+ Vector3 Corner, Vector3 AxisU, float LenU, Vector3 AxisV, float LenV, float BaseY, float TopY)
+{
+ /// Center of the rectangle in XZ.
+ public Vector2 Center => new(Corner.X + (AxisU.X * LenU + AxisV.X * LenV) * 0.5f,
+ Corner.Z + (AxisU.Z * LenU + AxisV.Z * LenV) * 0.5f);
+
+ /// Half the rectangle's diagonal: the disc that covers it.
+ public float Radius => 0.5f * MathF.Sqrt(LenU * LenU + LenV * LenV);
+
+ /// The footprint of a mesh-space bounding box placed by
+ /// . Of the box's three axes, the one the
+ /// transform points most nearly up is the prop's height; the other two
+ /// span the rectangle. A rigid prop authored Z-up and stood up by its
+ /// root bind pose therefore gets mesh X × mesh Y as its footprint and
+ /// mesh Z as its height, as it stands in the world. The band is the
+ /// lowest to highest corner of the placed box.
+ public static PropFootprint FromBounds(Vector3 min, Vector3 max, Matrix4x4 world)
+ {
+ var size = max - min;
+ Span edge = stackalloc Vector3[3];
+ edge[0] = Vector3.TransformNormal(new Vector3(size.X, 0f, 0f), world);
+ edge[1] = Vector3.TransformNormal(new Vector3(0f, size.Y, 0f), world);
+ edge[2] = Vector3.TransformNormal(new Vector3(0f, 0f, size.Z), world);
+ int up = 0;
+ float bestUp = -1f;
+ for (int i = 0; i < 3; i++)
+ {
+ float len = edge[i].Length();
+ float upness = len > 1e-6f ? MathF.Abs(edge[i].Y) / len : 0f;
+ if (upness > bestUp) { bestUp = upness; up = i; }
+ }
+ int iu = up == 0 ? 1 : 0, iv = up == 2 ? 1 : 2;
+ var corner = Vector3.Transform(min, world);
+ var flatU = new Vector3(edge[iu].X, 0f, edge[iu].Z);
+ var flatV = new Vector3(edge[iv].X, 0f, edge[iv].Z);
+ float lenU = flatU.Length(), lenV = flatV.Length();
+ // A box edge running backwards along its world axis still spans the
+ // same rectangle from the same corner; normalizing keeps the sign.
+ var axisU = lenU > 1e-6f ? flatU / lenU : Vector3.UnitX;
+ var axisV = lenV > 1e-6f ? flatV / lenV : Vector3.UnitZ;
+ float baseY = float.PositiveInfinity, topY = float.NegativeInfinity;
+ for (int k = 0; k < 8; k++)
+ {
+ var c = new Vector3((k & 1) == 0 ? min.X : max.X,
+ (k & 2) == 0 ? min.Y : max.Y,
+ (k & 4) == 0 ? min.Z : max.Z);
+ float y = Vector3.Transform(c, world).Y;
+ baseY = MathF.Min(baseY, y);
+ topY = MathF.Max(topY, y);
+ }
+ return new PropFootprint(corner, axisU, lenU, axisV, lenV, baseY, topY);
+ }
+}
+
+/// Marks a blocking prop on the nav mesh. The game
+/// (RenderHost.MarkAllObstacles) and the nav tools (hero-sim, roam-sim)
+/// both mark props through here, so a tool replay blocks exactly what the
+/// game blocks.
+public static class PropObstacle
+{
+ /// Props this tall or taller also block sight lines (fences
+ /// and crates under it don't: archers shoot over them).
+ public const float SightOccluderMinHeight = 2.2f;
+
+ /// Footprints smaller than this disc radius block nothing.
+ public const float MinRadius = 0.5f;
+
+ /// Marks on and
+ /// returns the number of triangles it blocked. Blame tags carry the
+ /// prop's tag and its footprint center. Tall props add their discs to
+ /// when given.
+ public static int Mark(NavMesh mesh, in PropFootprint f, string tag,
+ List<(Vector2 C, float R, float TopY)>? sightOccluders = null)
+ {
+ float radius = f.Radius;
+ if (radius < MinRadius) return 0;
+ var center = f.Center;
+ string blame = FormattableString.Invariant($"{tag}@{center.X:F1},{center.Y:F1}");
+ bool occludesSight = f.TopY - f.BaseY >= SightOccluderMinHeight;
+ float longLen = MathF.Max(f.LenU, f.LenV);
+ float shortLen = MathF.Max(0.4f, MathF.Min(f.LenU, f.LenV));
+ int marked = 0;
+ if (longLen > shortLen * 2.2f && longLen > 2f)
+ {
+ // SC-PATHING — elongated props (fence sections, walls) block as a
+ // CAPSULE of small discs along their long axis: one bounding disc
+ // would over-block the walkable ground beside a 6u fence.
+ var axis = f.LenU >= f.LenV ? f.AxisU : f.AxisV;
+ float discR = MathF.Max(0.45f, shortLen * 0.5f);
+ int discs = Math.Max(2, (int)MathF.Ceiling(longLen / (discR * 1.4f)));
+ for (int di = 0; di < discs; di++)
+ {
+ float along = -longLen * 0.5f + longLen * (di / (float)(discs - 1));
+ float cx = center.X + axis.X * along, cz = center.Y + axis.Z * along;
+ marked += mesh.MarkObstacle(cx, cz, discR, f.BaseY, f.TopY, blame);
+ if (occludesSight) sightOccluders?.Add((new Vector2(cx, cz), discR, f.TopY));
+ }
+ }
+ else if (shortLen > 2.4f)
+ {
+ // SC-PATHING-FOOTPRINT — big squat props (wagons, stalls,
+ // boulders) block their rectangle as a grid of small discs; one
+ // bounding disc overshoots the corners and sealed rooms and pens.
+ const float gridR = 0.8f;
+ int nu = Math.Max(2, (int)MathF.Ceiling(f.LenU / (gridR * 1.4f)) + 1);
+ int nv = Math.Max(2, (int)MathF.Ceiling(f.LenV / (gridR * 1.4f)) + 1);
+ for (int iu = 0; iu < nu; iu++)
+ for (int iv = 0; iv < nv; iv++)
+ {
+ var c = f.Corner + f.AxisU * (f.LenU * iu / (nu - 1)) + f.AxisV * (f.LenV * iv / (nv - 1));
+ marked += mesh.MarkObstacle(c.X, c.Z, gridR, f.BaseY, f.TopY, blame);
+ if (occludesSight) sightOccluders?.Add((new Vector2(c.X, c.Z), gridR, f.TopY));
+ }
+ }
+ else
+ {
+ // SC-NAV-OBSTACLE-RECT — mid-size props block their own rectangle,
+ // not the disc around it (the disc overshoots a rectangle's sides).
+ marked = f.LenU > 0.05f && f.LenV > 0.05f
+ ? mesh.MarkObstacleRect(f.Corner, f.AxisU, f.LenU, f.AxisV, f.LenV, f.BaseY, f.TopY, blame)
+ : mesh.MarkObstacle(center.X, center.Y, radius, f.BaseY, f.TopY, blame);
+ if (occludesSight) sightOccluders?.Add((center, radius, f.TopY));
+ }
+ return marked;
+ }
+}
diff --git a/src/SiegeFX.Runtime/Render/RenderHost.cs b/src/SiegeFX.Runtime/Render/RenderHost.cs
index 619e15d7..62b9764d 100644
--- a/src/SiegeFX.Runtime/Render/RenderHost.cs
+++ b/src/SiegeFX.Runtime/Render/RenderHost.cs
@@ -20892,16 +20892,14 @@ private void TickFragDebris(float dt)
/// Audit fold (run a3b8709caef6f3494):
/// - was inlined inside LoadStaticProps; only saw the
/// newly-loaded region's props, missed previously-loaded.
- /// - World-radius math now uses true world-space corner
- /// positions of the local AABB rather than naive column
- /// length, so rotated placements + non-Y rotations get the
- /// correct effective radius.
+ /// - footprints come from the placed bounding box
+ /// (SiegeFX.Core.Nav.PropFootprint), so rotated, scaled and
+ /// stood-up (root bind pose) props block where they stand.
// SC-PATHING-LOS — tall does_block_path props double as SIGHT occluders
// for standoff brains: XZ circle + world top Y, tested against an
// eye-height sight line. Props under ~2.2u world height (fences, crates)
// never occlude — archers shoot over them like retail.
private readonly List<(Vector2 C, float R, float TopY)> _sightOccluders = new();
- private const float SightOccluderMinHeight = 2.2f;
private void MarkAllObstacles()
{
@@ -20914,9 +20912,6 @@ private void MarkAllObstacles()
_sightOccluders.Clear();
int obstacles = 0;
int triangles = 0;
- // CA2014 — hoist the 4-corner stackalloc above the loop so a
- // huge prop count doesn't grow the stack frame linearly.
- Span corners = stackalloc Vector3[4];
foreach (var prop in _staticProps)
{
if (prop.IsDestroyed) continue;
@@ -20943,109 +20938,16 @@ private void MarkAllObstacles()
var blockTrim = (blockAttr ?? "").Trim().Trim('"').ToLowerInvariant();
if (blockTrim != "true" && blockTrim != "1") continue;
}
- // World-space XZ radius: transform the local AABB's 4
- // XZ corners (Y collapsed) by the placement matrix,
- // then take the max distance from the translation
- // origin. Handles arbitrary rotation + non-uniform
- // scale correctly (the previous M11+M13 length shortcut
- // dropped M12/M32 and silently under-blocked tilted
- // placements).
- var minL = prop.Mesh.Min;
- var maxL = prop.Mesh.Max;
- float midY = (minL.Y + maxL.Y) * 0.5f;
- corners[0] = new(minL.X, midY, minL.Z);
- corners[1] = new(maxL.X, midY, minL.Z);
- corners[2] = new(maxL.X, midY, maxL.Z);
- corners[3] = new(minL.X, midY, maxL.Z);
- var origin = prop.World.Translation;
- float maxR2 = 0f;
- foreach (var corner in corners)
- {
- var wc = Vector3.Transform(corner, prop.World);
- float dx = wc.X - origin.X;
- float dz = wc.Z - origin.Z;
- float r2 = dx * dx + dz * dz;
- if (r2 > maxR2) maxR2 = r2;
- }
- float radius = MathF.Sqrt(maxR2);
- if (radius < 0.5f) continue;
- // SC-PATHING — elongated props (fence sections, walls, carts)
- // block as a CAPSULE of small discs along their world long axis
- // instead of one bounding disc: a 6u fence with a 3u disc would
- // over-block the walkable grass beside it and turn "walk around
- // the fence" into a huge detour.
- var wcA = Vector3.Transform(corners[0], prop.World);
- var wcB = Vector3.Transform(corners[1], prop.World);
- var wcD = Vector3.Transform(corners[3], prop.World);
- float sideAB = new Vector2(wcB.X - wcA.X, wcB.Z - wcA.Z).Length(); // local X extent
- float sideAD = new Vector2(wcD.X - wcA.X, wcD.Z - wcA.Z).Length(); // local Z extent
- float longLen = MathF.Max(sideAB, sideAD);
- float shortLen = MathF.Max(0.4f, MathF.Min(sideAB, sideAD));
- // SC-PATHING-LOS — world height decides sight occlusion. Center
- // column transform is exact for yaw-only placements (the norm);
- // tilted props approximate fine at this granularity.
- var wBase = Vector3.Transform(
- new Vector3((minL.X + maxL.X) * 0.5f, minL.Y, (minL.Z + maxL.Z) * 0.5f), prop.World);
- var wTop = Vector3.Transform(
- new Vector3((minL.X + maxL.X) * 0.5f, maxL.Y, (minL.Z + maxL.Z) * 0.5f), prop.World);
- float propTopY = MathF.Max(wBase.Y, wTop.Y);
- // SC-NAV-OBSTACLE-YGATE — the prop's world vertical band. Gates
- // MarkObstacle so a ground-floor does_block_path prop can't
- // blanket-block the basement stacked beneath it in the same snode
- // (that severed the stair descent and stranded the cellar on its
- // own nav component). Center-column Y is exact for the yaw-only
- // norm; min/max guards a flipped placement.
- float propBaseY = MathF.Min(wBase.Y, wTop.Y);
- bool occludesSight = MathF.Abs(wTop.Y - wBase.Y) >= SightOccluderMinHeight;
+ // SC-NAV-FOOTPRINT — the prop's footprint and height band as it
+ // stands in the world (shared with the nav tools, so a replay
+ // blocks exactly what the game blocks). The old local-X × local-Z
+ // footprint was wrong for every prop stood up by its root bind
+ // pose (authored Z-up): its "footprint" spanned the prop's height.
+ var footprint = SiegeFX.Core.Nav.PropFootprint.FromBounds(prop.Mesh.Min, prop.Mesh.Max, prop.World);
// SC-NAV-BLAME — every triangle this prop blocks remembers who
- // sealed it, so a "no corridor" failure can name the culprit.
- string blameTag = $"{prop.Template}#0x{prop.Scid:X8}";
- int marked = 0;
- if (longLen > shortLen * 2.2f && longLen > 2f)
- {
- var axis = sideAB >= sideAD
- ? Vector3.Normalize(new Vector3(wcB.X - wcA.X, 0f, wcB.Z - wcA.Z))
- : Vector3.Normalize(new Vector3(wcD.X - wcA.X, 0f, wcD.Z - wcA.Z));
- float halfLong = longLen * 0.5f;
- float discR = MathF.Max(0.45f, shortLen * 0.5f);
- int discs = Math.Max(2, (int)MathF.Ceiling(longLen / (discR * 1.4f)));
- for (int di = 0; di < discs; di++)
- {
- float along = -halfLong + longLen * (di / (float)(discs - 1));
- var c = origin + axis * along;
- marked += _navMesh.MarkObstacle(c.X, c.Z, discR, propBaseY, propTopY, blameTag);
- if (occludesSight)
- _sightOccluders.Add((new Vector2(c.X, c.Z), discR, propTopY));
- }
- }
- else if (shortLen > 2.4f)
- {
- // SC-PATHING-FOOTPRINT — big squat props (wagons, stalls,
- // boulders, crypt furniture) block their OBB rectangle as a
- // grid of small discs. One bounding disc overshoots the
- // corners by up to 40% and sealed rooms/pens — the parity
- // roam soak froze 35 spawns inside such footprints.
- var axU = Vector3.Normalize(new Vector3(wcB.X - wcA.X, 0f, wcB.Z - wcA.Z));
- var axV = Vector3.Normalize(new Vector3(wcD.X - wcA.X, 0f, wcD.Z - wcA.Z));
- const float gridR = 0.8f;
- int nu = Math.Max(2, (int)MathF.Ceiling(sideAB / (gridR * 1.4f)) + 1);
- int nv = Math.Max(2, (int)MathF.Ceiling(sideAD / (gridR * 1.4f)) + 1);
- for (int iu = 0; iu < nu; iu++)
- for (int iv = 0; iv < nv; iv++)
- {
- var c = wcA + axU * (sideAB * iu / (nu - 1))
- + axV * (sideAD * iv / (nv - 1));
- marked += _navMesh.MarkObstacle(c.X, c.Z, gridR, propBaseY, propTopY, blameTag);
- if (occludesSight)
- _sightOccluders.Add((new Vector2(c.X, c.Z), gridR, propTopY));
- }
- }
- else
- {
- marked = _navMesh.MarkObstacle(origin.X, origin.Z, radius, propBaseY, propTopY, blameTag);
- if (occludesSight)
- _sightOccluders.Add((new Vector2(origin.X, origin.Z), radius, propTopY));
- }
+ // sealed it (and where), so a "no corridor" failure can name it.
+ int marked = SiegeFX.Core.Nav.PropObstacle.Mark(_navMesh, footprint,
+ $"{prop.Template}#0x{prop.Scid:X8}", _sightOccluders);
if (marked > 0)
{
obstacles++;
@@ -24548,19 +24450,6 @@ private static Matrix4x4 AbsoluteBindPose(AspMesh asp, int idx)
return m;
}
- /// Phase 21c — bone-0's world bind pose for a static prop, used to
- /// rotate Z-up authoring space into the engine's Y-up convention. Returns
- /// identity for unrigged meshes (no BindPose entries) AND for genuinely
- /// multi-bone skinned meshes (V2.5+ props whose geometry ships in
- /// world-bind space across several bones).
- ///
- /// SC-PROP-ATTACH-BONE — the gate was originally "exactly 1 bone", but many
- /// rigid props ship EXTRA attach bones (a wall torch carries an AP_light
- /// socket for its flame) while all their geometry still binds bone 0. Those
- /// tripped the old count gate, got no root correction, and rendered in raw
- /// Z-up — the torch cantilevering off the wall. The correct test is whether
- /// any GEOMETRY binds past bone 0: attach bones carry no verts, so a rigid
- /// prop reads max-geometry-bone 0 regardless of how many sockets it has.
/// World transform of an item placed in a region
/// (inventory.gas), composed like
/// but rooted at bone 0's bind pose whenever all the geometry hangs on
@@ -24579,7 +24468,7 @@ private static Matrix4x4 AbsoluteBindPose(AspMesh asp, int idx)
private Matrix4x4 ComposeItemPlacementWorld(AspMesh asp, SiegeFX.Core.Assets.NodePlacement p)
{
var root = Matrix4x4.Identity;
- if (asp.BindPose.Length > 0 && MaxGeometryBone(asp) == 0)
+ if (asp.BindPose.Length > 0 && PropBindPose.MaxGeometryBone(asp) == 0)
{
bool gripped = asp.BoneNames.Count > 1 && asp.BindPose.Length > 1
&& string.Equals(asp.BoneNames[0], "grip", StringComparison.OrdinalIgnoreCase)
@@ -24594,62 +24483,10 @@ private Matrix4x4 ComposeItemPlacementWorld(AspMesh asp, SiegeFX.Core.Assets.Nod
return local * nodeWorld;
}
- private static Matrix4x4 ComputeRootBindPose(AspMesh asp)
- {
- if (asp.BindPose.Length == 0) return Matrix4x4.Identity;
- if (!IsRigidRootProp(asp)) return Matrix4x4.Identity;
- var bp = asp.BindPose[0];
- return Matrix4x4.CreateFromQuaternion(bp.Rotation) *
- Matrix4x4.CreateTranslation(bp.Translation);
- }
-
- /// A prop is "rigid-root" — safe to apply bone-0's bind
- /// correction to — when ALL geometry binds bone 0 AND every additional
- /// bone is an attach socket (AP_light for a torch/candlestand flame,
- /// AP_* generally), not a real animation bone.
- ///
- /// SC-PROP-ATTACH-BONE — verified by a tank-wide asp sweep: of the
- /// multi-bone props that bind all geometry to bone 0, two shapes exist —
- /// light props (candlestands, torches) whose extra bones are AP_light
- /// sockets and DO need the Z-up->Y-up correction, and hinged props
- /// (grates) whose extra bone is a real pivot (Bone04) and currently
- /// render right WITHOUT it. Gating on attach-socket names fixes the
- /// former and leaves the latter (and every skinned mesh) untouched, so
- /// this can't flip a prop that already sits correctly.
- private static bool IsRigidRootProp(AspMesh asp)
- {
- if (MaxGeometryBone(asp) != 0) return false;
- for (int i = 1; i < asp.BoneNames.Count; i++)
- {
- var n = asp.BoneNames[i];
- if (string.IsNullOrEmpty(n) ||
- !n.StartsWith("ap_", StringComparison.OrdinalIgnoreCase))
- return false;
- }
- return true;
- }
-
- /// Highest bone index that any vertex is actually weighted to
- /// (its highest-weight/primary bone). Unrigged geometry is implicitly on
- /// the root, so returns 0. Attach bones (AP_light, AP_*) carry no geometry
- /// and never raise this, so a rigid prop with sockets still reads 0.
- private static int MaxGeometryBone(AspMesh asp)
- {
- if (!asp.HasSkin) return 0;
- int max = 0;
- for (int c = 0; c < asp.SkinWeights.Length; c++)
- {
- var w = asp.SkinWeights[c];
- uint b = asp.SkinBones[c];
- int primary = 0; float pw = -1f;
- if (w.X > pw) { primary = (int)( b & 0xFF); pw = w.X; }
- if (w.Y > pw) { primary = (int)((b >> 8) & 0xFF); pw = w.Y; }
- if (w.Z > pw) { primary = (int)((b >> 16) & 0xFF); pw = w.Z; }
- if (w.W > pw) { primary = (int)((b >> 24) & 0xFF); pw = w.W; }
- if (primary > max) max = primary;
- }
- return max;
- }
+ // Root bind pose and rigid-root test: SiegeFX.Core.Assets.PropBindPose,
+ // shared with the tools so their obstacle footprints match the game's.
+ private static Matrix4x4 ComputeRootBindPose(AspMesh asp) => PropBindPose.Root(asp);
+ private static bool IsRigidRootProp(AspMesh asp) => PropBindPose.IsRigidRoot(asp);
/// Phase 21a-3 — rebuild the nav mesh against the current unified
/// region scope (player + all preloaded neighbors). Called both at the
diff --git a/src/SiegeFX.Tools/Program.cs b/src/SiegeFX.Tools/Program.cs
index c1b9c2af..a0e1e52f 100644
--- a/src/SiegeFX.Tools/Program.cs
+++ b/src/SiegeFX.Tools/Program.cs
@@ -3061,14 +3061,16 @@ static int CmdWorldPath(string[] a)
}
/// SC-PATHING parity — mark does_block_path props on the sim mesh
-/// with the same rules RenderHost.MarkAllObstacles uses in-game (chain-
-/// resolved does_block_path, base_door skip, bounding disc vs capsule-of-
-/// discs for elongated props), so roam-sim soaks the mesh the game plays.
-/// Resolves Logic.dsres/Objects.dsres as <root>/Resources/
-/// siblings of the map tank; missing tanks = unmarked mesh + a loud note.
-/// MIRROR of RenderHost.MarkAllObstacles — keep the two in step.
+/// as RenderHost.MarkAllObstacles does in-game (chain-resolved
+/// does_block_path, base_door skip), so roam-sim and hero-sim soak the mesh
+/// the game plays. Each prop is placed like the game's map props (scale ×
+/// root bind pose × placement × node) and marked through the shared
+/// . Resolves
+/// Logic.dsres/Objects.dsres as <root>/Resources/ siblings of
+/// the map tank; missing tanks = unmarked mesh + a loud note.
static int MarkRoamSimObstacles(SiegeFX.Core.Nav.NavMesh mesh, string mapTankPath,
- string regionsArg, Dictionary nodeWorld)
+ string regionsArg, Dictionary nodeWorld, ICollection? broken = null,
+ List<(string Tag, SiegeFX.Core.Nav.PropFootprint F)>? footprints = null)
{
string? root = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetFullPath(mapTankPath)));
string logicPath = root is null ? "" : Path.Combine(root, "Resources", "Logic.dsres");
@@ -3088,8 +3090,7 @@ static int MarkRoamSimObstacles(SiegeFX.Core.Nav.NavMesh mesh, string mapTankPat
foreach (var f in objectsReader.ListFiles())
if (f.EndsWith(".asp", StringComparison.OrdinalIgnoreCase))
aspIndex[Path.GetFileNameWithoutExtension(f)] = f;
- var boundsCache = new Dictionary(StringComparer.OrdinalIgnoreCase);
- Span corners = stackalloc Vector3[4];
+ var propMeshes = new Dictionary(StringComparer.OrdinalIgnoreCase);
using var obsMapTank = TankFile.Open(mapTankPath);
var obsMapReader = new TankReader(obsMapTank);
foreach (var raw in regionsArg.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
@@ -3104,6 +3105,11 @@ static int MarkRoamSimObstacles(SiegeFX.Core.Nav.NavMesh mesh, string mapTankPat
{
if (!nodeWorld.TryGetValue(p.Placement.NodeGuid, out var nw)) continue;
if (!store.TryGet(p.TemplateName, out var tpl) || tpl is null) continue;
+ // Props the player broke in the session being replayed are
+ // gone in-game, so they block nothing (by scid or template).
+ string tag = $"{p.TemplateName} 0x{p.Scid:X8}";
+ if (broken is not null && (broken.Contains($"0x{p.Scid:X8}") || broken.Contains(p.TemplateName)))
+ continue;
bool isDoor = false;
for (var t = tpl; t is not null; t = t.Specializes)
if (string.Equals(t.Name, "base_door", StringComparison.OrdinalIgnoreCase))
@@ -3115,97 +3121,40 @@ static int MarkRoamSimObstacles(SiegeFX.Core.Nav.NavMesh mesh, string mapTankPat
if (blockTrim != "true" && blockTrim != "1") continue;
var modelName = store.GetAttribute(tpl, "aspect", "model")?.Trim().Trim('"');
if (string.IsNullOrEmpty(modelName)) continue;
- if (!boundsCache.TryGetValue(modelName!, out var bounds))
+ if (!propMeshes.TryGetValue(modelName!, out var pm))
{
- bounds = null;
+ pm = null;
if (aspIndex.TryGetValue(modelName!, out var aspPath))
{
try
{
var asp = SiegeFX.Core.Assets.AspMesh.Load(objectsReader.ExtractToMemory(aspPath));
+ // Bounds over the drawn corners, as the game's StaticMesh takes them.
var mn = new Vector3(float.PositiveInfinity);
var mx = new Vector3(float.NegativeInfinity);
- foreach (var v in asp.Positions) { mn = Vector3.Min(mn, v); mx = Vector3.Max(mx, v); }
- if (mn.X <= mx.X) bounds = (mn, mx);
+ foreach (var c in asp.Corners) { var v = asp.Positions[c.VertexIndex]; mn = Vector3.Min(mn, v); mx = Vector3.Max(mx, v); }
+ if (mn.X <= mx.X) pm = (mn, mx, SiegeFX.Core.Assets.PropBindPose.Root(asp));
}
- catch { bounds = null; }
+ catch { pm = null; }
}
- boundsCache[modelName!] = bounds;
+ propMeshes[modelName!] = pm;
}
- if (bounds is not { } bb) continue;
- var world = Matrix4x4.CreateFromQuaternion(p.Placement.Orientation)
+ if (pm is not { } m) continue;
+ // The game's map-prop transform (RenderHost.ComposePlacementWorld
+ // + scale_multiplier): scale × root bind pose × placement × node.
+ float scale = 1f;
+ var scaleAttr = (SiegeFX.Core.Assets.TemplateStore.FindChild(p.Node, "aspect") is { } instAspect
+ ? SiegeFX.Core.Assets.TemplateStore.FindAttr(instAspect, "scale_multiplier") : null)
+ ?? store.GetAttribute(tpl, "aspect", "scale_multiplier");
+ if (scaleAttr is not null && float.TryParse(scaleAttr, System.Globalization.NumberStyles.Float,
+ System.Globalization.CultureInfo.InvariantCulture, out var sv)) scale = sv;
+ var world = Matrix4x4.CreateScale(scale) * m.Root
+ * Matrix4x4.CreateFromQuaternion(p.Placement.Orientation)
* Matrix4x4.CreateTranslation(p.Placement.LocalPosition)
* nw;
- // — everything below mirrors RenderHost.MarkAllObstacles —
- var minL = bb.Min; var maxL = bb.Max;
- float midY = (minL.Y + maxL.Y) * 0.5f;
- corners[0] = new(minL.X, midY, minL.Z);
- corners[1] = new(maxL.X, midY, minL.Z);
- corners[2] = new(maxL.X, midY, maxL.Z);
- corners[3] = new(minL.X, midY, maxL.Z);
- var origin = world.Translation;
- float maxR2 = 0f;
- foreach (var corner in corners)
- {
- var wc0 = Vector3.Transform(corner, world);
- float dx = wc0.X - origin.X, dz = wc0.Z - origin.Z;
- float r2 = dx * dx + dz * dz;
- if (r2 > maxR2) maxR2 = r2;
- }
- float radius = MathF.Sqrt(maxR2);
- if (radius < 0.5f) continue;
- // SC-NAV-OBSTACLE-YGATE — mirror the in-game prop vertical
- // band so the soak mesh gates obstacles per floor too.
- var wBase = Vector3.Transform(
- new Vector3((minL.X + maxL.X) * 0.5f, minL.Y, (minL.Z + maxL.Z) * 0.5f), world);
- var wTop = Vector3.Transform(
- new Vector3((minL.X + maxL.X) * 0.5f, maxL.Y, (minL.Z + maxL.Z) * 0.5f), world);
- float propBaseY = MathF.Min(wBase.Y, wTop.Y);
- float propTopY = MathF.Max(wBase.Y, wTop.Y);
- var wcA = Vector3.Transform(corners[0], world);
- var wcB = Vector3.Transform(corners[1], world);
- var wcD = Vector3.Transform(corners[3], world);
- float sideAB = new Vector2(wcB.X - wcA.X, wcB.Z - wcA.Z).Length();
- float sideAD = new Vector2(wcD.X - wcA.X, wcD.Z - wcA.Z).Length();
- float longLen = MathF.Max(sideAB, sideAD);
- float shortLen = MathF.Max(0.4f, MathF.Min(sideAB, sideAD));
- int marked = 0;
- if (longLen > shortLen * 2.2f && longLen > 2f)
- {
- var axis = sideAB >= sideAD
- ? Vector3.Normalize(new Vector3(wcB.X - wcA.X, 0f, wcB.Z - wcA.Z))
- : Vector3.Normalize(new Vector3(wcD.X - wcA.X, 0f, wcD.Z - wcA.Z));
- float halfLong = longLen * 0.5f;
- float discR = MathF.Max(0.45f, shortLen * 0.5f);
- int n = Math.Max(2, (int)MathF.Ceiling(longLen / (discR * 1.4f)));
- for (int di = 0; di < n; di++)
- {
- float along = -halfLong + longLen * (di / (float)(n - 1));
- var c = origin + axis * along;
- marked += mesh.MarkObstacle(c.X, c.Z, discR, propBaseY, propTopY);
- }
- }
- else if (shortLen > 2.4f)
- {
- // SC-PATHING-FOOTPRINT — OBB rectangle grid, mirroring
- // the in-game branch (bounding disc sealed rooms).
- var axU = Vector3.Normalize(new Vector3(wcB.X - wcA.X, 0f, wcB.Z - wcA.Z));
- var axV = Vector3.Normalize(new Vector3(wcD.X - wcA.X, 0f, wcD.Z - wcA.Z));
- const float gridR = 0.8f;
- int nu = Math.Max(2, (int)MathF.Ceiling(sideAB / (gridR * 1.4f)) + 1);
- int nv = Math.Max(2, (int)MathF.Ceiling(sideAD / (gridR * 1.4f)) + 1);
- for (int iu = 0; iu < nu; iu++)
- for (int iv = 0; iv < nv; iv++)
- {
- var c = wcA + axU * (sideAB * iu / (nu - 1))
- + axV * (sideAD * iv / (nv - 1));
- marked += mesh.MarkObstacle(c.X, c.Z, gridR, propBaseY, propTopY);
- }
- }
- else
- {
- marked = mesh.MarkObstacle(origin.X, origin.Z, radius, propBaseY, propTopY);
- }
+ var footprint = SiegeFX.Core.Nav.PropFootprint.FromBounds(m.Min, m.Max, world);
+ footprints?.Add((tag, footprint));
+ int marked = SiegeFX.Core.Nav.PropObstacle.Mark(mesh, footprint, tag.Replace(' ', '#'));
if (marked > 0) { props++; discs += marked; }
}
}
@@ -3675,6 +3624,50 @@ static int CmdRegionChaseSim(string[] a)
return failures.Count == 0 ? 0 : 3;
}
+/// `hero-sim --dump` — the nav mesh around a point as text: each
+/// blocking prop's footprint and Y band, then each triangle (source snode,
+/// vertices, neighbours across each edge, extra links, and the blame tag of
+/// whatever blocks it). Enough to redraw the area and check a sealed stair
+/// or doorway against its walls without the game data.
+static int DumpNavArea(SiegeFX.Core.Nav.NavMesh mesh,
+ List<(string Tag, SiegeFX.Core.Nav.PropFootprint F)> footprints,
+ Vector3 center, float radius, string path)
+{
+ var ci = System.Globalization.CultureInfo.InvariantCulture;
+ string F(float v) => v.ToString("F3", ci);
+ var c2 = new Vector2(center.X, center.Z);
+ using var w = new StreamWriter(path);
+ w.WriteLine($"# nav area around ({F(center.X)},{F(center.Z)}) r={F(radius)}");
+ w.WriteLine("# prop base= top= quad= x4");
+ w.WriteLine("# tri snode= kind= v= x3 n= extra= blocked=");
+ int props = 0, tris = 0;
+ foreach (var (tag, f) in footprints)
+ {
+ var a0 = new Vector2(f.Corner.X, f.Corner.Z);
+ var du = new Vector2(f.AxisU.X, f.AxisU.Z) * f.LenU;
+ var dv = new Vector2(f.AxisV.X, f.AxisV.Z) * f.LenV;
+ var quad = new[] { a0, a0 + du, a0 + du + dv, a0 + dv };
+ if (!quad.Any(q => Vector2.Distance(q, c2) <= radius + 3f)) continue;
+ w.WriteLine($"prop {tag.Replace(' ', '#')} base={F(f.BaseY)} top={F(f.TopY)} quad=" +
+ string.Join(' ', quad.Select(q => $"{F(q.X)},{F(q.Y)}")));
+ props++;
+ }
+ for (int t = 0; t < mesh.TriangleCount; t++)
+ {
+ var cc = mesh.Centroids[t];
+ if (Vector2.Distance(new Vector2(cc.X, cc.Z), c2) > radius) continue;
+ var v = new[] { mesh.Vertices[mesh.Indices[3 * t]], mesh.Vertices[mesh.Indices[3 * t + 1]], mesh.Vertices[mesh.Indices[3 * t + 2]] };
+ string extra = mesh.ExtraLinks is not null && mesh.ExtraLinks.TryGetValue(t, out var ex) ? string.Join(',', ex) : "-";
+ string blocked = mesh.Blocked is not null && mesh.Blocked[t] ? (mesh.BlockedTag?[t] ?? "?").Replace(' ', '_') : "-";
+ w.WriteLine($"tri {t} snode=0x{mesh.SourceSnodeGuid[t]:X8} kind={mesh.Kinds[t]} v=" +
+ string.Join(' ', v.Select(p => $"{F(p.X)},{F(p.Y)},{F(p.Z)}")) +
+ $" n={mesh.Neighbors[3 * t]},{mesh.Neighbors[3 * t + 1]},{mesh.Neighbors[3 * t + 2]} extra={extra} blocked={blocked}");
+ tris++;
+ }
+ Console.WriteLine($"Dump : {tris} triangle(s), {props} blocking prop(s) -> {path}");
+ return 0;
+}
+
/// `region hero-sim` — headless audit of the HERO's movement, the
/// half chase-sim and roam-sim don't cover. Two passes from random standing
/// points, with the hero's own follower settings (Player traversal, partial-
@@ -3696,8 +3689,11 @@ static int CmdRegionHeroSim(string[] a)
{
if (a.Length < 3)
{
- Console.Error.WriteLine("usage: siegefx region hero-sim [--clicks=150] [--range=10] [--speed=4.5] [--seed=1] [--near=x,z,r] [--diag]");
+ Console.Error.WriteLine("usage: siegefx region hero-sim [--clicks=150] [--range=10] [--speed=4.5] [--seed=1] [--near=x,z,r] [--diag] [--from=x,y,z --to=x,y,z] [--broken=,...] [--dump=]");
Console.Error.WriteLine(" walks the hero to random clicks and up to random targets; reports failures and their start/goal to replay");
+ Console.Error.WriteLine(" --from/--to replays one click walk (coordinates as the game log prints them) with the follower's diagnostics on");
+ Console.Error.WriteLine(" --broken leaves out props broken in the session being replayed (they block nothing in-game)");
+ Console.Error.WriteLine(" --dump writes every nav triangle and blocking prop footprint within --near to a text file, then exits");
return 1;
}
int clicks = 150, seed = 1;
@@ -3705,11 +3701,20 @@ static int CmdRegionHeroSim(string[] a)
bool diag = false;
Vector3 nearCenter = default;
float nearRadius = 0f;
+ Vector3? replayFrom = null, replayTo = null;
+ string? dumpPath = null;
+ var broken = new HashSet(StringComparer.OrdinalIgnoreCase);
static bool TryF(string s, out float v) =>
float.TryParse(s, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out v);
for (int i = 3; i < a.Length; i++)
{
if (a[i] == "--diag") diag = true;
+ else if (a[i].StartsWith("--from=") && TryParseVec3(a[i]["--from=".Length..], out var fv)) replayFrom = fv;
+ else if (a[i].StartsWith("--to=") && TryParseVec3(a[i]["--to=".Length..], out var tv)) replayTo = tv;
+ else if (a[i].StartsWith("--broken="))
+ foreach (var b in a[i]["--broken=".Length..].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ broken.Add(b.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? "0x" + b[2..].ToUpperInvariant() : b);
+ else if (a[i].StartsWith("--dump=")) dumpPath = a[i]["--dump=".Length..];
else if (a[i].StartsWith("--clicks=") && int.TryParse(a[i]["--clicks=".Length..], out var cv)) clicks = Math.Clamp(cv, 1, 5000);
else if (a[i].StartsWith("--range=") && TryF(a[i]["--range=".Length..], out var rv)) range = rv;
else if (a[i].StartsWith("--speed=") && TryF(a[i]["--speed=".Length..], out var sv)) speed = sv;
@@ -3727,7 +3732,13 @@ static bool TryF(string s, out float v) =>
var nodeWorld = new Dictionary();
foreach (var (guid, _, _, w) in LoadWorldNodePlacements(a[0], a[1], a[2]))
nodeWorld[guid] = w;
- MarkRoamSimObstacles(mesh, a[0], a[2], nodeWorld);
+ var footprints = dumpPath is null ? null : new List<(string Tag, SiegeFX.Core.Nav.PropFootprint F)>();
+ MarkRoamSimObstacles(mesh, a[0], a[2], nodeWorld, broken, footprints);
+ if (dumpPath is not null)
+ {
+ if (nearRadius <= 0f) { Console.Error.WriteLine("--dump needs --near=x,z,r"); return 1; }
+ return DumpNavArea(mesh, footprints!, nearCenter, nearRadius, dumpPath);
+ }
var traversal = NavTraversal.Player;
var rng = new Random(seed);
@@ -3780,6 +3791,36 @@ NavFollower NewHero(Vector3 pos) =>
static float DistXZ(Vector3 p, Vector3 q) => MathF.Sqrt((p.X - q.X) * (p.X - q.X) + (p.Z - q.Z) * (p.Z - q.Z));
+ // ── Replay: one click walk from the game log ──────────────────────
+ if (replayFrom is { } from && replayTo is { } to)
+ {
+ var hero = new NavFollower(mesh, from, speed)
+ { Traversal = traversal, PartialPathFallback = true, DiagnosticLogging = true };
+ hero.SetTarget(to);
+ Console.WriteLine($"Replay : {FormatVec(hero.Position)} tri={hero.CurrentTriangle} -> {FormatVec(to)}" +
+ $" path={hero.RemainingPath.Count}tri waypoints={hero.Waypoints.Count} blocked={hero.PathBlocked}");
+ for (int w = 0; w < hero.Waypoints.Count; w++)
+ Console.WriteLine($" wp[{w,2}] {FormatVec(hero.Waypoints[w])}");
+ string outcome = hero.PathBlocked ? "BLOCKED" : "PINNED";
+ float budget = DistXZ(from, to) / MathF.Max(1f, speed) * 4f + 10f, walked = 0f;
+ int lastTri = hero.CurrentTriangle;
+ for (float t = 0f; t < budget && !hero.PathBlocked; t += dt)
+ {
+ var before = hero.Position;
+ hero.Tick(dt);
+ walked += DistXZ(before, hero.Position);
+ if (hero.CurrentTriangle != lastTri)
+ {
+ Console.WriteLine($" t={t,5:F2} {FormatVec(hero.Position)} tri={hero.CurrentTriangle}");
+ lastTri = hero.CurrentTriangle;
+ }
+ if (hero.ReachedGoal) { outcome = DistXZ(hero.Position, to) <= 1.5f ? "OK" : "PARTIAL"; break; }
+ if (hero.PathBlocked) outcome = "STUCK";
+ }
+ Console.WriteLine($"Outcome : {outcome} at {FormatVec(hero.Position)} tri={hero.CurrentTriangle}, walked {walked:F1}u");
+ return outcome == "OK" ? 0 : 3;
+ }
+
// ── Pass 1: clicks ────────────────────────────────────────────────
var clickOutcomes = new Dictionary();
var clickFails = new List();
diff --git a/tests/SiegeFX.Tests/PropFootprintTests.cs b/tests/SiegeFX.Tests/PropFootprintTests.cs
new file mode 100644
index 00000000..99c5bdd0
--- /dev/null
+++ b/tests/SiegeFX.Tests/PropFootprintTests.cs
@@ -0,0 +1,54 @@
+using System.Numerics;
+using SiegeFX.Core.Nav;
+
+namespace SiegeFX.Tests;
+
+public class PropFootprintTests
+{
+ // An invented crate: 2 wide (X), 1 tall (Y), 3 deep (Z), authored Y-up.
+ static readonly Vector3 Min = new(-1f, 0f, -1.5f), Max = new(1f, 1f, 1.5f);
+
+ [Fact]
+ public void YUpBoxSpansXByZAndStandsOnItsBase()
+ {
+ var f = PropFootprint.FromBounds(Min, Max, Matrix4x4.CreateTranslation(10f, 2f, 20f));
+ Assert.Equal(2f, f.LenU, 3);
+ Assert.Equal(3f, f.LenV, 3);
+ Assert.Equal(2f, f.BaseY, 3);
+ Assert.Equal(3f, f.TopY, 3);
+ Assert.Equal(10f, f.Center.X, 3);
+ Assert.Equal(20f, f.Center.Y, 3);
+ }
+
+ [Fact]
+ public void ZUpBoxStoodUpByItsBindPoseUsesItsZAsHeight()
+ {
+ // Authored Z-up: 2 wide (X), 3 long (Y), 1 tall (Z). The root bind
+ // pose turns mesh Z to world up.
+ var zMin = new Vector3(-1f, -1.5f, 0f);
+ var zMax = new Vector3(1f, 1.5f, 1f);
+ var standUp = Matrix4x4.CreateRotationX(-MathF.PI / 2f);
+ var f = PropFootprint.FromBounds(zMin, zMax, standUp * Matrix4x4.CreateTranslation(5f, 0f, 5f));
+ Assert.Equal(6f, f.LenU * f.LenV, 3);
+ Assert.Equal(3f, MathF.Max(f.LenU, f.LenV), 3);
+ Assert.Equal(0f, f.BaseY, 3);
+ Assert.Equal(1f, f.TopY, 3);
+ Assert.Equal(5f, f.Center.X, 3);
+ Assert.Equal(5f, f.Center.Y, 3);
+ }
+
+ [Fact]
+ public void YawAndScaleMoveAndSizeTheRectangle()
+ {
+ var world = Matrix4x4.CreateScale(0.5f) * Matrix4x4.CreateRotationY(MathF.PI / 4f)
+ * Matrix4x4.CreateTranslation(-3f, 0f, 4f);
+ var f = PropFootprint.FromBounds(Min, Max, world);
+ Assert.Equal(1f, f.LenU, 3);
+ Assert.Equal(1.5f, f.LenV, 3);
+ Assert.Equal(0.5f, f.TopY, 3);
+ Assert.Equal(-3f, f.Center.X, 3);
+ Assert.Equal(4f, f.Center.Y, 3);
+ // The rectangle's sides stay perpendicular after the yaw.
+ Assert.Equal(0f, Vector3.Dot(f.AxisU, f.AxisV), 3);
+ }
+}