From ca4b482f8b85fca6209255331b86ade7dcd99ee9 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 11 Jun 2026 14:44:49 +0200 Subject: [PATCH] T6 (BR-7) C4: straddle-only outside-add (A6.P5 widening DELETED) + #90 stickiness removed The two remaining flagged workarounds retired, per the BR-7 plan + the WF1 [MEDIUM] correction (re-gate, do NOT delete the outside-add): 1. A6.P5 hasExitPortal topology widening DELETED. Outdoor cells enter the collision cell array ONLY on the retail straddle gate - |dist| < radius + F_EPSILON against an exterior portal plane (CEnvCell::find_transit_cells Ghidra 0x0052c820, gate 0052c9d6, live-binary verified) - the same flag that already gated the membership pick (#112 rider). The widening existed so outdoor- registered doors stayed findable from indoor cells under the old flat registry query; with per-cell shadow lists the door is found in the straddle-admitted outdoor cell's own list (tick-13558 pin holds). The hasExitPortal out-param + plumbing deleted from FindTransitCellsSphere; the AddAllOutsideCells call in BuildCellSetAndPickContaining re-gated on exitOutsideStraddle (once-per-walk = retail CELLARRAY.added_outside). 2. #90 ResolveCellId sphere-overlap stickiness REMOVED (the 4ca3596 workaround, deferred-to-A6.P4 in the physics digest). It was dead code: the method's only caller is FindEnvCollisions' cache-null TEST fallback, and the indoor branch (where the stickiness lived) required a non-null DataCache. Production membership flows exclusively through the collide-then-pick advance whose ordered-array hysteresis (current cell at index 0, interior-wins-break) is the retail mechanism the workaround approximated. ResolveCellId reduced to the bare prefix-preserving outdoor re-derive, documented test-only. Test updates (pins of the deleted behaviors inverted to retail): - A6P5_BuildCellSetFromIndoorStart_ReachesDoorOutdoorCell (asserted the topology widening verbatim) -> DeepInteriorSphere_NoStraddle_ AddsNoOutdoorCells: a deep-interior sphere admits NO outdoor cells. - A6P5_BuildCellSetFromAlcove... -> AlcoveSphere_StraddlesExitPortal_ ReachesDoorOutdoorCell (the captured alcove position genuinely straddles - the retail-positive half). - Issue112MembershipTests straddle pin + the second-sphere straddle test updated to the single-flag signature. Suites: Core 1416/0/2, App 225, UI 420, Net 294 - green. Co-Authored-By: Claude Fable 5 --- src/AcDream.Core/Physics/CellTransit.cs | 84 +++++------- src/AcDream.Core/Physics/PhysicsEngine.cs | 124 ++++-------------- .../Conformance/Issue112MembershipTests.cs | 13 +- .../CellTransitFindTransitCellsSphereTests.cs | 2 +- .../Physics/CellTransitTests.cs | 83 +++++------- 5 files changed, 101 insertions(+), 205 deletions(-) diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index 949ca16c..ba6678a8 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -58,20 +58,6 @@ public static class CellTransit float sphereRadius, ICollection candidates, out bool exitOutside) - => FindTransitCellsSphere( - cache, currentCell, currentCellId, worldSphereCenter, sphereRadius, - candidates, out exitOutside, out _); - - /// - public static void FindTransitCellsSphere( - PhysicsDataCache cache, - CellPhysics currentCell, - uint currentCellId, - Vector3 worldSphereCenter, - float sphereRadius, - ICollection candidates, - out bool exitOutside, - out bool hasExitPortal) { var spheres = new[] { @@ -84,7 +70,7 @@ public static class CellTransit FindTransitCellsSphere( cache, currentCell, currentCellId, - spheres, spheres.Length, candidates, out exitOutside, out hasExitPortal); + spheres, spheres.Length, candidates, out exitOutside); } /// @@ -98,12 +84,9 @@ public static class CellTransit /// This is the only condition under which retail's /// CEnvCell::find_transit_cells calls add_all_outside_cells /// (acclient.exe 0052c8e5-0052c92d straddle test, 0052c9d2-0052c9f0 gate; - /// pseudo-C :310070-310120). Drives the membership pick's outdoor branch. - /// Topology-only: this cell has at least one - /// exterior (0xFFFF) portal, regardless of sphere position. NOT retail — - /// kept for the A6.P5 collision cell-set widening (outdoor-registered door - /// entities must stay findable from indoor cells until the A6.P4 per-cell - /// shadow architecture ships; see the exterior-portal branch comment). + /// pseudo-C :310070-310120). Drives the membership pick's outdoor branch + /// AND (BR-7 C4, retail-faithfully) the collision cell-set outside-add — + /// the former hasExitPortal topology widening is deleted. public static void FindTransitCellsSphere( PhysicsDataCache cache, CellPhysics currentCell, @@ -111,11 +94,9 @@ public static class CellTransit IReadOnlyList worldSpheres, int numSpheres, ICollection candidates, - out bool exitOutside, - out bool hasExitPortal) + out bool exitOutside) { exitOutside = false; - hasExitPortal = false; uint lbPrefix = currentCellId & 0xFFFF0000u; int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres); @@ -129,8 +110,6 @@ public static class CellTransit if (portal.OtherCellId == 0xFFFF) { - hasExitPortal = true; - // #112 rider (2026-06-10): retail straddle gate, RESTORED and // verified against the LIVE 2013 binary (cdb attach, function // 0052c820; x87 decode at 0052c8e5-0052c92d): @@ -144,19 +123,17 @@ public static class CellTransit // portal_side / exact_match in this branch — BN's pseudo-C // invented those (feedback_bn_decomp_field_names). // - // History: this gate existed pre-A6.P5 and was removed - // 2026-05-25 citing the CALLER (find_cell_list :308775-:308785 - // walks every array cell unconditionally — true, but each - // callee still applies its own straddle gate). The A6.P5 - // symptom it fixed (outdoor-registered cottage DOORS invisible - // to collision from indoor cells) is really the missing - // per-cell shadow_object_list (#99/A6.P4): retail finds those - // doors via shadow lists, not via outdoor transit cells. Until - // A6.P4 ships, BuildCellSetAndPickContaining keeps widening the - // COLLISION cell set on hasExitPortal — but the membership PICK - // gates its outdoor branch on this retail flag, which is what - // keeps deep-interior containment gaps on curr_cell (retail - // keep-curr) instead of demoting to outdoor (#112). + // History: this gate existed pre-A6.P5, was removed 2026-05-25 + // citing the CALLER (find_cell_list :308775-:308785 walks every + // array cell unconditionally — true, but each callee still + // applies its own straddle gate), and was restored for the + // membership PICK by the #112 rider. BR-7 / A6.P4 C4 + // (2026-06-11) finished the story: the per-cell shadow + // architecture made the A6.P5 hasExitPortal topology widening + // unnecessary (doors are found in the straddle-admitted outdoor + // cell's own list), so this flag now gates BOTH the pick's + // outdoor branch AND the collision cell-set outside-add — + // pure retail. if (!exitOutside) { for (int i = 0; i < sphereCount; i++) @@ -540,7 +517,7 @@ public static class CellTransit FindTransitCellsSphere( cache, cell, cellId, worldSpheres, sphereCount, - candidates, out bool exitStraddle, out _); + candidates, out bool exitStraddle); if (exitStraddle && !outdoorAdded) { @@ -787,7 +764,7 @@ public static class CellTransit FindTransitCellsSphere( cache, cell, cellId, worldSpheres, sphereCount, - candidates, out bool exitOutsideStraddle, out bool hasExitPortal); + candidates, out bool exitOutsideStraddle); // #112 rider (2026-06-10): the retail straddle flag (live-binary // verified — see FindTransitCellsSphere) gates the PICK's outdoor @@ -795,16 +772,21 @@ public static class CellTransit // when a path sphere straddles an exterior portal plane. outdoorPickAllowed |= exitOutsideStraddle; - // A6.P5 (kept, NARROWED to the collision cell SET): the first - // exit-portal cell triggers the outdoor neighbourhood add once, by - // TOPOLOGY — wider than retail. This keeps outdoor-registered door - // entities findable from indoor cells (the 2026-05-25 door capture) - // until #99/A6.P4 ships per-cell shadow lists; the pick no longer - // consumes these cells unless the retail flag fired, so membership - // matches retail in both regimes. Appended AFTER the interior cells, - // matching retail order (add_all_outside_cells at the end, - // pseudo_c:310120) — interior-wins is preserved. - if (hasExitPortal && !outdoorAdded) + // BR-7 / A6.P4 C4 (2026-06-11): outdoor cells enter the array + // on the retail STRADDLE gate — |dist| < radius + F_EPSILON + // against an exterior portal plane (CEnvCell::find_transit_cells + // 0x0052c820; gate at 0052c9d6) — replacing the A6.P5 + // hasExitPortal TOPOLOGY widening. The widening existed to keep + // outdoor-registered doors findable from indoor cells under the + // old flat query; with per-cell shadow lists the door is found + // in the straddle-admitted outdoor cell's OWN list (and the + // straddle fires whenever a sphere is genuinely at the + // threshold — the tick-13558 door pin proves the admission). + // Appended AFTER the interior cells, matching retail order + // (add_all_outside_cells at the end, pseudo_c:310120) — + // interior-wins is preserved. Once-per-walk via outdoorAdded = + // retail CELLARRAY.added_outside (0x00533630). + if (exitOutsideStraddle && !outdoorAdded) { AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, blockOrigin, candidates); outdoorAdded = true; diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index bc466840..a31740a9 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -290,84 +290,38 @@ public sealed class PhysicsEngine cg.CurrCell = cell; } + /// + /// TEST-ONLY outdoor cell re-derive. The single caller is + /// Transition.FindEnvCollisions's cache-null fallback + /// (PhysicsEngineTests run engines without a , + /// so is unavailable). Production + /// membership flows exclusively through the collide-then-pick advance + /// (RunCheckOtherCellsAndAdvanceFindCellSet). + /// + /// + /// BR-7 / A6.P4 C4 (2026-06-11): the former indoor branch — including + /// the #90 sphere-overlap stickiness workaround (4ca3596) and the + /// building-transit promotion — was DEAD CODE on this path (it required + /// a non-null DataCache; the only caller guarantees null) and is + /// removed. #90's doorway ping-pong concern is owned by the retail + /// ordered-pick hysteresis (current cell at array index 0, + /// interior-wins-break; CellTransit.BuildCellSetAndPickContaining) — + /// the workaround is retired, closing the digest's deferred-removal + /// item. + /// + /// + /// Preserves the L.2e prefix-preservation fix (always apply the + /// matched landblock's high-16 prefix even when + /// arrived bare-low-byte). + /// internal uint ResolveCellId(Vector3 worldPos, float sphereRadius, uint fallbackCellId) { if (fallbackCellId == 0) return 0; - uint fallbackLow = fallbackCellId & 0xFFFFu; + // Indoor fallback ids pass through unchanged — identical to the old + // dead path's `DataCache is null → return fallbackCellId` outcome. + if ((fallbackCellId & 0xFFFFu) >= 0x0100u) return fallbackCellId; - if (fallbackLow >= 0x0100u) - { - // Indoor branch needs DataCache to look up cells; outdoor uses - // _landblocks (no DataCache dependency). - if (DataCache is null) return fallbackCellId; - - // ── Cell-stickiness REVERTED (A6.P3 slice 3 v3, 2026-05-22) ── - // Slice 3 v1 (sphere-overlap, 8898166) over-corrected — held - // player in cellar even when transitioning out at the ramp top. - // Slice 3 v2 (point-in, 3e140cf) closed the ping-pong at the - // inn doorway (data confirmed) BUT prevented the player from - // reaching the top of the cellar ramp (the stuck spot - // transitioned from "ping-pong at top" to "never reach top"). - // - // Reverting to no-stickiness for now. The ping-pong at the inn - // doorway returns but is a lesser evil than blocking cellar-up - // entirely. Issue #98 cellar-up has a deeper bug that needs - // separate investigation (BSP step-physics or AdjustOffset - // slope-projection at the cottage main floor boundary). - // - // Slice 3 work remains valuable as research evidence; the fix - // shape was wrong. Issue #90 stays as workaround until a - // better stickiness mechanism is designed (probably needs to - // be GATED by some "near cell boundary" check rather than - // applied unconditionally). - - // Fallback cell no longer valid → re-resolve via portal-graph BFS. - uint indoorResult = CellTransit.FindCellList(DataCache, worldPos, sphereRadius, fallbackCellId); - - // ISSUES #83 / Phase A1.7 (2026-05-21): verify the indoor result - // actually contains the player. CellTransit.FindCellList falls back - // to currentCellId when no candidate cell's CellBSP contains the - // sphere center — but this happens even when the player has walked - // OUTSIDE the entire portal-connected indoor cell graph (e.g., - // exited through an unblocked wall or doorway gap). In that state - // the player's CellId is stuck on an indoor cell whose BSP is - // far away, every indoor-bsp query returns OK (NodeIntersects - // fails at root), and no walls block. - // - // If the resolved indoor cell's BSP does NOT contain the sphere - // center, fall through to the outdoor cell resolution below — it - // will compute the correct landcell from the terrain grid and - // optionally re-enter an indoor cell via CheckBuildingTransit. - var indoorCell = DataCache.GetCellStruct(indoorResult); - if (indoorCell?.CellBSP?.Root is null) - return indoorResult; // render root (CurrCell) set by the player's UpdateCellId // Can't verify (no CellBSP); trust FindCellList. - - // Issue #90 fix (2026-05-20): use SPHERE-overlap instead of POINT-in - // for the indoor verification. The previous point-only check caused - // a per-frame ping-pong at the inn doorway: indoor BSP push-back - // moved the sphere CENTER a few cm outside the indoor CellBSP - // volume → point-only check returned false → fell through to outdoor - // → next tick re-promoted to indoor → wall hit → push-back → - // outdoor → repeat. Net visual behavior: "walls walk through" - // because outdoor ticks bypass indoor BSP entirely. With sphere- - // overlap, the player stays classified indoor as long as ANY part - // of the foot sphere still overlaps the indoor cell volume. - // - // Retail oracle: CCellStruct::sphere_intersects_cell at - // acclient_2013_pseudo_c.txt:317666 → - // BSPTREE::sphere_intersects_cell_bsp at :323267. - var localCenter = Vector3.Transform(worldPos, indoorCell.InverseWorldTransform); - if (BSPQuery.SphereIntersectsCellBsp(indoorCell.CellBSP.Root, localCenter, sphereRadius)) - return indoorResult; // render root (CurrCell) set by the player's UpdateCellId - - // Fall through to outdoor resolution: player has FULLY left the - // indoor portal-connected graph (sphere no longer overlaps). - } - - // Outdoor seed: use terrain grid to compute the prefixed cell id. - // Preserves the L.2e prefix-preservation fix (always apply the matched - // landblock's high-16 prefix even when fallbackCellId arrived bare-low-byte). foreach (var kvp in _landblocks) { var lb = kvp.Value; @@ -376,29 +330,7 @@ public sealed class PhysicsEngine if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f) { uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY); - uint outdoorCellId = (kvp.Key & 0xFFFF0000u) | lowCellId; - - // Outdoor→indoor entry: if this landcell has a cached building, - // check whether the sphere has crossed into one of its interior - // EnvCells via the building's portals. - if (DataCache is not null) - { - var building = DataCache.GetBuilding(outdoorCellId); - if (building is not null) - { - var candidates = new System.Collections.Generic.HashSet(); - CellTransit.CheckBuildingTransit( - DataCache, building, worldPos, sphereRadius, candidates); - if (candidates.Count > 0) - { - // First candidate wins — building portal containment is - // mutually exclusive in retail (one interior cell per portal). - foreach (var c in candidates) return c; - } - } - } - - return outdoorCellId; // render root (CurrCell) set by the player's UpdateCellId + return (kvp.Key & 0xFFFF0000u) | lowCellId; } } diff --git a/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs b/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs index 7dd71341..7a890097 100644 --- a/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs +++ b/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs @@ -101,21 +101,22 @@ public sealed class Issue112MembershipTests var cell102 = cache.GetCellStruct(0xA9B30102u)!; // (a) Deep inside the room, 3 m from the door plane: the cell HAS an - // exterior portal (topology flag) but no straddle → no outdoor - // admission flag (retail: var_44 stays 0, add_all_outside skipped). + // exterior portal but no straddle → no outdoor admission flag + // (retail: var_44 stays 0, add_all_outside skipped). (BR-7 C4 + // deleted the non-retail hasExitPortal topology output — the + // straddle flag is the only outdoor-admission signal, like + // retail.) var farCandidates = new List(); CellTransit.FindTransitCellsSphere( cache, cell102, 0xA9B30102u, new Vector3(183.0f, 86.5f, 117.0f), - FootRadius, farCandidates, out bool farStraddle, out bool farHasExit); - Assert.True(farHasExit); + FootRadius, farCandidates, out bool farStraddle); Assert.False(farStraddle); // (b) At the door plane (0.30 m away < 0.48 radius): straddle fires. var nearCandidates = new List(); CellTransit.FindTransitCellsSphere( cache, cell102, 0xA9B30102u, new Vector3(185.70f, 85.5f, 117.0f), - FootRadius, nearCandidates, out bool nearStraddle, out bool nearHasExit); - Assert.True(nearHasExit); + FootRadius, nearCandidates, out bool nearStraddle); Assert.True(nearStraddle); } diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs index bcb4bc64..d6bffb22 100644 --- a/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs @@ -212,7 +212,7 @@ public class CellTransitFindTransitCellsSphereTests CellTransit.FindTransitCellsSphere( cache, exitCell, currentCellId: 0xA9B40100u, - spheres, spheres.Length, candidates, out bool exitOutside, out _); + spheres, spheres.Length, candidates, out bool exitOutside); Assert.True(exitOutside); } diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitTests.cs index ec58db29..92d2ca31 100644 --- a/tests/AcDream.Core.Tests/Physics/CellTransitTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellTransitTests.cs @@ -16,37 +16,29 @@ namespace AcDream.Core.Tests.Physics; /// Tests for portal-graph expansion. /// /// -/// A6.P5 (2026-05-25) — unit-tests the cellSet build for the cottage door -/// scenario, where pre-fix the exitOutside gate produced over- -/// penetration ticks (sphere advanced 0.27 m INTO the door slab because -/// the door's outdoor cell wasn't in the cellSet during a cell-crossing -/// substep). -/// -/// -/// -/// Loads cells 0xA9B4013F (player's starting indoor cell at the -/// over-penetration tick) and 0xA9B40150 (alcove cell adjacent to -/// the doorway) from the real dat. Asserts that BFS expansion from -/// 0xA9B4013F reaches the door's outdoor cell 0xA9B40029 -/// via the portal chain. The fix makes this hold unconditionally; -/// pre-fix it only held when the sphere physically straddled an exit -/// portal. +/// History: A6.P5 (2026-05-25) widened the collision cell set on exit- +/// portal TOPOLOGY (outdoor cells added whenever an overlapped cell had a +/// 0xFFFF portal) so outdoor-registered doors stayed findable from indoor +/// cells under the flat registry query. BR-7 / A6.P4 C4 (2026-06-11) +/// removed the widening: outdoor cells enter the array only on the retail +/// STRADDLE gate (|dist| < radius + F_EPSILON vs the exterior portal +/// plane — CEnvCell::find_transit_cells, Ghidra 0x0052c820, live-binary +/// verified), and doors are found per-cell via registration-time +/// membership instead. These tests pin BOTH halves of the retail +/// semantics on real Holtburg dat geometry. /// /// public class CellTransitTests { [Fact] - public void A6P5_BuildCellSetFromIndoorStart_ReachesDoorOutdoorCell() + public void DeepInteriorSphere_NoStraddle_AddsNoOutdoorCells() { var datDir = ResolveDatDir(); if (datDir is null) return; // Hydrate the cells in the portal chain. - // 0xA9B4013F — start cell at the over-penetration tick - // 0xA9B40150 — alcove cell adjacent to doorway (has exit portal) - // The door lives in outdoor cell 0xA9B40029 (registered as a - // shadow entry on the landblock — not loaded here; this test - // only asserts the cellSet membership, not collision). + // 0xA9B4013F — deep interior cell (the old over-penetration tick) + // 0xA9B40150 — alcove cell adjacent to the doorway (exit portal) var cache = new PhysicsDataCache(); HydrateCell(cache, datDir, 0xA9B4013Fu); HydrateCell(cache, datDir, 0xA9B40150u); @@ -54,16 +46,15 @@ public class CellTransitTests Assert.NotNull(cache.GetCellStruct(0xA9B4013Fu)); Assert.NotNull(cache.GetCellStruct(0xA9B40150u)); - // Sphere at the over-penetration tick start position: world - // (132.594, 16.350, 94.48 sphere center). Foot Z = 94, sphere - // radius 0.48 → center Z = 94 + radius? Actually the live - // capture's sphere center is at Z = 94.48 since foot Z = 94 - // and sphere is foot-anchored. For this test the Z value - // doesn't matter — only XY portal membership. + // Sphere deep in 0x13F's volume — no path sphere straddles + // 0x150's exit portal plane from here (the A6.P5 pin's own + // message documented exactly that). Retail adds NO outdoor cells + // in this state; the door (outdoor 0xA9B40029) is reached only + // when the sphere actually straddles the threshold — pinned by + // the alcove test below and the tick-13558 door pin. var sphereWorld = new Vector3(132.5935f, 16.350428f, 94.48f); const float sphereRadius = 0.48f; const uint startCellId = 0xA9B4013Fu; - const uint doorOutdoorCell = 0xA9B40029u; _ = CellTransit.FindCellSet( cache, @@ -72,37 +63,25 @@ public class CellTransitTests startCellId, out var cellSet); - Assert.True( - cellSet.Contains(doorOutdoorCell), - $"A6.P5: BFS portal expansion from indoor start cell " + - $"0x{startCellId:X8} (sphere world XY = " + - $"({sphereWorld.X:F3}, {sphereWorld.Y:F3})) should include " + - $"the door's outdoor cell 0x{doorOutdoorCell:X8} via the " + - $"portal chain 0x{startCellId:X8} → 0xA9B40150 → outdoor. " + - $"Pre-fix: the exitOutside gate required the sphere to " + - $"straddle 0xA9B40150's exit portal — sphere is in " + - $"0x{startCellId:X8}'s volume, so the gate didn't fire and " + - $"the door's cell wasn't added. Post-fix: gate removed; " + - $"exit-portal topology adds outdoor cells unconditionally. " + - $"Actual cellSet: {string.Join(",", cellSet.Select(c => $"0x{c:X8}"))}"); + Assert.DoesNotContain(cellSet, c => (c & 0xFFFFu) < 0x0100u); + Assert.Contains(startCellId, cellSet); } [Fact] - public void A6P5_BuildCellSetFromAlcove_AlsoReachesDoorOutdoorCell() + public void AlcoveSphere_StraddlesExitPortal_ReachesDoorOutdoorCell() { var datDir = ResolveDatDir(); if (datDir is null) return; - // The alcove cell 0xA9B40150 IS the cell with the exit portal. - // Pre-fix this worked SOMETIMES (when the sphere straddled the - // portal). Post-fix it works ALWAYS. This is a regression guard - // for the previously-sometimes-working case. + // The alcove cell 0xA9B40150 IS the cell with the exit portal, and + // at this captured position the foot sphere straddles its plane + // (|dist| < 0.48 + ε — same geometry the tick-13558 straddle pin + // proves). Retail admits the outdoor cells exactly here, which is + // how the indoor-side sphere reaches the outdoor-registered door's + // per-cell shadow list. var cache = new PhysicsDataCache(); HydrateCell(cache, datDir, 0xA9B40150u); - // Sphere at the stuck position — sphere center is NOT at the - // exit portal plane (sphere is in the alcove volume, away from - // the doorway threshold geometrically). var sphereWorld = new Vector3(132.4014f, 16.761757f, 94.48f); const float sphereRadius = 0.48f; @@ -115,8 +94,10 @@ public class CellTransitTests Assert.True( cellSet.Contains(0xA9B40029u), - $"A6.P5: BFS from alcove cell 0xA9B40150 should always " + - $"include the door's outdoor cell 0xA9B40029. " + + $"Straddle-gated outside-add: the alcove sphere straddles " + + $"0xA9B40150's exit portal, so the door's outdoor cell " + + $"0xA9B40029 must enter the set (retail " + + $"CEnvCell::find_transit_cells straddle, Ghidra 0x0052c820). " + $"Actual: {string.Join(",", cellSet.Select(c => $"0x{c:X8}"))}"); }