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