diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 22d6d523..854196d8 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -1183,15 +1183,32 @@ walls** in particular. (Symptoms not fully characterized yet: likely walking thr openings that should block / blocking at openings that should pass, and door collision not matching the door's open/closed state.) -**Root cause / status (to investigate):** dungeon collision is EnvCell-based — the cell's -collision BSP + portal openings + per-cell static objects (doors). Candidates: door -apparatus collision in EnvCells (open/closed BSP swap) not fully ported; portal-opening -(wall gap) collision geometry handled differently from buildings; the per-cell -shadow-object registration (A6.P4, see the physics digest) for dungeon EnvCell statics. -Related families: #32 (edge-slide), #116 (slide-response), the door-collision saga -(see `feedback_dedup_keys_after_cardinality_change`, `feedback_retail_per_cell_shadow_list`). -Needs a targeted repro (which door / which opening, expected vs actual) before fixing — -oracle-first per the physics digest. +**CHARACTERIZED 2026-07-05 (Facility Hub corridor repro, probe + dat evidence) +— two stacked mechanisms:** +1. **PortalSide portal polygons are IN the physics polygon set and we treat + them as solid.** Live: running the corridor, the seam crossing + `0x8A02016E → 0x8A02017A` (x≈85.25) records a wall hit with normal + (−1,0,0) — straight against the movement (`launch-175-verify2.log:42858`). + Dat (`Issue137CorridorSeamInspectionTests`): cell 0x8A02016E's portals to + 0x011E (polys 1/3/5, flags=**PortalSide**, no ExactMatch) are PRESENT in + `CellStruct.PhysicsPolygons` — every ExactMatch portal in the same cell is + absent from the physics set. The cell's rotation maps those local ±Y portal + planes to world ±X — the phantom mid-corridor wall. Retail must honor the + portal's SIDE (pass from one side / solid from the other, or pass when the + neighbor is loaded); we collide with the raw polygon unconditionally. + **Oracle next (MANDATORY before fixing):** grep named-retail for how + BSPTREE::find_collisions / CEnvCell treats portal-flagged physics polys + (portal_side semantics; the prep note "portal passability comes from BSP + structure" was true for ExactMatch portals only). +2. **The stale sliding normal then wedges all forward motion** (the #116 + slide-response family): after the single seam hit, EVERY subsequent + forward resolve returns `ok=False hit=no` with zero advance — the + body-persisted SlidingNormal (−1,0,0) projects the +X offset to exactly + zero in AdjustOffset, aborting at step 0 BEFORE any collision test could + update the state — an ABSORBING wedge escaped only by strafing ("push + through on the side"). Retail re-derives slide state per frame + (get_object_info pc:279992 governs only the NEXT frame — #116 notes); + audit who clears the body's sliding normal when no contact recurs. **Files:** `src/AcDream.Core/Physics/` (EnvCell collision, CellTransit, the door apparatus), `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (per-cell registration). See diff --git a/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamInspectionTests.cs b/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamInspectionTests.cs new file mode 100644 index 00000000..2ea033cd --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamInspectionTests.cs @@ -0,0 +1,90 @@ +using System; +using System.IO; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; +using Env = System.Environment; + +namespace AcDream.Core.Tests.Physics; + +/// +/// #137 corridor-seam inspection (2026-07-05, Facility Hub). Live probe +/// evidence (launch-175-verify2.log:42858): crossing corridor cells +/// 0x8A02016E → 0x8A02017A at world x≈85.25 records a wall hit with normal +/// (−1,0,0) — pointing straight back against the movement — after which the +/// stale sliding normal wedges all forward motion (ok=False hit=no, offset +/// projected to zero). Question this dump answers: does cell 0x8A02017A's +/// PHYSICS polygon set contain a portal-spanning polygon at its entry plane +/// (normal ≈ ±X at the portal's local X) — i.e., are portal-sealing polys in +/// our collision set where retail filters them? +/// +public class Issue137CorridorSeamInspectionTests +{ + private readonly ITestOutputHelper _out; + public Issue137CorridorSeamInspectionTests(ITestOutputHelper output) => _out = output; + + [Theory] + [InlineData(0x8A02016Eu)] + [InlineData(0x8A02017Au)] + public void CorridorCell_PhysicsPolysAndPortals_DatInspection(uint envCellId) + { + var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + if (!Directory.Exists(datDir)) + { + _out.WriteLine($"SKIP: dat directory not found at {datDir}"); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + + var envCell = dats.Get(envCellId); + Assert.NotNull(envCell); + _out.WriteLine($"=== EnvCell 0x{envCellId:X8} ==="); + _out.WriteLine($" pos=({envCell!.Position.Origin.X:F2},{envCell.Position.Origin.Y:F2},{envCell.Position.Origin.Z:F2}) " + + $"rot=({envCell.Position.Orientation.X:F3},{envCell.Position.Orientation.Y:F3},{envCell.Position.Orientation.Z:F3},{envCell.Position.Orientation.W:F3})"); + _out.WriteLine($" EnvironmentId=0x{envCell.EnvironmentId:X4} CellStructure={envCell.CellStructure}"); + _out.WriteLine($" CellPortals={envCell.CellPortals.Count}"); + foreach (var p in envCell.CellPortals) + _out.WriteLine($" portal poly={p.PolygonId} other=0x{p.OtherCellId:X4} flags={p.Flags}"); + + var environment = dats.Get(0x0D000000u | envCell.EnvironmentId); + Assert.NotNull(environment); + Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs)); + + _out.WriteLine($" PhysicsPolygons={cs!.PhysicsPolygons.Count} (portal-relevant normals below)"); + foreach (var (id, poly) in cs.PhysicsPolygons) + { + // Compute the face normal from the vertex fan (same math as + // PhysicsDataCache.ResolvePolygons). + var verts = poly.VertexIds; + if (verts.Count < 3) continue; + if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[0], out var v0)) continue; + if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[1], out var v1)) continue; + if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[2], out var v2)) continue; + var n = System.Numerics.Vector3.Normalize(System.Numerics.Vector3.Cross( + v1.Origin - v0.Origin, v2.Origin - v0.Origin)); + + // Only print near-horizontal-normal polys (walls) — the seam wall + // candidates; floors/ceilings are noise here. + if (MathF.Abs(n.Z) > 0.3f) continue; + _out.WriteLine($" poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) v0=({v0.Origin.X:F2},{v0.Origin.Y:F2},{v0.Origin.Z:F2}) verts={verts.Count} sides={poly.SidesType} stip={poly.Stippling}"); + } + + // The portal polygons live in the VISUAL polygon set — print their + // ids so overlap with the physics set (same id space?) is visible. + _out.WriteLine($" VisualPolygons={cs.Polygons.Count}"); + foreach (var p in envCell.CellPortals) + { + if (cs.Polygons.TryGetValue((ushort)p.PolygonId, out var vp)) + { + _out.WriteLine($" portal-poly {p.PolygonId} IS in the visual set (verts={vp.VertexIds.Count})"); + bool inPhysics = cs.PhysicsPolygons.ContainsKey((ushort)p.PolygonId); + _out.WriteLine($" portal-poly {p.PolygonId} in PHYSICS set: {inPhysics}"); + } + } + } +}