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."); } ///