fix(phys): #112 residual - retail straddle gate for outdoor-cell admission (live-binary verified)
The oracle read the #112 residual was waiting on, settled against the LIVE 2013 client (cdb attach, CEnvCell::find_transit_cells @ 0052c820; BN pseudo-C was ambiguous and partly wrong per feedback_bn_decomp_field_names - it invented portal_side tests in this branch): retail admits outdoor transit cells from an indoor cell IFF a path sphere STRADDLES an exterior portal polygon plane, |dist| < radius + F_EPSILON(0.000199999995, @ 007c8c70). The flag at [esp+18h] (set 0052c925, x87 decode fcompp/test ah,41h + fcomp/test ah,5/jp) gates the add_all_outside_cells call (0052c9d6 je). Graph reachability alone NEVER admits outdoor cells in retail. Port (CellTransit): - FindTransitCellsSphere: exitOutside now carries the retail straddle semantics; new hasExitPortal out carries the old topology-only flag. - BuildCellSetAndPickContaining: the collision cell SET keeps the A6.P5 topology widening on hasExitPortal (outdoor-registered doors must stay findable from indoor cells until #99/A6.P4 ships per-cell shadow lists - the 2026-05-25 door capture scenario), but the membership PICK's outdoor branch is gated on the retail flag. Membership is now retail-identical in both regimes: straddle -> outdoor candidates valid; no straddle -> outdoor ignored -> retail keep-curr. This is what stops deep-interior containment gaps in ANY house from demoting to outdoor (the #112 transparent-interior shape) - the systemic protection the user asked for, without house-by-house verification. The at-doorway A9B3 gap demote is RETAIL-FAITHFUL (gap point is 0.23m from 0x104s door plane < 0.48 foot radius -> retail straddles + demotes + self-heals inward): DocumentsResidual renamed to ...DemotesRetailFaithfully, expectation unchanged. New conformance pins: deep-gap keep-curr (A9B3Cottage_GapBeyondStraddleDistance_KeepsCurrCell) + function-level gate semantics on real dat geometry (FindTransitCellsSphere_ExitPortalStraddleGate_MatchesRetail). Tests: Core 1391 green (+2) / App 224 / UI 420 / Net 294; pre-existing 4 #99-era failures unchanged; P1 membership goldens + A6.P5 door-set tests explicitly green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
927fd8fde2
commit
414c3deaf4
3 changed files with 173 additions and 47 deletions
|
|
@ -32,6 +32,13 @@ public static class CellTransit
|
|||
/// </summary>
|
||||
private const float EPSILON = 0.02f;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>F_EPSILON</c> (acclient.exe data @ 007c8c70, 0.000199999995f) —
|
||||
/// the pad added to the sphere radius in the exterior-portal straddle test
|
||||
/// (<c>fadd [ecx+4]</c> at 0052c8eb, #112 rider live-binary read 2026-06-10).
|
||||
/// </summary>
|
||||
private const float FEpsilon = 0.000199999995f;
|
||||
|
||||
/// <summary>
|
||||
/// Indoor portal-neighbour expansion. For each portal of
|
||||
/// <paramref name="currentCell"/>, test whether the sphere overlaps
|
||||
|
|
@ -51,6 +58,20 @@ public static class CellTransit
|
|||
float sphereRadius,
|
||||
ICollection<uint> candidates,
|
||||
out bool exitOutside)
|
||||
=> FindTransitCellsSphere(
|
||||
cache, currentCell, currentCellId, worldSphereCenter, sphereRadius,
|
||||
candidates, out exitOutside, out _);
|
||||
|
||||
/// <inheritdoc cref="FindTransitCellsSphere(PhysicsDataCache, CellPhysics, uint, IReadOnlyList{Sphere}, int, ICollection{uint}, out bool, out bool)"/>
|
||||
public static void FindTransitCellsSphere(
|
||||
PhysicsDataCache cache,
|
||||
CellPhysics currentCell,
|
||||
uint currentCellId,
|
||||
Vector3 worldSphereCenter,
|
||||
float sphereRadius,
|
||||
ICollection<uint> candidates,
|
||||
out bool exitOutside,
|
||||
out bool hasExitPortal)
|
||||
{
|
||||
var spheres = new[]
|
||||
{
|
||||
|
|
@ -63,7 +84,7 @@ public static class CellTransit
|
|||
|
||||
FindTransitCellsSphere(
|
||||
cache, currentCell, currentCellId,
|
||||
spheres, spheres.Length, candidates, out exitOutside);
|
||||
spheres, spheres.Length, candidates, out exitOutside, out hasExitPortal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -71,6 +92,18 @@ public static class CellTransit
|
|||
/// pass <c>sphere_path.num_sphere</c> and <c>sphere_path.global_sphere</c>.
|
||||
/// Any sphere can trigger a portal neighbor or outdoor exit.
|
||||
/// </summary>
|
||||
/// <param name="exitOutside">RETAIL semantics (live-binary verified
|
||||
/// 2026-06-10, #112 rider): true iff some path sphere STRADDLES one of this
|
||||
/// cell's exterior portal polygon planes — <c>|dist| < radius + EPSILON</c>.
|
||||
/// This is the only condition under which retail's
|
||||
/// <c>CEnvCell::find_transit_cells</c> calls <c>add_all_outside_cells</c>
|
||||
/// (acclient.exe 0052c8e5-0052c92d straddle test, 0052c9d2-0052c9f0 gate;
|
||||
/// pseudo-C :310070-310120). Drives the membership pick's outdoor branch.</param>
|
||||
/// <param name="hasExitPortal">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).</param>
|
||||
public static void FindTransitCellsSphere(
|
||||
PhysicsDataCache cache,
|
||||
CellPhysics currentCell,
|
||||
|
|
@ -78,9 +111,11 @@ public static class CellTransit
|
|||
IReadOnlyList<Sphere> worldSpheres,
|
||||
int numSpheres,
|
||||
ICollection<uint> candidates,
|
||||
out bool exitOutside)
|
||||
out bool exitOutside,
|
||||
out bool hasExitPortal)
|
||||
{
|
||||
exitOutside = false;
|
||||
hasExitPortal = false;
|
||||
|
||||
uint lbPrefix = currentCellId & 0xFFFF0000u;
|
||||
int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres);
|
||||
|
|
@ -94,33 +129,50 @@ public static class CellTransit
|
|||
|
||||
if (portal.OtherCellId == 0xFFFF)
|
||||
{
|
||||
// A6.P5 (2026-05-25): exit portals add outdoor cells
|
||||
// UNCONDITIONALLY, by topology — not by sphere-plane overlap.
|
||||
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):
|
||||
//
|
||||
// Retail's CObjCell::find_cell_list (acclient_2013_pseudo_c.txt
|
||||
// :308742-308869) walks vtable[0x80] on every cell already in
|
||||
// the array and adds reachable cells without testing the
|
||||
// sphere against each portal plane. The straddle check we
|
||||
// had here gated outdoor inclusion on the sphere physically
|
||||
// overlapping the EXIT portal — which fails to fire when:
|
||||
// a) the sphere is in a SIBLING indoor cell that BFS-
|
||||
// expanded to this one (sphere is geographically near
|
||||
// the doorway region, just not at THIS cell's exit
|
||||
// portal plane); OR
|
||||
// b) the per-tick target moves the sphere across the
|
||||
// portal plane on one tick but not the next, producing
|
||||
// intermittent visibility from the same position.
|
||||
// pad = sphere.radius + F_EPSILON (fadd [ecx+4])
|
||||
// dist = dot(localCenter, portalPlane.n) + d (cell-local)
|
||||
// flag |= (dist > -pad) && (dist < +pad) (fcompp/test ah,41h
|
||||
// + fcomp/test ah,5/jp)
|
||||
//
|
||||
// Pre-fix bug: cottage doors at outdoor cells were invisible
|
||||
// from indoor cells during cell-crossing substeps (live
|
||||
// capture 2026-05-25; over-penetration test in
|
||||
// CellTransitTests.A6P5_BuildCellSetFromIndoorStart_...).
|
||||
// add_all_outside_cells fires IFF flag (0052c9d6 je → skip). No
|
||||
// portal_side / exact_match in this branch — BN's pseudo-C
|
||||
// invented those (feedback_bn_decomp_field_names).
|
||||
//
|
||||
// Post-fix: any cell visited by BFS that has at least one
|
||||
// exit portal contributes exitOutside=true regardless of
|
||||
// sphere position. AddAllOutsideCells fires once per BFS
|
||||
// (deduped in BuildCellSetAndPickContaining).
|
||||
exitOutside = true;
|
||||
// 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).
|
||||
if (!exitOutside)
|
||||
{
|
||||
for (int i = 0; i < sphereCount; i++)
|
||||
{
|
||||
var sphere = worldSpheres[i];
|
||||
float pad = sphere.Radius + FEpsilon;
|
||||
var localCenter = Vector3.Transform(
|
||||
sphere.Origin, currentCell.InverseWorldTransform);
|
||||
float dist = Vector3.Dot(localCenter, poly.Plane.Normal) + poly.Plane.D;
|
||||
if (dist > -pad && dist < pad)
|
||||
{
|
||||
exitOutside = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -510,9 +562,14 @@ public static class CellTransit
|
|||
// #106: the current block's world origin converts the world-frame sphere
|
||||
// coords into retail's block-local frame for the LandDefs lcoord math.
|
||||
// Unregistered terrain (tests; pre-stream) falls back to Vector3.Zero —
|
||||
// the legacy anchor-block assumption (world frame == block-local frame).
|
||||
// the legacy anchor-frame assumption (world frame == block-local frame).
|
||||
cache.CellGraph.TryGetTerrainOrigin(currentCellId, out var blockOrigin);
|
||||
|
||||
// #112 rider: outdoor candidates may win the pick only when retail would
|
||||
// have admitted them — outdoor seeds always; indoor seeds only when a
|
||||
// sphere straddled an exterior portal plane during the BFS (set below).
|
||||
bool outdoorPickAllowed = currentLow < 0x0100u;
|
||||
|
||||
if (currentLow >= 0x0100u)
|
||||
{
|
||||
// Indoor seed: the CURRENT cell is added at INDEX 0 (retail
|
||||
|
|
@ -537,14 +594,24 @@ public static class CellTransit
|
|||
|
||||
FindTransitCellsSphere(
|
||||
cache, cell, cellId, worldSpheres, sphereCount,
|
||||
candidates, out bool exitOutside);
|
||||
candidates, out bool exitOutsideStraddle, out bool hasExitPortal);
|
||||
|
||||
// A6.P5 (kept): the first exit-portal cell triggers the outdoor
|
||||
// neighbourhood add once. Appended AFTER the interior cells, matching
|
||||
// retail (CEnvCell::find_transit_cells calls add_all_outside_cells at
|
||||
// the end, pseudo_c:310120) — so interior cells precede outdoor in the
|
||||
// pick order and interior-wins is preserved.
|
||||
if (exitOutside && !outdoorAdded)
|
||||
// #112 rider (2026-06-10): the retail straddle flag (live-binary
|
||||
// verified — see FindTransitCellsSphere) gates the PICK's outdoor
|
||||
// branch below. Retail only ever has outdoor cells in this array
|
||||
// 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)
|
||||
{
|
||||
AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, blockOrigin, candidates);
|
||||
outdoorAdded = true;
|
||||
|
|
@ -605,12 +672,16 @@ public static class CellTransit
|
|||
if (BSPQuery.PointInsideCellBsp(cand.CellBSP.Root, local))
|
||||
return candId; // interior-wins, stop (pseudo_c:308819)
|
||||
}
|
||||
else if (outdoorResult == 0u && containingOutdoorId != 0u)
|
||||
else if (outdoorResult == 0u && containingOutdoorId != 0u && outdoorPickAllowed)
|
||||
{
|
||||
// Outdoor candidate — CLandCell::point_in_cell is the XY-column the
|
||||
// sphere is over (acdream landcells have no BSP point_in_cell; the
|
||||
// documented adaptation). Record as the running result but DO NOT
|
||||
// break — an interior cell later in the array can still win.
|
||||
// #112 rider: gated on outdoorPickAllowed — retail's array only
|
||||
// contains outdoor cells when a sphere straddled an exterior portal
|
||||
// plane (live-binary verified); ours may also contain them via the
|
||||
// A6.P5 collision widening, which the pick must ignore.
|
||||
if (candId == containingOutdoorId)
|
||||
outdoorResult = candId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter;
|
||||
|
|
@ -37,7 +38,7 @@ public sealed class Issue112MembershipTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void A9B3CottageGap_IndoorSeed_DemotesViaOutdoorCandidates_DocumentsResidual()
|
||||
public void A9B3CottageGap_AtDoorway_StraddlesExitPlane_DemotesRetailFaithfully()
|
||||
{
|
||||
var datDir = ConformanceDats.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; }
|
||||
|
|
@ -51,19 +52,73 @@ public sealed class Issue112MembershipTests
|
|||
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, ...).
|
||||
// RESOLVED 2026-06-10 (#112 rider, live-binary oracle): retail's
|
||||
// CEnvCell::find_transit_cells admits outdoor cells IFF a path sphere
|
||||
// STRADDLES an exterior portal's plane (|dist| < radius + F_EPSILON;
|
||||
// acclient.exe 0052c8e5-0052c9f0). The gap point sits 0.23 m from
|
||||
// 0x104's exit-door plane (x=184.684) with foot radius 0.48 — it
|
||||
// STRADDLES, so retail admits the outdoor column and demotes here too.
|
||||
// This at-doorway demote is RETAIL-FAITHFUL, not a divergence; it
|
||||
// self-heals one step inward via doorway re-promotion. The former
|
||||
// "DocumentsResidual" framing is closed — see the deep-gap test below
|
||||
// for the behavior that DID change with the straddle gate.
|
||||
Assert.Equal(0xA9B3003Cu, picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A9B3Cottage_GapBeyondStraddleDistance_KeepsCurrCell_RetailGate()
|
||||
{
|
||||
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);
|
||||
|
||||
// A no-cell point past the straddle window: 0.66 m beyond 0x104's
|
||||
// exit-door plane (x=184.684 + 0.48 + 0.18), still in no interior cell
|
||||
// (inside the shell-wall band). Pre-gate, the A6.P5 topology widening
|
||||
// let the outdoor column WIN the pick here → outdoor demote deep in a
|
||||
// containment gap (#112's transparent-interior shape). Retail keeps
|
||||
// curr_cell: no sphere straddles any exterior portal plane, so the
|
||||
// outdoor cells never become pick candidates (live-binary verified).
|
||||
var deepGap = new Vector3(185.345f, 82.464f, 116.48f);
|
||||
|
||||
uint picked = CellTransit.FindCellList(cache, deepGap, FootRadius, 0xA9B30104u);
|
||||
_out.WriteLine($"pick(seed 0x104) at deep gap -> 0x{picked:X8}");
|
||||
Assert.Equal(0xA9B30104u, picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindTransitCellsSphere_ExitPortalStraddleGate_MatchesRetail()
|
||||
{
|
||||
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);
|
||||
|
||||
// Cottage north room 0x102 has an exterior door at x=186 (portal poly
|
||||
// plane n=(±1,0,0), |d|=186 — dat dump 2026-06-10). Function-level pin
|
||||
// of the retail gate semantics on real dat geometry:
|
||||
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).
|
||||
var farCandidates = new List<uint>();
|
||||
CellTransit.FindTransitCellsSphere(
|
||||
cache, cell102, 0xA9B30102u, new Vector3(183.0f, 86.5f, 117.0f),
|
||||
FootRadius, farCandidates, out bool farStraddle, out bool farHasExit);
|
||||
Assert.True(farHasExit);
|
||||
Assert.False(farStraddle);
|
||||
|
||||
// (b) At the door plane (0.30 m away < 0.48 radius): straddle fires.
|
||||
var nearCandidates = new List<uint>();
|
||||
CellTransit.FindTransitCellsSphere(
|
||||
cache, cell102, 0xA9B30102u, new Vector3(185.70f, 85.5f, 117.0f),
|
||||
FootRadius, nearCandidates, out bool nearStraddle, out bool nearHasExit);
|
||||
Assert.True(nearHasExit);
|
||||
Assert.True(nearStraddle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThresholdCottage_AdjacentClaim_LaterallyRecovers_ViaStabGraph()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ public class CellTransitFindTransitCellsSphereTests
|
|||
|
||||
CellTransit.FindTransitCellsSphere(
|
||||
cache, exitCell, currentCellId: 0xA9B40100u,
|
||||
spheres, spheres.Length, candidates, out bool exitOutside);
|
||||
spheres, spheres.Length, candidates, out bool exitOutside, out _);
|
||||
|
||||
Assert.True(exitOutside);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue