From 8257b9ba10aa7fd52e2c4a17bf0ce4c7a9094914 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 8 Jul 2026 15:06:33 +0200 Subject: [PATCH] =?UTF-8?q?fix(#186):=20render=20side-cull=20mis-sided=20t?= =?UTF-8?q?hin=20connectors=20=E2=80=94=20use=20dat=20PortalSide=20bit,=20?= =?UTF-8?q?not=20AABB=20centroid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The indoor GREY flap at a top-floor connecting room. The render portal side-cull reconstructed each doorway's "interior side" (PortalClipPlane.InsideSide) from the cell's AABB CENTROID. For a THIN connector cell (0xF6820118, 5 render polys), the bounding-box center falls on the WRONG side of the 0118->0116 doorway, so the eye read as a back-portal and the forward room 0116 was culled -> the aperture showed the fog clear color = grey. Retail's PView::InitCell (0x005a4b70) and acdream's own PHYSICS path (CellTransit.cs:190) both read the explicit dat PortalSide bit ((Flags&2)==0) instead of guessing from geometry. Port the render path (GameWindow.BuildLoadedCell) to the same bit. Proven by a live retail cdb trace (retail draws 0116 from the 0118 root at the grey pose; tools/cdb/issue186-connector-decider.cdb) + an offline dat diagnostic (Issue186...PortalSide_CentroidVsDatBit_AtGreyEye): the dat bit matches the old centroid on every portal of these cells EXCEPT the one #186 breaks, so the switch is surgical. Full regression green (App 741 / Core 2631); the CornerFlood + Issue113 dat-loading helpers updated to the same bit confirm every real Holtburg/tower/hall cell floods identically. Touches neither PortalSideEpsilon nor the deleted EyeInsidePortalOpening rescue (the two DO-NOT-RETRY traps). Live-gated: user-confirmed no grey at any camera angle; probe shows 216 root=0118 frames, 0 still grey (0118->0116 now TRV, vis=4). Co-Authored-By: Claude Opus 4.8 --- src/AcDream.App/Rendering/GameWindow.cs | 20 ++-- .../Rendering/CornerFloodReplayTests.cs | 8 +- .../Issue113MeetingHallFloodTests.cs | 8 +- ...186ConnectorCellGeometryInspectionTests.cs | 97 +++++++++++++++++++ tools/cdb/issue186-connector-decider.cdb | 37 +++++++ tools/cdb/run-issue186-decider.ps1 | 7 ++ 6 files changed, 164 insertions(+), 13 deletions(-) create mode 100644 tools/cdb/issue186-connector-decider.cdb create mode 100644 tools/cdb/run-issue186-decider.ps1 diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 4cd41649..cadccc24 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7390,9 +7390,6 @@ public sealed class GameWindow : IDisposable var clipPlanes = new List(); var portalPolygons = new List(); - // Compute cell centroid in local space for InsideSide determination. - var centroid = (boundsMin + boundsMax) * 0.5f; - foreach (var portal in envCell.CellPortals) { portals.Add(new CellPortalInfo( @@ -7426,10 +7423,19 @@ public sealed class GameWindow : IDisposable System.Numerics.Vector3.Cross(p1 - p0, p2 - p0)); float d = -System.Numerics.Vector3.Dot(normal, p0); - // Determine InsideSide: which side of the plane the cell centroid is on. - // If centroid dot > 0 → inside is positive half-space (InsideSide=0). - float centroidDot = System.Numerics.Vector3.Dot(normal, centroid) + d; - int insideSide = centroidDot >= 0 ? 0 : 1; + // InsideSide from the dat PortalSide bit — retail PView::InitCell + // (0x005a4b70) gates traversal on `iVar9 != portals->portal_side`, and + // acdream's own physics path uses the same bit (CellTransit.cs:190). + // The former AABB-centroid reconstruction mis-derived the interior side + // for THIN connector cells: the connector's bounding-box center falls on + // the wrong side of a portal near the cell edge, so the eye read as a + // back-portal and the forward room was culled — the #186 grey flap at + // 0xF6820118→0116 (retail draws 0116 from that root; cdb-confirmed). + // PortalSide (Flags&2)==0 → the portal polygon normal points INTO the + // owning cell → interior is the negative half → InsideSide=1. Diagnostic + // Issue186…PortalSide_CentroidVsDatBit_AtGreyEye: the dat bit matches the + // centroid on every portal of these cells EXCEPT the one #186 breaks. + int insideSide = ((ushort)portal.Flags & 0x2) == 0 ? 1 : 0; clipPlanes.Add(new PortalClipPlane { diff --git a/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs b/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs index c6b137fe..d5a0c004 100644 --- a/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs +++ b/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs @@ -88,7 +88,6 @@ public class CornerFloodReplayTests var portals = new List(); var clipPlanes = new List(); var portalPolygons = new List(); - var centroid = (boundsMin + boundsMax) * 0.5f; foreach (var portal in envCell.CellPortals) { @@ -106,10 +105,13 @@ public class CornerFloodReplayTests var p2 = new Vector3(v2.Origin.X, v2.Origin.Y, v2.Origin.Z); var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0)); float d = -Vector3.Dot(normal, p0); - float centroidDot = Vector3.Dot(normal, centroid) + d; + // InsideSide from the dat PortalSide bit — mirrors the production fix + // (GameWindow.BuildLoadedCell): retail InitCell (0x005a4b70) + physics + // CellTransit use the dat bit; the old AABB-centroid guess mis-sided thin + // connectors (#186). Kept identical here so this replay stays a faithful mirror. clipPlanes.Add(new PortalClipPlane { - Normal = normal, D = d, InsideSide = centroidDot >= 0 ? 0 : 1, + Normal = normal, D = d, InsideSide = ((ushort)portal.Flags & 0x2) == 0 ? 1 : 0, }); } else diff --git a/tests/AcDream.App.Tests/Rendering/Issue113MeetingHallFloodTests.cs b/tests/AcDream.App.Tests/Rendering/Issue113MeetingHallFloodTests.cs index 8b63aedc..205a3034 100644 --- a/tests/AcDream.App.Tests/Rendering/Issue113MeetingHallFloodTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Issue113MeetingHallFloodTests.cs @@ -77,7 +77,6 @@ public class Issue113MeetingHallFloodTests var portals = new List(); var clipPlanes = new List(); var portalPolygons = new List(); - var centroid = (boundsMin + boundsMax) * 0.5f; foreach (var portal in envCell.CellPortals) { @@ -95,10 +94,13 @@ public class Issue113MeetingHallFloodTests var p2 = new Vector3(v2.Origin.X, v2.Origin.Y, v2.Origin.Z); var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0)); float d = -Vector3.Dot(normal, p0); - float centroidDot = Vector3.Dot(normal, centroid) + d; + // InsideSide from the dat PortalSide bit — mirrors the production fix + // (GameWindow.BuildLoadedCell): retail InitCell (0x005a4b70) + physics + // CellTransit use the dat bit; the old AABB-centroid guess mis-sided thin + // connectors (#186). Kept identical here so this replay stays a faithful mirror. clipPlanes.Add(new PortalClipPlane { - Normal = normal, D = d, InsideSide = centroidDot >= 0 ? 0 : 1, + Normal = normal, D = d, InsideSide = ((ushort)portal.Flags & 0x2) == 0 ? 1 : 0, }); } else diff --git a/tests/AcDream.Core.Tests/Physics/Issue186ConnectorCellGeometryInspectionTests.cs b/tests/AcDream.Core.Tests/Physics/Issue186ConnectorCellGeometryInspectionTests.cs index 10b1b1e9..57e86e70 100644 --- a/tests/AcDream.Core.Tests/Physics/Issue186ConnectorCellGeometryInspectionTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Issue186ConnectorCellGeometryInspectionTests.cs @@ -78,6 +78,103 @@ public class Issue186ConnectorCellGeometryInspectionTests } } + /// + /// #186 root-cause probe (report-only): for every portal of the three connector + /// cells, reproduce acdream's RENDER side test (centroid-derived InsideSide, + /// GameWindow.cs:7425-7438 + PortalVisibilityBuilder.cs:857-864) and compare it to + /// the dat PortalSide bit that retail's PView::InitCell (0x005a4b70) and + /// acdream's own PHYSICS path (CellTransit.cs:190) use. Evaluates each at the live + /// retail/acdream grey-pose eye. The retail cdb trace proved retail ADMITS 0118->0116 + /// at this eye; acdream CULLs it. This dump shows whether the centroid InsideSide + /// disagrees with the dat PortalSide for 0118->0116 (the fix) and AGREES elsewhere + /// (so the fix is surgical, not a global side-test change). + /// + [Fact] + public void PortalSide_CentroidVsDatBit_AtGreyEye() + { + var datDir = DatDir(); + if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + // Live grey-pose eye (fresh HEAD capture + retail cdb trace both ~here). + var eyeWorld = new Vector3(135.51f, 83.32f, 69.16f); + + var disagreements = new List(); // portals where centroid != dat bit + bool sawConnectorBackPortal = false; // the 0118->0116 case + bool connectorCentroidCulls = false, connectorDatAdmits = false; + + foreach (uint cellId in new[] { 0xF6820116u, 0xF6820117u, 0xF6820118u }) + { + var env = dats.Get(cellId); + if (env is null) { _out.WriteLine($"cell 0x{cellId:X8}: EnvCell NOT FOUND"); continue; } + var environment = dats.Get(0x0D000000u | env.EnvironmentId); + if (environment is null || !environment.Cells.TryGetValue(env.CellStructure, out var cs) || cs is null) + { _out.WriteLine($"cell 0x{cellId:X8}: no CellStruct"); continue; } + + var origin = new Vector3(env.Position.Origin.X, env.Position.Origin.Y, env.Position.Origin.Z); + var orient = new Quaternion(env.Position.Orientation.X, env.Position.Orientation.Y, + env.Position.Orientation.Z, env.Position.Orientation.W); + var world = Matrix4x4.CreateFromQuaternion(orient) * Matrix4x4.CreateTranslation(origin); + Matrix4x4.Invert(world, out var inverse); + var localEye = Vector3.Transform(eyeWorld, inverse); + + // Centroid = AABB center over ALL cell verts (GameWindow.cs:7373-7394). + var bmin = new Vector3(float.MaxValue); var bmax = new Vector3(float.MinValue); + foreach (var kv in cs.VertexArray!.Vertices) + { var p = new Vector3(kv.Value.Origin.X, kv.Value.Origin.Y, kv.Value.Origin.Z); + bmin = Vector3.Min(bmin, p); bmax = Vector3.Max(bmax, p); } + var centroid = (bmin + bmax) * 0.5f; + + _out.WriteLine($"=== cell 0x{cellId:X8} localEye=({localEye.X:F2},{localEye.Y:F2},{localEye.Z:F2}) ==="); + foreach (var portal in env.CellPortals!) + { + if (!cs.Polygons!.TryGetValue(portal.PolygonId, out var poly) || poly.VertexIds is null || poly.VertexIds.Count < 3) + { _out.WriteLine($" p->0x{portal.OtherCellId:X4}: no poly"); continue; } + Vector3 V(int k) { var v = cs.VertexArray.Vertices[(ushort)poly.VertexIds[k]]; return new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z); } + var p0 = V(0); var p1 = V(1); var p2 = V(2); + var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0)); + float d = -Vector3.Dot(normal, p0); + + float centroidDot = Vector3.Dot(normal, centroid) + d; + int centroidInside = centroidDot >= 0 ? 0 : 1; + + ushort flags = (ushort)portal.Flags; + bool portalSide = (flags & 0x2) == 0; // PortalInfo.cs:44 / CellPortal.cs:24 + int datInside = portalSide ? 1 : 0; // mapping derived from retail InitCell + physics CellTransit + + float eyeDot = Vector3.Dot(normal, localEye) + d; + const float eps = 0.01f; + bool admitCentroid = centroidInside == 0 ? eyeDot >= -eps : eyeDot <= eps; + bool admitDat = datInside == 0 ? eyeDot >= -eps : eyeDot <= eps; + + if (centroidInside != datInside) + disagreements.Add($"0x{cellId & 0xFFFF:X4}->0x{portal.OtherCellId:X4}"); + if (cellId == 0xF6820118u && portal.OtherCellId == 0x0116) + { + sawConnectorBackPortal = true; + connectorCentroidCulls = !admitCentroid; + connectorDatAdmits = admitDat; + } + + string flag = centroidInside != datInside ? " <<< DISAGREE" : ""; + string verdict = admitCentroid != admitDat ? $" centroid={(admitCentroid ? "ADMIT" : "CULL")} dat={(admitDat ? "ADMIT" : "CULL")}" : ""; + _out.WriteLine($" p->0x{portal.OtherCellId:X4} flags=0x{flags:X2} PortalSide={portalSide,-5} " + + $"centroidInside={centroidInside} datInside={datInside} eyeDot={eyeDot,7:F3} " + + $"admit[centroid={admitCentroid,-5} dat={admitDat,-5}]{flag}{verdict}"); + } + } + + // Root cause + fix pins (the retail cdb trace is the oracle: retail draws 0116 + // from the 0118 root at this eye; acdream's centroid rule culled it → grey). + Assert.True(sawConnectorBackPortal, "0xF6820118->0116 portal not found in the dat"); + Assert.True(connectorCentroidCulls, "the OLD centroid rule should CULL 0118->0116 at the grey eye (the bug)"); + Assert.True(connectorDatAdmits, "the dat PortalSide bit should ADMIT 0118->0116 at the grey eye (the fix, matches retail)"); + // Surgical: the ONLY portal across these three cells where the centroid rule + // disagrees with the dat bit is the one #186 breaks. If this list ever grows, + // the centroid→dat-bit switch is no longer a no-op for the working portals. + Assert.Equal(new[] { "0x0118->0x0116" }, disagreements.ToArray()); + } + private static Vector3? PolyNormal(DatReaderWriter.Types.Polygon poly, DatReaderWriter.Types.VertexArray va) { if (poly.VertexIds is null || poly.VertexIds.Count < 3) return null; diff --git a/tools/cdb/issue186-connector-decider.cdb b/tools/cdb/issue186-connector-decider.cdb new file mode 100644 index 00000000..4cb50a07 --- /dev/null +++ b/tools/cdb/issue186-connector-decider.cdb @@ -0,0 +1,37 @@ +* #186 connector GREY flap - PICK-vs-FLOOD decider (2026-07-08) +* Attach to a LIVE retail acclient.exe HELD in the connecting-room grey pose. +* Binary MUST pair with refs/acclient.pdb (check_exe_pdb.py -> MATCH). +* +* Two breakpoints, both sampled at a low rate to avoid lagging retail -> ACE timeout: +* bp1 SmartBox::update_viewer (0x453ce0) -> viewer.objcell_id = the render ROOT (viewer_cell) +* player m_position = confirms we are at the pose (expect 0116) +* bp2 PView::DrawCells (0x5a4840) -> cell_draw_num + cell_draw_list ids = the DRAWN cell set +* + eye viewpoint +* +* DECISION (handoff 2026-07-08-186 section 3): +* retail viewerCell == 0116 -> PICK fix (acdream over-switches root to connector 0118) +* retail viewerCell == 0118 AND draw list has 0116 -> FLOOD fix (retail keeps the back-portal room drawn) +* +* update_viewer sets viewer_cell at EXIT, so at bp ENTRY viewer.objcell_id is the PRIOR frame value. +* For a HELD (stationary) pose prior==current, so this is exact - HOLD the grey pose. +* +* Auto-detach via .detach after a hit threshold (qd does NOT fire inside a conditional bp action - it +* strands cdb and kills retail). NEVER put a semicolon inside a comment line (it splits into a command). + +.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\vigorous-joliot-f0c3ad\issue186-connector-decider.log +.sympath C:\Users\erikn\source\repos\acdream\refs +.symopt+ 0x40 +.reload /f acclient.exe + +.echo ===SYMBOLS=== +x acclient!SmartBox::update_viewer +x acclient!PView::DrawCells + +r $t0 = 0 +r $t1 = 0 + +bp acclient!SmartBox::update_viewer "r $t0 = @$t0 + 1; .if (@$t0 % 15 == 1) { .printf \"[root] hit=%d viewerCell=%04x playerCell=%04x\\n\", @$t0, @@c++(((acclient!SmartBox*)@ecx)->viewer.objcell_id) & 0xffff, @@c++(((acclient!SmartBox*)@ecx)->player->m_position.objcell_id) & 0xffff }; gc" + +bp acclient!PView::DrawCells "r $t1 = @$t1 + 1; .if (@$t1 % 15 == 1) { r $t2 = @@c++(((acclient!PView*)@ecx)->cell_draw_num); .printf \"[draw] hit=%d num=%d:\", @$t1, @$t2; .for (r $t3 = 0; @$t3 < @$t2; r $t3 = @$t3 + 1) { .printf \" %04x\", @@c++(((acclient!PView*)@ecx)->cell_draw_list.data[@$t3]->m_DID.id) & 0xffff }; .printf \" || eye \"; dx acclient!Render::FrameCurrent->viewer.viewpoint }; .if (@$t1 >= 3000) { .detach } .else { gc }" + +g diff --git a/tools/cdb/run-issue186-decider.ps1 b/tools/cdb/run-issue186-decider.ps1 new file mode 100644 index 00000000..6b77bd5e --- /dev/null +++ b/tools/cdb/run-issue186-decider.ps1 @@ -0,0 +1,7 @@ +# Attach cdb to a live retail acclient.exe and run the #186 connector decider script. +# Prereq: retail is already in-world (user walked to the connecting room, holding the grey pose). +# Verify pairing first: py tools/pdb-extract/check_exe_pdb.py "C:/Turbine/Asheron's Call/acclient.exe" +$cdb = "C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe" +$script = "C:\Users\erikn\source\repos\acdream\.claude\worktrees\vigorous-joliot-f0c3ad\tools\cdb\issue186-connector-decider.cdb" +$tee = "C:\Users\erikn\source\repos\acdream\.claude\worktrees\vigorous-joliot-f0c3ad\issue186-connector-decider.console.log" +& $cdb -pn acclient.exe -cf $script *>&1 | Tee-Object -FilePath $tee