feat(core): UCG W2 Task 3 — stab-list doorway hysteresis in ResolveCellId

Port retail CObjCell::find_cell_list do_not_load_cells prune
(acclient_2013_pseudo_c.txt:308829-308867) as indoor->outdoor doorway
hysteresis: hold the previous indoor cell when the outdoor candidate is
not in its stab list AND the foot-sphere still overlaps the cell's
containment BSP expanded by DoorwayHoldMargin. Kills the front-door
0170<->0031 ping-pong (handoff §5) the #98 saga never addressed. Fires
only at the front-door seam; the cellar has no exit portal so it never
falls through here (#98 cellar-up untouched).

Three TDD tests in CellGraphMembershipTests: HOLD (the RED->GREEN case,
Y=3.9 inside the 0.2 m margin), RELEASE when fully outside (Y=4.5
exceeds expanded margin), and stab-list gate (outdoor candidate in stab
list releases even near the boundary).

Adds using System.Linq for IReadOnlyList.Contains at the prune site.
SphereOverlapsEnvCell helper mirrors BSPQuery.SphereIntersectsCellBsp
via EnvCell.InverseWorldTransform + ContainmentBsp.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-02 11:22:50 +02:00
parent 3622a658fd
commit 2acd8f9e1d
2 changed files with 246 additions and 0 deletions

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
namespace AcDream.Core.Physics;
@ -51,6 +52,27 @@ public sealed class PhysicsEngine
float WorldOffsetX,
float WorldOffsetY);
/// <summary>
/// UCG W2b doorway-hysteresis band (metres) added to the foot-sphere radius when deciding
/// whether to HOLD the previous indoor cell at the indoor→outdoor seam. Must exceed the
/// ~10 cm push-back oscillation at the threshold (handoff §5: front-door 0170↔0031) while
/// still releasing on a genuine step-out. The single value the visual gate may tune.
/// </summary>
private const float DoorwayHoldMargin = 0.2f;
/// <summary>
/// UCG W2b: does the foot-sphere (at <paramref name="worldPos"/>, inflated to
/// <paramref name="radius"/>) overlap <paramref name="cell"/>'s containment BSP? Returns false
/// when the cell has no containment BSP (can't test ⇒ release, never hold forever).
/// Retail: CCellStruct::sphere_intersects_cell (acclient_2013_pseudo_c.txt:317666).
/// </summary>
private static bool SphereOverlapsEnvCell(World.Cells.EnvCell cell, Vector3 worldPos, float radius)
{
if (cell.ContainmentBsp?.Root is null) return false;
var local = Vector3.Transform(worldPos, cell.InverseWorldTransform);
return BSPQuery.SphereIntersectsCellBsp(cell.ContainmentBsp.Root, local, radius);
}
/// <summary>
/// Register a landblock with its terrain surface, indoor cells, portal
/// planes, and world-space origin offset.
@ -377,6 +399,27 @@ public sealed class PhysicsEngine
}
}
// UCG W2b — retail find_cell_list do_not_load_cells prune
// (acclient_2013_pseudo_c.txt:308829-308867). Retail drops from the candidate
// cell-array any cell that is neither the current cell nor in its stab list
// (VisibleCells). Adapted here as doorway hysteresis at the indoor→outdoor seam:
// if the previous membership answer is an indoor cell and the outdoor candidate
// is NOT in its stab list (outdoor landcells never are), HOLD the indoor cell
// while the foot-sphere still overlaps that cell's containment BSP expanded by
// DoorwayHoldMargin. The strict overlap check at the indoor branch (~line 340)
// just released (the center was pushed past the wall), but the expanded test holds
// us across the ~10 cm push-back oscillation at the threshold (handoff §5:
// 0170↔0031). Releases on a genuine step-out — by then the bare overlap is also
// false, so CheckBuildingTransit won't re-grab the cell next tick. Anti-ping-pong
// the #98 saga never had; fires only at the front-door seam (the cellar has no
// exit portal → it never falls through here → #98 cellar-up is untouched).
if (DataCache?.CellGraph.CurrCell is World.Cells.EnvCell prevCell
&& !prevCell.StabList.Contains(outdoorCellId)
&& SphereOverlapsEnvCell(prevCell, worldPos, sphereRadius + DoorwayHoldMargin))
{
return SetCurrAndReturn(prevCell.Id);
}
return SetCurrAndReturn(outdoorCellId);
}
}