From 2d6954ee44d23ca10abdcfcec856ea55c7344830 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 10 Jun 2026 15:03:49 +0200 Subject: [PATCH] fix(phys): #112 - remove the non-retail escape-hatch demote from the pick; lateral stab-graph recovery + retail keep-curr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (oracle: CLandCell::point_in_cell :316941 = terrain-poly only; find_cell_list null-result keep-curr pc:308788-308825; CEnvCell:: check_building_transit :309827 = sphere_intersects_cell per portal-adjacent cell): retail KEEPS curr_cell when nothing contains the centre — including inside a house's containment gaps. Our 6dbbf95 escape hatch instead demoted any hydrated indoor claim the sphere no longer overlaps to the outdoor column; at the A9B3 hill cottage's real interior gap this stranded the player outdoor-classified deep indoors, where re-promotion is portal- adjacent-only (retail-identical) -> the outdoor flood rendered the interior transparent (the user's "sometimes transparent" walk). The hatch's actual target - poisoned (cell, position) SAVES - has been handled at the SNAP by PhysicsEngine.Resolve's AdjustPosition validation since #107/#111, so the per-tick pick reverts to retail semantics: 1. lateral recovery first - when the sphere no longer overlaps the claim, search the claim's stab list for a containing cell (retail find_visible_child_cell :311444, the same recovery AdjustPosition uses); the #111 adjacent-claim shape now self-heals laterally (dat-backed test: pick(seed 0x172) at a 0x171-interior point -> 0x171); 2. else KEEP curr_cell (retail null-result). Two old tests asserting the hatch demote rewritten to the retail semantics (tests-can-codify-bugs); P1 retail-golden conformance gates explicitly green (FindCellListConformance + ThresholdPortalCrossing + CottageDoorway + CameraCornerSeal = 11/11). New Issue112MembershipTests: the lateral-recovery fact + a DocumentsResidual fact pinning the remaining at-doorway gap demote (via the NORMAL outdoor-candidate path; open oracle read = retail's add_all_outside_cells gate in CEnvCell::find_transit_cells pc:317499 - sphere-proximity vs graph-reachability). Core 1383 + 4 pre-existing #99 failures + 1 skip. Co-Authored-By: Claude Fable 5 --- src/AcDream.Core/Physics/CellTransit.cs | 48 ++++++----- .../Issue107SpawnDiagnosticTests.cs | 12 +-- .../Conformance/Issue112MembershipTests.cs | 86 +++++++++++++++++++ .../Physics/CellTransitFindCellSetTests.cs | 22 +++-- 4 files changed, 133 insertions(+), 35 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index 88eb096d..7027b6a5 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -621,34 +621,40 @@ public static class CellTransit // null → caller keeps curr_cell). if (outdoorResult != 0u) return outdoorResult; - // ── #106 gate-2 escape hatch: bogus-indoor-claim recovery ────────── - // Restores the #83/A1.7 + #90 verification that lived in - // PhysicsEngine.ResolveCellId before the collide-then-pick rewrite - // moved membership here: an INDOOR current cell that IS hydrated but - // whose CellBSP no longer overlaps ANY part of the foot sphere is a - // bogus claim — a corrupt server save pairing an indoor cell with a - // position far outside it, or the player walked out through an - // unblocked gap. Keeping it wedges everything downstream: the BFS - // can't reach an exit portal from a cell the sphere isn't in (no - // candidates → frozen), ShadowObjectRegistry's #98 gate reads - // "indoor primary" (no object collision anywhere), and there's no - // wall BSP and no terrain (void fall). Demote to the outdoor column - // under the sphere centre (LandDefs global math — cross-block safe). + // ── No containing cell: lateral recovery, then retail keep-curr ──── + // Retail find_cell_list leaves *result null here and the CALLER KEEPS + // curr_cell (pc:308788-308825) — including when the centre sits in a + // containment GAP between a house's cell volumes. #112 (2026-06-10): + // the A9B3 hill cottage has a real gap inside the house; the 6dbbf95 + // escape hatch that used to live here demoted such gaps to the + // outdoor column, stranding the player outdoor-classified deep inside + // the house (outdoor→indoor promotion is portal-adjacent-only, retail- + // identical) → the outdoor flood rendered the interior transparent. + // The hatch's actual target — poisoned (cell, position) SAVES — is + // handled at the SNAP by PhysicsEngine.Resolve's AdjustPosition + // validation since #107/#111; mid-session farness cannot arise (the + // sphere moves continuously, and real building exits flow through + // exterior portals → outside cells enter the candidate array → the + // normal outdoorResult path above demotes there, retail-faithfully). // - // Sphere-overlap (BSPQuery.SphereIntersectsCellBsp, - // pseudo_c:317666→:323267), NOT point-in: a doorway push-back leaves - // the centre a few cm outside while the sphere still overlaps — that - // must NOT demote (#90's ping-pong). A cell with no hydrated CellBSP - // cannot be verified — trust the claim (stale beats null while - // streaming hydrates). - if (currentLow >= 0x0100u && containingOutdoorId != 0u) + // Before keeping a claim whose volume the sphere no longer overlaps, + // try the claim's VISIBLE GRAPH for a containing cell (retail + // CEnvCell::find_visible_child_cell in stab-list mode :311444 — the + // same recovery AdjustPosition uses at :280028): a near-miss claim + // one room off self-heals laterally instead of waiting for a doorway. + if (currentLow >= 0x0100u) { var cur = cache.GetCellStruct(currentCellId); if (cur?.CellBSP?.Root is not null) { var curLocal = Vector3.Transform(worldSphereCenter, cur.InverseWorldTransform); if (!BSPQuery.SphereIntersectsCellBsp(cur.CellBSP.Root, curLocal, sphereRadius)) - return containingOutdoorId; + { + uint recovered = FindVisibleChildCell( + cache, currentCellId, worldSphereCenter, useStabList: true); + if (recovered != 0u && recovered != currentCellId) + return recovered; + } } } diff --git a/tests/AcDream.Core.Tests/Conformance/Issue107SpawnDiagnosticTests.cs b/tests/AcDream.Core.Tests/Conformance/Issue107SpawnDiagnosticTests.cs index 7215bb25..e171b324 100644 --- a/tests/AcDream.Core.Tests/Conformance/Issue107SpawnDiagnosticTests.cs +++ b/tests/AcDream.Core.Tests/Conformance/Issue107SpawnDiagnosticTests.cs @@ -75,13 +75,15 @@ public sealed class Issue107SpawnDiagnosticTests _out.WriteLine($"FindCellList(seed=0x{ActualCell:X8}) -> 0x{pickGood:X8}"); Assert.Equal(ActualCell, pickGood); - // Production pick with the POISONED seed demotes to the outdoor column — - // retail-identical for a cross-building claim (0x171 is not in 0x162's - // stab list). The login-side fix is AdjustPosition at the snap, which - // demotes IMMEDIATELY instead of after the first movement. + // Production pick with the POISONED seed KEEPS it — retail's + // find_cell_list null-result behavior (pc:308788-308825; #112 removed + // the non-retail escape-hatch demote that used to return the outdoor + // column here). The poisoned-save case is handled at the SNAP: + // PhysicsEngine.Resolve's AdjustPosition validation rejects the claim + // and demotes via seen_outside BEFORE physics ever runs on it. uint pickBad = CellTransit.FindCellList(cache, footCenter, FootRadius, PoisonedClaim); _out.WriteLine($"FindCellList(seed=0x{PoisonedClaim:X8}) -> 0x{pickBad:X8}"); - Assert.Equal(0xA9B40031u, pickBad); + Assert.Equal(PoisonedClaim, pickBad); } [Fact] diff --git a/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs b/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs new file mode 100644 index 00000000..32399d34 --- /dev/null +++ b/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs @@ -0,0 +1,86 @@ +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Conformance; + +/// +/// #112 (2026-06-10): the A9B3 hill cottage has a real containment GAP inside +/// the house (world (184.9, −109.5, 116) / A9B3-local (184.9, 82.5) is in NO +/// interior cell while points ~0.7 m away are inside 0x100/0x103). The +/// 6dbbf95 escape hatch demoted the walker to the outdoor column there, +/// stranding them outdoor-classified deep indoors (no containment-based +/// re-promotion) → the outdoor flood rendered the interior transparent. +/// Retail find_cell_list KEEPS curr_cell when nothing contains the centre +/// (pc:308788-308825). These tests pin the replacement semantics against the +/// real dats. +/// +public sealed class Issue112MembershipTests +{ + private readonly ITestOutputHelper _out; + public Issue112MembershipTests(ITestOutputHelper output) => _out = output; + + private const float FootRadius = 0.48f; + + private static PhysicsDataCache LoadLandblockInteriors(DatCollection dats, uint lbPrefix) + { + var cache = new PhysicsDataCache(); + for (uint low = 0x0100; low <= 0x01FF; low++) + { + try { ConformanceDats.LoadEnvCell(dats, cache, lbPrefix | low); } + catch { } + } + return cache; + } + + [Fact] + public void A9B3CottageGap_IndoorSeed_DemotesViaOutdoorCandidates_DocumentsResidual() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + var cache = LoadLandblockInteriors(dats, 0xA9B30000u); + + // The gap point (A9B3-local frame, footcenter height): contained by NO + // interior cell (dat-scan fact from the live capture issue111-verify7). + var gap = new Vector3(184.915f, 82.464f, 116.48f); + + uint picked = CellTransit.FindCellList(cache, gap, FootRadius, 0xA9B30104u); + _out.WriteLine($"pick(seed 0x104) at gap -> 0x{picked:X8}"); + + // DOCUMENTS THE #112 RESIDUAL (flips loudly when fixed): the gap sits + // in the doorway region, so the BFS from 0x104 reaches the exterior + // portal and outdoor cells enter the candidate array → the NORMAL + // outdoorResult path demotes (not the removed escape hatch — its + // removal fixed the deep-room stranding; re-promotion now happens at + // the doorway cells on the way back in). Open question for the fix: + // retail's CEnvCell::find_transit_cells gate for add_all_outside_cells + // (pc:317499 region) — if it requires sphere proximity to the exterior + // portal POLYGON (not just graph reachability), this demote disappears + // and the assert below should become Assert.Equal(0xA9B30104u, ...). + Assert.Equal(0xA9B3003Cu, picked); + } + + [Fact] + public void ThresholdCottage_AdjacentClaim_LaterallyRecovers_ViaStabGraph() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + var cache = LoadLandblockInteriors(dats, 0xA9B40000u); + + // The #111 gate shape: claim 0x172 (adjacent room), position deep + // inside 0x171 (the captured spawn). The sphere does not overlap + // 0x172's volume from there → the new lateral recovery searches the + // claim's stab list (retail find_visible_child_cell :311444) and + // self-heals to 0x171 — instead of the old outdoor demote. + var spawnFootCenter = new Vector3(155.390f, 11.020f, 94.0f + FootRadius); + + uint picked = CellTransit.FindCellList(cache, spawnFootCenter, FootRadius, 0xA9B40172u); + _out.WriteLine($"pick(seed 0x172) at 0x171-interior -> 0x{picked:X8}"); + Assert.Equal(0xA9B40171u, picked); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs index f5e78756..136b28c4 100644 --- a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs @@ -265,15 +265,19 @@ public class CellTransitFindCellSetTests } [Fact] - public void IndoorSeed_SphereFullyOutsideHydratedCell_DemotesToOutdoorColumn() + public void IndoorSeed_SphereFullyOutsideHydratedCell_KeepsCurrent_RetailNullResult() { - // The gate-2 live wedge shape: claimed cell 0xA9B40150, sphere far - // outside its volume (x = -10, fully behind the x≥0 half-space). - // The BFS finds no portals (sphere nowhere near them), so no outdoor - // candidates exist — pre-fix this returned the bogus claim forever. - // Expected: the global outdoor column under the centre. x=-10 is one - // cell WEST of the A9B4 block edge → block 0xA8B4, lcoord (1351,1440) - // → cell (7,0) → low 0x39. + // #112 (2026-06-10): REWRITTEN from the 6dbbf95 escape-hatch + // assertion (was: demote to the outdoor column 0xA8B40039). Retail + // find_cell_list leaves *result null when NOTHING contains the centre + // and the caller KEEPS curr_cell (pc:308788-308825) — the hatch's + // demote was a non-retail addition that also fired on legitimate + // sub-meter containment gaps INSIDE houses (the A9B3 cottage), + // stranding the player outdoor-classified deep indoors → transparent + // interior. The hatch's actual target (poisoned saves) is handled at + // the SNAP by PhysicsEngine.Resolve's AdjustPosition validation + // (#107/#111). With no stab-list on this fixture cell, the lateral + // recovery finds nothing → keep the claim. var cache = new PhysicsDataCache(); cache.RegisterCellStructForTest(0xA9B40150u, MakeCellWithBoundedBsp(Matrix4x4.Identity)); @@ -282,7 +286,7 @@ public class CellTransitFindCellSetTests currentCellId: 0xA9B40150u, out _); - Assert.Equal(0xA8B40039u, containing); + Assert.Equal(0xA9B40150u, containing); } [Fact]