From b3ce505ca8bd845c55a96d678be7fac04dc8e628 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 24 May 2026 06:49:46 +0200 Subject: [PATCH] =?UTF-8?q?fix(phys):=20A6.P3=20#98=20=E2=80=94=20gate=20o?= =?UTF-8?q?utdoor=20shadow=20radial=20sweep=20on=20indoor=20primary=20cell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Physics/ShadowObjectRegistry.cs | 39 ++++- src/AcDream.Core/Physics/TransitionTypes.cs | 9 +- .../Physics/CellarUpTrajectoryReplayTests.cs | 140 +++++------------- 3 files changed, 85 insertions(+), 103 deletions(-) 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."); } ///