diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
index b574591..8a40f2f 100644
--- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
+++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
@@ -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.
///
+ ///
+ ///
+ /// Issue #98 (2026-05-24): the optional
+ /// parameter gates the outdoor radial sweep on the SPHERE's primary cell
+ /// type. Mirrors retail's CObjCell::find_cell_list at
+ /// acclient_2013_pseudo_c.txt:308751-308769: 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 CEnvCell::find_collisions only iterating
+ /// this->shadow_object_list, retail's indoor cells never test
+ /// against outdoor statics like landblock-baked buildings.
+ ///
+ ///
+ ///
+ /// 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 cellScope=0);
+ /// 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 primaryCellId=0 preserves
+ /// the pre-fix radial-only behavior for callers that don't know /
+ /// care about cell type (existing tests).
+ ///
///
public void GetNearbyObjects(Vector3 worldPos, float queryRadius,
float worldOffsetX, float worldOffsetY, uint landblockId,
List results,
- System.Collections.Generic.IReadOnlyCollection? indoorCellIds = null)
+ System.Collections.Generic.IReadOnlyCollection? indoorCellIds = null,
+ uint primaryCellId = 0u)
{
results.Clear();
var seen = new HashSet();
@@ -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);
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index 7d5b2b2..ddfad64 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -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)
{
diff --git a/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs b/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
index c8519c4..a409bb1 100644
--- a/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
@@ -481,37 +481,47 @@ public class CellarUpTrajectoryReplayTests
}
///
- /// 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 '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.
///
///
- /// Live capture's [resolve] probe pinpointed the blocking
- /// entity: obj=0xA9B47900 — a landblock-baked static building
- /// (the cottage GfxObj 0x01000A2B). The cottage's floor polys
- /// live in this GfxObj as a ShadowEntry, NOT in any cottage cell.
+ /// Architectural anchor: retail's CObjCell::find_cell_list at
+ /// acclient_2013_pseudo_c.txt:308751-308769 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 add_all_outside_cells), never to
+ /// indoor EnvCells. CEnvCell::find_collisions at 309560 only
+ /// iterates this->shadow_object_list, so indoor cells never test
+ /// against the cottage. Our fix mirrors this by gating the outdoor
+ /// radial sweep in GetNearbyObjects on the sphere's primary cell
+ /// type.
///
///
///
- /// Apparatus-convergence form (2026-05-23 evening v2): with the
- /// cottage GfxObj registered via ,
- /// 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
- /// .
+ /// If this test starts failing because the cap reappears, the
+ /// primaryCellId wiring at TransitionTypes.cs:2180 or the
+ /// gate at ShadowObjectRegistry.cs:GetNearbyObjects 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.
///
///
[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}).");
- }
-
- ///
- /// 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:
- ///
- /// - Live: full +X motion preserved (sphere slides +0.0266 m in X
- /// along the cottage floor before the Y motion is capped).
- /// - Harness: ZERO X motion (sphere stays at its input X position,
- /// only the Y component is blocked).
- ///
- ///
- ///
- /// 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.
- ///
- ///
- ///
- /// 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.
- ///
- ///
- ///
- /// 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 path.
- ///
- ///
- [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.");
}
///