From c802449072b397e948758eb9a763678a71c438bf Mon Sep 17 00:00:00 2001 From: dafioram <5838258+dafioram@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:58:49 +0000 Subject: [PATCH 1/9] hero-sim --from/--to: replay one click walk from the game log Replays a single click walk with the coordinates the game log prints, with the follower's diagnostics on, so a stuck walk from a playtest can be stepped through offline. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FdjaHiBNBYFeCRNMsCg2Bs --- src/SiegeFX.Tools/Program.cs | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/SiegeFX.Tools/Program.cs b/src/SiegeFX.Tools/Program.cs index c1b9c2af..00843ece 100644 --- a/src/SiegeFX.Tools/Program.cs +++ b/src/SiegeFX.Tools/Program.cs @@ -3696,8 +3696,9 @@ 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]"); 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"); return 1; } int clicks = 150, seed = 1; @@ -3705,11 +3706,14 @@ static int CmdRegionHeroSim(string[] a) bool diag = false; Vector3 nearCenter = default; float nearRadius = 0f; + Vector3? replayFrom = null, replayTo = null; 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("--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; @@ -3780,6 +3784,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(); From a171e6bf0fa7fedb74ab4af15969a5fc826192d8 Mon Sep 17 00:00:00 2001 From: dafioram <5838258+dafioram@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:42:01 +0000 Subject: [PATCH 2/9] hero-sim: name each blocking prop; --broken leaves out smashed ones The no-corridor diagnosis listed blockers without saying which prop they were. Each obstacle disc is now tagged with its template and scid. --broken=,... leaves out props the player broke in the session being replayed, which block nothing in-game. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FdjaHiBNBYFeCRNMsCg2Bs --- src/SiegeFX.Tools/Program.cs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/SiegeFX.Tools/Program.cs b/src/SiegeFX.Tools/Program.cs index 00843ece..3cfd555e 100644 --- a/src/SiegeFX.Tools/Program.cs +++ b/src/SiegeFX.Tools/Program.cs @@ -3068,7 +3068,7 @@ static int CmdWorldPath(string[] a) /// siblings of the map tank; missing tanks = unmarked mesh + a loud note. /// MIRROR of RenderHost.MarkAllObstacles — keep the two in step. static int MarkRoamSimObstacles(SiegeFX.Core.Nav.NavMesh mesh, string mapTankPath, - string regionsArg, Dictionary nodeWorld) + string regionsArg, Dictionary nodeWorld, ICollection? broken = null) { string? root = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetFullPath(mapTankPath))); string logicPath = root is null ? "" : Path.Combine(root, "Resources", "Logic.dsres"); @@ -3104,6 +3104,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)) @@ -3182,7 +3187,7 @@ static int MarkRoamSimObstacles(SiegeFX.Core.Nav.NavMesh mesh, string mapTankPat { float along = -halfLong + longLen * (di / (float)(n - 1)); var c = origin + axis * along; - marked += mesh.MarkObstacle(c.X, c.Z, discR, propBaseY, propTopY); + marked += mesh.MarkObstacle(c.X, c.Z, discR, propBaseY, propTopY, tag); } } else if (shortLen > 2.4f) @@ -3199,12 +3204,12 @@ static int MarkRoamSimObstacles(SiegeFX.Core.Nav.NavMesh mesh, string mapTankPat { var c = wcA + axU * (sideAB * iu / (nu - 1)) + axV * (sideAD * iv / (nv - 1)); - marked += mesh.MarkObstacle(c.X, c.Z, gridR, propBaseY, propTopY); + marked += mesh.MarkObstacle(c.X, c.Z, gridR, propBaseY, propTopY, tag); } } else { - marked = mesh.MarkObstacle(origin.X, origin.Z, radius, propBaseY, propTopY); + marked = mesh.MarkObstacle(origin.X, origin.Z, radius, propBaseY, propTopY, tag); } if (marked > 0) { props++; discs += marked; } } @@ -3696,9 +3701,10 @@ 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] [--from=x,y,z --to=x,y,z]"); + 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=,...]"); 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)"); return 1; } int clicks = 150, seed = 1; @@ -3707,6 +3713,7 @@ static int CmdRegionHeroSim(string[] a) Vector3 nearCenter = default; float nearRadius = 0f; Vector3? replayFrom = null, replayTo = 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++) @@ -3714,6 +3721,9 @@ static bool TryF(string s, out float v) => 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("--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; @@ -3731,7 +3741,7 @@ 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); + MarkRoamSimObstacles(mesh, a[0], a[2], nodeWorld, broken); var traversal = NavTraversal.Player; var rng = new Random(seed); From 8afcc49149b06af00a36e14f5f05f5df7b2bddac Mon Sep 17 00:00:00 2001 From: dafioram <5838258+dafioram@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:51:14 +0000 Subject: [PATCH 3/9] Nav: mid-size props block their own footprint, not a disc A does_block_path prop that is neither long and thin nor big blocked a disc reaching its bounding box's corners. Beside a rectangle's sides that overshoots: a farmhouse bed's 1.66u disc covered the top stair triangles beside it and sealed the upper floor (hero-sim replay of the field log: sealed by bed_glb_04). These props now block their oriented footprint rectangle plus 0.1u, with the disc's rules otherwise (the vertical band, centroid containment, edge crossings only for small triangles). The game and hero-sim/roam-sim's mirror both change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FdjaHiBNBYFeCRNMsCg2Bs --- src/SiegeFX.Core/Nav/NavMesh.cs | 75 ++++++++++++++++++++++++ src/SiegeFX.Runtime/Render/RenderHost.cs | 9 ++- src/SiegeFX.Tools/Program.cs | 6 +- 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/SiegeFX.Core/Nav/NavMesh.cs b/src/SiegeFX.Core/Nav/NavMesh.cs index 36311996..a0b2d505 100644 --- a/src/SiegeFX.Core/Nav/NavMesh.cs +++ b/src/SiegeFX.Core/Nav/NavMesh.cs @@ -288,6 +288,81 @@ 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. + 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 = topY + ObstacleAboveTopTol; + // 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? + bool SegmentHits((float U, float V) p, (float U, float V) q) + { + 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; + } + return Clip(-du, p.U - u0) && Clip(du, u1 - p.U) && Clip(-dv, p.V - v0) && Clip(dv, v1 - p.V); + } + string size = $"{lenU:F2}x{lenV:F2}"; + int marked = 0; + for (int t = 0; t < TriangleCount; t++) + { + 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); + if (!SegmentHits(la, lb) && !SegmentHits(lb, lc) && !SegmentHits(lc, la)) 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}"; + 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; + /// 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.Runtime/Render/RenderHost.cs b/src/SiegeFX.Runtime/Render/RenderHost.cs index 619e15d7..714e0b41 100644 --- a/src/SiegeFX.Runtime/Render/RenderHost.cs +++ b/src/SiegeFX.Runtime/Render/RenderHost.cs @@ -21042,7 +21042,14 @@ private void MarkAllObstacles() } else { - marked = _navMesh.MarkObstacle(origin.X, origin.Z, radius, propBaseY, propTopY, blameTag); + // SC-NAV-OBSTACLE-RECT — the prop's own footprint, not the + // disc around it (the disc overshoots a rectangle's sides + // and sealed a stair top beside a bed). + 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)); + marked = sideAB > 0.05f && sideAD > 0.05f + ? _navMesh.MarkObstacleRect(wcA, axU, sideAB, axV, sideAD, propBaseY, propTopY, blameTag) + : _navMesh.MarkObstacle(origin.X, origin.Z, radius, propBaseY, propTopY, blameTag); if (occludesSight) _sightOccluders.Add((new Vector2(origin.X, origin.Z), radius, propTopY)); } diff --git a/src/SiegeFX.Tools/Program.cs b/src/SiegeFX.Tools/Program.cs index 3cfd555e..f1fbc1c5 100644 --- a/src/SiegeFX.Tools/Program.cs +++ b/src/SiegeFX.Tools/Program.cs @@ -3209,7 +3209,11 @@ static int MarkRoamSimObstacles(SiegeFX.Core.Nav.NavMesh mesh, string mapTankPat } else { - marked = mesh.MarkObstacle(origin.X, origin.Z, radius, propBaseY, propTopY, tag); + 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)); + marked = sideAB > 0.05f && sideAD > 0.05f + ? mesh.MarkObstacleRect(wcA, axU, sideAB, axV, sideAD, propBaseY, propTopY, tag) + : mesh.MarkObstacle(origin.X, origin.Z, radius, propBaseY, propTopY, tag); } if (marked > 0) { props++; discs += marked; } } From 2ced64ab595c76e9204bc56eb489115afa88212f Mon Sep 17 00:00:00 2001 From: dafioram <5838258+dafioram@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:09:07 +0000 Subject: [PATCH 4/9] Nav: a rectangle obstacle's edge test uses the exact footprint The 0.1u margin is for centroid containment (bodies on a tile). Applied to the edge-crossing test it let two slivers grazing the bed's side at the farmhouse stair top seal the stair (hero-sim replay: bed_glb_04 rect edge a=0.25 / a=0.00). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FdjaHiBNBYFeCRNMsCg2Bs --- src/SiegeFX.Core/Nav/NavMesh.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/SiegeFX.Core/Nav/NavMesh.cs b/src/SiegeFX.Core/Nav/NavMesh.cs index a0b2d505..67de2c36 100644 --- a/src/SiegeFX.Core/Nav/NavMesh.cs +++ b/src/SiegeFX.Core/Nav/NavMesh.cs @@ -297,7 +297,9 @@ public int MarkObstacle(float worldX, float worldZ, float radius, float baseY, f /// 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. + /// beside it and sealed a farmhouse's upper floor. The margin applies + /// to centroid containment only; edge crossings test the exact + /// footprint. public int MarkObstacleRect(Vector3 corner, Vector3 axisU, float lenU, Vector3 axisV, float lenV, float baseY, float topY, string? tag) { @@ -316,8 +318,13 @@ public int MarkObstacleRect(Vector3 corner, Vector3 axisU, float lenU, Vector3 a } 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: the margin is for bodies + // standing on a tile, and a sliver merely grazing it along a stair + // top beside the bed sealed the stair. bool SegmentHits((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) { From 3d91e7eb383c1c78504ae4fda0537b12e8c75933 Mon Sep 17 00:00:00 2001 From: dafioram <5838258+dafioram@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:25:32 +0000 Subject: [PATCH 5/9] Nav: a rectangle obstacle seals a sliver only on a real crossing The farmhouse stair's only links to the ground floor are three slivers at its foot. A cart's (in-game) or the bed's (hero-sim) footprint grazed their edges, and the edge test sealed them, cutting the stair off from both floors (field log: every click from the stair rejected, sealed by cart_grs_03 rect edge a=0.25/0.22/0.00). An edge crossing now seals a small triangle only when at least 0.4u of the edge lies inside the footprint; the blame tag records that depth. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FdjaHiBNBYFeCRNMsCg2Bs --- src/SiegeFX.Core/Nav/NavMesh.cs | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/SiegeFX.Core/Nav/NavMesh.cs b/src/SiegeFX.Core/Nav/NavMesh.cs index 67de2c36..bff6f527 100644 --- a/src/SiegeFX.Core/Nav/NavMesh.cs +++ b/src/SiegeFX.Core/Nav/NavMesh.cs @@ -298,8 +298,9 @@ public int MarkObstacle(float worldX, float worldZ, float radius, float baseY, f /// 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; edge crossings test the exact - /// footprint. + /// 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) { @@ -318,10 +319,12 @@ public int MarkObstacleRect(Vector3 corner, Vector3 axisU, float lenU, Vector3 a } 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: the margin is for bodies - // standing on a tile, and a sliver merely grazing it along a stair - // top beside the bed sealed the stair. - bool SegmentHits((float U, float V) p, (float U, float V) q) + // 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; @@ -334,7 +337,9 @@ bool Clip(float den, float num) else { if (r < t0) return false; if (r < t1) t1 = r; } return true; } - return Clip(-du, p.U - u0) && Clip(du, u1 - p.U) && Clip(-dv, p.V - v0) && Clip(dv, v1 - p.V); + 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; @@ -354,13 +359,14 @@ bool Clip(float den, float num) 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); - if (!SegmentHits(la, lb) && !SegmentHits(lb, lc) && !SegmentHits(lc, la)) continue; + float depth = MathF.Max(InsideLength(la, lb), MathF.Max(InsideLength(lb, lc), InsideLength(lc, la))); + if (depth < ObstacleRectEdgeDepth) 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}"; + BlockedTag[t] = $"{tag}|rect {size} edge a={areaXZ:F2} in={depth:F2}"; marked++; } return marked; @@ -370,6 +376,10 @@ bool Clip(float den, float num) /// (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; + /// 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) From 0bc42cc4ecfc4747bec6427d6f57f8586e18fb7b Mon Sep 17 00:00:00 2001 From: dafioram <5838258+dafioram@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:46:28 +0000 Subject: [PATCH 6/9] Nav: rectangle obstacles block the ground they stand on The farmhouse stair was still sealed (field log: every click from its foot refused, sealed by cart_grs_03 rect edge a=0.25 in=0.45, tri 14162), just past the 0.4u edge-depth rule. Two rules for mid-size (rectangle) props: - An edge crossing seals a small triangle only when the triangle's center is within 0.3u of the footprint: a sliver that merely clips a corner stays walkable. - A rectangle prop blocks surfaces up to 0.9u above its base (and no higher than its top + 0.5), the ground it stands on; raised floors and stairs beside it, across a wall, are no longer blocked. Disc obstacles (long fences, big wagons) keep their band: one center base height would leave a hillside fence's uphill end unblocked. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FdjaHiBNBYFeCRNMsCg2Bs --- src/SiegeFX.Core/Nav/NavMesh.cs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/SiegeFX.Core/Nav/NavMesh.cs b/src/SiegeFX.Core/Nav/NavMesh.cs index bff6f527..800d574a 100644 --- a/src/SiegeFX.Core/Nav/NavMesh.cs +++ b/src/SiegeFX.Core/Nav/NavMesh.cs @@ -206,6 +206,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 @@ -310,7 +325,7 @@ public int MarkObstacleRect(Vector3 corner, Vector3 axisU, float lenU, Vector3 a float m = ObstacleRectMargin; float u0 = -m, u1 = lenU + m, v0 = -m, v1 = lenV + m; float loY = baseY - ObstacleBelowFloorTol; - float hiY = topY + ObstacleAboveTopTol; + float hiY = ObstacleBandTop(baseY, topY); // Local footprint coordinates of a world point. (float U, float V) Local(Vector3 p) { @@ -361,6 +376,13 @@ bool Clip(float den, float num) 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; @@ -380,6 +402,10 @@ bool Clip(float den, float num) /// 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) From ddf943ca77485449d8dfc61f1ef76712ec901557 Mon Sep 17 00:00:00 2001 From: dafioram <5838258+dafioram@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:52:57 +0000 Subject: [PATCH 7/9] hero-sim --dump: the nav mesh around a point as text Writes every nav triangle within --near (source snode, vertices, neighbours across each edge, extra links, and the blame tag of whatever blocks it) and each blocking prop's footprint and Y band. Enough to redraw a sealed stair or doorway and check it against the walls without the game data. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FdjaHiBNBYFeCRNMsCg2Bs --- src/SiegeFX.Tools/Program.cs | 63 ++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/src/SiegeFX.Tools/Program.cs b/src/SiegeFX.Tools/Program.cs index f1fbc1c5..5fe1579b 100644 --- a/src/SiegeFX.Tools/Program.cs +++ b/src/SiegeFX.Tools/Program.cs @@ -3068,7 +3068,8 @@ static int CmdWorldPath(string[] a) /// siblings of the map tank; missing tanks = unmarked mesh + a loud note. /// MIRROR of RenderHost.MarkAllObstacles — keep the two in step. static int MarkRoamSimObstacles(SiegeFX.Core.Nav.NavMesh mesh, string mapTankPath, - string regionsArg, Dictionary nodeWorld, ICollection? broken = null) + string regionsArg, Dictionary nodeWorld, ICollection? broken = null, + List<(string Tag, Vector2[] Quad, float BaseY, float TopY)>? footprints = null) { string? root = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetFullPath(mapTankPath))); string logicPath = root is null ? "" : Path.Combine(root, "Resources", "Logic.dsres"); @@ -3174,6 +3175,13 @@ static int MarkRoamSimObstacles(SiegeFX.Core.Nav.NavMesh mesh, string mapTankPat 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)); + if (footprints is not null) + { + var wcC = Vector3.Transform(corners[2], world); + footprints.Add((tag, new[] { new Vector2(wcA.X, wcA.Z), new Vector2(wcB.X, wcB.Z), + new Vector2(wcC.X, wcC.Z), new Vector2(wcD.X, wcD.Z) }, + propBaseY, propTopY)); + } int marked = 0; if (longLen > shortLen * 2.2f && longLen > 2f) { @@ -3684,6 +3692,46 @@ 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, Vector2[] Quad, float BaseY, float TopY)> 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