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:
parent
bf6d97625c
commit
b3ce505ca8
3 changed files with 85 additions and 103 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue