fix(phys): A6.P3 #98 — gate outdoor shadow radial sweep on indoor primary cell

The cellar-up cap was caused by ShadowObjectRegistry.GetNearbyObjects
running its outdoor 24m-grid radial query unconditionally — including
when the moving sphere's primary cell is indoor. The landblock-baked
cottage GfxObj 0x01000A2B (registered with cellScope=0u, i.e.
landblock-wide) was returned for a sphere inside the cellar EnvCell,
and its downward-facing cottage-floor poly at world Z=94 head-bumped
the sphere from below, capping ascent at foot Z=92.74.

Diagnosis this session via the live capture in
a6-issue98-resolve-capture-2.jsonl (92K records, 132 cap events all
with body on the ramp polygon) FALSIFIED the prior "stale ramp
contact plane" hypothesis: the contact plane is correctly the ramp's
plane because the sphere IS on the ramp at the cap. The cap is a
proximate consequence of the cottage GfxObj being queried at all from
an indoor primary cell.

Retail decomp anchor (acclient_2013_pseudo_c.txt):
  - 308751-308769: CObjCell::find_cell_list branches on the moving
    object's m_position.objcell_id — INDOOR adds only that cell +
    portal-visible neighbors via CELLARRAY::add_cell; OUTDOOR adds
    all overlapping outdoor cells via CLandCell::add_all_outside_cells.
    Object-position-driven, not sphere-radius-driven.
  - 309560: CEnvCell::find_collisions calls find_env_collisions
    (own cell BSP only) THEN CObjCell::find_obj_collisions on `this`.
  - 308916: CObjCell::find_obj_collisions iterates this->shadow_object_list
    — strictly per-cell, never landblock-wide.

Combined: a landblock-baked static like the cottage building is added
to outdoor cells' shadow_object_list only (its m_position resolves to
an outdoor cell). An indoor EnvCell's shadow_object_list never
contains the cottage. CEnvCell::find_collisions therefore never tests
the sphere against the cottage. Retail-faithful behavior.

Falsification spike (this session): scoping the cottage to a single
distant outdoor cell instead of landblock-wide caused the harness
LiveCompare_FirstCap test to stop reproducing the cn=(0,0,-1) cap,
confirming the cap is caused by the radial sweep returning the
cottage to an indoor primary.

The fix:
  - Add optional `primaryCellId` parameter to
    ShadowObjectRegistry.GetNearbyObjects. When indoor (>= 0x0100),
    skip the outdoor radial sweep entirely after the indoorCellIds
    branch runs. Default 0u preserves prior behavior for
    cell-unaware callers (existing tests pass unchanged).
  - Transition.FindObjCollisions passes sp.CheckCellId.
  - Harness LiveCompare_FirstCap_* flipped to documents-the-fix form
    (asserts the downward-facing cottage-floor cap does NOT fire).
    Deletes the residual-X-motion test that documented a post-cap
    edge-slide — irrelevant once the cap is gone.

This same gate should close the other "Finding 3 family" indoor/outdoor
collision bugs (#97 phantom collisions, indoor sling-out). Visual
verification by the user is the remaining acceptance check before
closing #98.

Verification:
  - 11/11 CellarUpTrajectoryReplayTests pass in isolation
  - 55 ShadowObjectRegistry + TransitionTypes + PhysicsEngine
    + CellPhysics + CellTransit tests pass
  - 8 pre-existing static-state-leakage failures in serial physics
    suite are unchanged (verified by stash + retest on baseline)
  - dotnet build clean, 0 warnings

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-24 06:49:46 +02:00
parent bf6d97625c
commit b3ce505ca8
3 changed files with 85 additions and 103 deletions

View file

@ -254,11 +254,37 @@ public sealed class ShadowObjectRegistry
/// grid lookup (cell ids like `0xA9B40029`) never queries the indoor key.
/// Net effect pre-fix: interior items don't block movement.
/// </para>
///
/// <para>
/// Issue #98 (2026-05-24): the optional <paramref name="primaryCellId"/>
/// parameter gates the outdoor radial sweep on the SPHERE's primary cell
/// type. Mirrors retail's <c>CObjCell::find_cell_list</c> at
/// <c>acclient_2013_pseudo_c.txt:308751-308769</c>: when the sphere's
/// position is in an indoor cell (id ≥ 0x0100), retail only adds THAT
/// cell + portal-visible neighbors to the cell array — never outdoor
/// cells. Combined with <c>CEnvCell::find_collisions</c> only iterating
/// <c>this->shadow_object_list</c>, retail's indoor cells never test
/// against outdoor statics like landblock-baked buildings.
/// </para>
///
/// <para>
/// Pre-fix bug shape (issue #98): the cellar EnvCell's player sphere
/// queried the outdoor 24-m grid via the radial sweep and picked up the
/// landblock-wide cottage GfxObj (registered with <c>cellScope=0</c>);
/// the head sphere bumped the cottage's downward-facing floor poly from
/// below at world Z=94 and capped the ascent. With this gate, the
/// outdoor sweep is skipped when the primary cell is indoor, so the
/// cottage is only seen from outdoor primary cells (the building's
/// own outdoor footprint). Default <c>primaryCellId=0</c> preserves
/// the pre-fix radial-only behavior for callers that don't know /
/// care about cell type (existing tests).
/// </para>
/// </summary>
public void GetNearbyObjects(Vector3 worldPos, float queryRadius,
float worldOffsetX, float worldOffsetY, uint landblockId,
List<ShadowEntry> results,
System.Collections.Generic.IReadOnlyCollection<uint>? indoorCellIds = null)
System.Collections.Generic.IReadOnlyCollection<uint>? indoorCellIds = null,
uint primaryCellId = 0u)
{
results.Clear();
var seen = new HashSet<uint>();
@ -279,6 +305,17 @@ public sealed class ShadowObjectRegistry
}
}
// Issue #98 (2026-05-24): when the sphere is in an INDOOR cell, skip
// the outdoor radial sweep entirely — retail's CEnvCell::find_collisions
// only iterates this->shadow_object_list, never outdoor cells'. Indoor
// statics are reached via indoorCellIds above. This closes the
// cottage-cellar Z-cap (head sphere bumping cottage floor from below
// because the landblock-wide cottage GfxObj was returned by the
// unconditional radial sweep). Callers that don't pass primaryCellId
// (or pass 0) keep the pre-fix radial-only behavior.
if ((primaryCellId & 0xFFFFu) >= 0x0100u)
return;
// Extract landblock X/Y from the ID.
int lbX = (int)((landblockId >> 24) & 0xFF);
int lbY = (int)((landblockId >> 16) & 0xFF);

View file

@ -2177,11 +2177,18 @@ public sealed class Transition
_ = CellTransit.FindCellSet(engine.DataCache, currPos, sphereRadius,
sp.CheckCellId, out var indoorCellIds);
// Issue #98 (2026-05-24): pass primary cellId so the radial outdoor
// sweep is skipped when sphere is in an indoor cell. Mirrors retail's
// CObjCell::find_cell_list indoor/outdoor branch
// (acclient_2013_pseudo_c.txt:308751-308769) — indoor cells only
// iterate their own shadow lists + portal-visible neighbors, never
// outdoor cells' shadow lists. Closes the cottage cellar-up cap.
engine.ShadowObjects.GetNearbyObjects(
currPos, queryRadius,
worldOffsetX, worldOffsetY, landblockId,
nearbyObjs,
indoorCellIds);
indoorCellIds,
primaryCellId: sp.CheckCellId);
foreach (var obj in nearbyObjs)
{

View file

@ -481,37 +481,47 @@ public class CellarUpTrajectoryReplayTests
}
/// <summary>
/// First-cap event — the failing tick. Live engine reports cn=(0,0,-1),
/// a downward-facing collision normal, capping the foot sphere at
/// world Z=92.74. Math: head sphere TOP reaches Z=94.0 (the cottage
/// floor) when foot Z = 94.0 - sphereHeight = 92.80. The head bumps
/// the cottage floor from BELOW — NOT a step-up / AdjustOffset bug.
/// First-cap event — replays the live tick where the engine reported
/// the cottage-floor cap (cn=(0,0,-1) at world Z=92.74). This test
/// documents the issue #98 FIX (2026-05-24): with the indoor-primary-cell
/// gate on <see cref="ShadowObjectRegistry.GetNearbyObjects"/>'s outdoor
/// radial sweep, the cottage GfxObj is no longer returned to an indoor
/// (cellar) primary cell, so the head-sphere head-bump into the cottage
/// floor at world Z=94 does not fire.
///
/// <para>
/// Live capture's <c>[resolve]</c> probe pinpointed the blocking
/// entity: <c>obj=0xA9B47900</c> — a landblock-baked static building
/// (the cottage GfxObj <c>0x01000A2B</c>). The cottage's floor polys
/// live in this GfxObj as a ShadowEntry, NOT in any cottage cell.
/// Architectural anchor: retail's <c>CObjCell::find_cell_list</c> at
/// <c>acclient_2013_pseudo_c.txt:308751-308769</c> branches indoor/outdoor
/// on the registering object's m_position cell type — outdoor statics
/// like the landblock-baked cottage are added to OUTDOOR cells'
/// shadow_object_list only (via <c>add_all_outside_cells</c>), never to
/// indoor EnvCells. <c>CEnvCell::find_collisions</c> at 309560 only
/// iterates <c>this->shadow_object_list</c>, so indoor cells never test
/// against the cottage. Our fix mirrors this by gating the outdoor
/// radial sweep in <c>GetNearbyObjects</c> on the sphere's primary cell
/// type.
/// </para>
///
/// <para>
/// Apparatus-convergence form (2026-05-23 evening v2): with the
/// cottage GfxObj registered via <see cref="RegisterCottageGfxObj"/>,
/// the harness reproduces the live cn=(0,0,-1) cap event. This test
/// enforces THAT specific reproduction. The post-cap position
/// processing has a separate residual divergence — documented by
/// <see cref="LiveCompare_FirstCap_ResidualXMotionDivergence_DocumentsNextInvestigation"/>.
/// If this test starts failing because the cap reappears, the
/// <c>primaryCellId</c> wiring at <c>TransitionTypes.cs:2180</c> or the
/// gate at <c>ShadowObjectRegistry.cs:GetNearbyObjects</c> has
/// regressed. The harness still registers the cottage with the
/// production cellScope=0 (landblock-wide) shape, so the apparatus
/// itself proves the fix lives in the query path, not the registration
/// path.
/// </para>
/// </summary>
[Fact]
public void LiveCompare_FirstCap_HarnessReproducesCottageFloorCapNormal()
public void LiveCompare_FirstCap_FixClosesCottageFloorCap()
{
var (engine, _) = BuildEngineWithCellarFixtures();
var captured = LoadCapturedRecord(record =>
record.Result.CollisionNormalValid
&& record.Result.CollisionNormal.Z < -0.99f);
// Live must have cn=(0,0,-1) at this point — sanity check.
// Live must have cn=(0,0,-1) at this point — sanity check that the
// fixture still contains the bug-shape record we're replaying.
Assert.True(captured.Result.CollisionNormalValid,
"Captured record must have collisionNormalValid=true.");
Assert.True(captured.Result.CollisionNormal.Z < -0.99f,
@ -534,90 +544,18 @@ public class CellarUpTrajectoryReplayTests
moverFlags: (ObjectInfoState)captured.Input.MoverFlags,
movingEntityId: captured.Input.MovingEntityId);
// Apparatus convergence: harness reproduces the cap-event collision
// normal exactly. If this fails, the cottage GfxObj registration
// has regressed (RegisterCottageGfxObj is broken, the dump fixture
// is stale, or the harness wiring lost the landblock context).
Assert.True(harnessResult.CollisionNormalValid,
"Harness must reproduce the live collision-normal-valid signal " +
"now that the cottage GfxObj is registered.");
Assert.True(harnessResult.CollisionNormal.Z < -0.99f,
$"Harness must reproduce the live downward-facing cap normal " +
$"(live cn={captured.Result.CollisionNormal}, harness cn={harnessResult.CollisionNormal}).");
}
/// <summary>
/// A6.P3 issue #98 (2026-05-23 evening v2) — documents-the-bug for the
/// residual divergence the apparatus surfaced. With the cottage GfxObj
/// registered, the harness reproduces the live cap-event collision
/// normal, but the POST-CAP POSITION processing diverges:
/// <list type="bullet">
/// <item>Live: full +X motion preserved (sphere slides +0.0266 m in X
/// along the cottage floor before the Y motion is capped).</item>
/// <item>Harness: ZERO X motion (sphere stays at its input X position,
/// only the Y component is blocked).</item>
/// </list>
///
/// <para>
/// Both X positions agree on Y=7.2243 and Z=92.7390. Only X differs:
/// live X=141.3865 (requested target), harness X=141.3599 (current = no
/// move). The requested delta was (+0.0266, -0.4022, 0); live applied
/// the +X portion, harness applied nothing.
/// </para>
///
/// <para>
/// Hypothesis (to investigate next session): live's response to a
/// cn=(0,0,-1) head-bump treats it as a Z-only constraint and lets the
/// XY component of the move complete via edge-slide. Harness's BSP path
/// is rejecting the WHOLE move vector when the cottage floor poly
/// intersects the head sphere, instead of computing a slid offset.
/// </para>
///
/// <para>
/// Documents-the-bug: PASSES today on the asserted residual magnitude.
/// When a future session fixes the post-cap edge-slide, harness X will
/// match live X — this test FAILS at that point, signaling that the
/// X divergence is closed and the test should be folded back into the
/// strict <see cref="AssertCallMatchesCapture"/> path.
/// </para>
/// </summary>
[Fact]
public void LiveCompare_FirstCap_ResidualXMotionDivergence_DocumentsNextInvestigation()
{
var (engine, _) = BuildEngineWithCellarFixtures();
var captured = LoadCapturedRecord(record =>
record.Result.CollisionNormalValid
&& record.Result.CollisionNormal.Z < -0.99f);
Assert.NotNull(captured.BodyBefore);
var body = SeedBodyFromSnapshot(captured.BodyBefore);
var harnessResult = engine.ResolveWithTransition(
currentPos: captured.Input.CurrentPos,
targetPos: captured.Input.TargetPos,
cellId: captured.Input.CellId,
sphereRadius: captured.Input.SphereRadius,
sphereHeight: captured.Input.SphereHeight,
stepUpHeight: captured.Input.StepUpHeight,
stepDownHeight: captured.Input.StepDownHeight,
isOnGround: captured.Input.IsOnGround,
body: body,
moverFlags: (ObjectInfoState)captured.Input.MoverFlags,
movingEntityId: captured.Input.MovingEntityId);
// Live preserved the full +X motion through the cap event; harness
// blocked it. Y and Z agree.
Assert.Equal(captured.Result.Position.Y, harnessResult.Position.Y, 4);
Assert.Equal(captured.Result.Position.Z, harnessResult.Position.Z, 4);
float liveDeltaX = captured.Result.Position.X - captured.Input.CurrentPos.X;
float harnessDeltaX = harnessResult.Position.X - captured.Input.CurrentPos.X;
Assert.True(liveDeltaX > 0.02f,
$"Live must show +X motion after cap (expected ~+0.0266 m, got {liveDeltaX:F4}).");
Assert.True(MathF.Abs(harnessDeltaX) < 0.001f,
$"Harness currently zeros X motion through the cap (expected ~0, got {harnessDeltaX:F4}). " +
"If this assertion starts failing because harness now preserves +X, the post-cap " +
"edge-slide divergence is closed — fold this back into AssertCallMatchesCapture.");
// Issue #98 fix: the cottage-floor cap (cn.z near -1) must not fire
// from an indoor primary cell. The harness MAY still produce some
// other collision response (e.g. cn=(0,0,+1) from the ramp walkable
// surface, or a wall hit) — we explicitly assert ONLY that the
// downward-facing cottage-floor cap is gone.
Assert.False(
harnessResult.CollisionNormalValid
&& harnessResult.CollisionNormal.Z < -0.99f,
$"Issue #98 fix should prevent the downward-facing cottage-floor " +
$"cap. Harness produced cn={harnessResult.CollisionNormal} " +
$"(valid={harnessResult.CollisionNormalValid}). If z is back near " +
$"-1, the GetNearbyObjects indoor-primary gate has regressed.");
}
/// <summary>