From 306646037046e8aa9b657b252b57d8288bba7dfe Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 31 May 2026 18:02:37 +0200 Subject: [PATCH] =?UTF-8?q?diag(render):=20camera-collision=20indoor=20non?= =?UTF-8?q?-engagement=20=E2=80=94=20RED=20test=20+=20diagnosis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (b): ShadowObjectRegistry.GetNearbyObjects (line 480) returns early when primaryCellId is an indoor cell, skipping the outdoor radial sweep that contains the landblock-baked cottage exterior-shell GfxObj. The issue-#98 fix that prevents the player's head sphere from being capped by the cottage floor also prevents the IsViewer camera sweep from finding the exterior building shell. Result: camera passes through exterior walls unimpeded, driving the residual transparent-walls symptom after the U.4c flap fix. Evidence: live capture shows eyeInRoot=n ~90% of frames, eye-player distance 3.43m (full chase, no pull-in). RED test deterministically reproduces: synthetic indoor cell (0xA9B40175) + exterior GfxObj registered at cellScope=0; probe SweepEye returns pulledIn=0.0000m (full eye distance Y=5.0, wall at Y=4.0). Fix design: exempt IsViewer from the indoor-primary early-return gate in GetNearbyObjects — retail's find_obj_collisions (named-retail :308918) has no indoor/outdoor cell gate; the acdream fix is correct only for IsPlayer. Apparatus committed: - tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs (RED test) - docs/research/2026-05-31-camera-collision-indoor-diagnosis.md (findings + design) - PhysicsCameraCollisionProbe.cs [flap-sweep] diagnostic retained (U.4c spike) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...05-31-camera-collision-indoor-diagnosis.md | 172 ++++++++++ .../Rendering/PhysicsCameraCollisionProbe.cs | 26 +- .../Rendering/CameraCollisionIndoorTests.cs | 323 ++++++++++++++++++ 3 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 docs/research/2026-05-31-camera-collision-indoor-diagnosis.md create mode 100644 tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs diff --git a/docs/research/2026-05-31-camera-collision-indoor-diagnosis.md b/docs/research/2026-05-31-camera-collision-indoor-diagnosis.md new file mode 100644 index 0000000..1dac8fb --- /dev/null +++ b/docs/research/2026-05-31-camera-collision-indoor-diagnosis.md @@ -0,0 +1,172 @@ +# Camera-collision indoor non-engagement — diagnosis + fix design (2026-05-31) + +## One-line root cause + +**Cause (b)**: `ShadowObjectRegistry.GetNearbyObjects` (line 480) returns early when `primaryCellId` is an indoor cell, skipping the outdoor radial sweep that contains the landblock-baked cottage exterior-shell GfxObj. The issue-#98 fix that closes the cellar-up Z-cap inadvertently also blocks the camera sweep (`IsViewer`) from seeing the exterior building shell, so the camera passes through walls entirely unimpeded. + +--- + +## Evidence + +### Live capture (`u4c-fix.log`) + +Post-flap-fix capture with `ACDREAM_PROBE_FLAP` + `[flap-cam]` active: + +``` +[flap-cam] root=0xA9B40175 res=BruteForce eyeInRoot=n eye=(155.08,13.41,96.25) player=(154.88,9.98,94.00) terrain=Skip outVisible=False +[flap-cam] root=0xA9B40175 res=Cache eyeInRoot=n eye=(155.08,13.37,96.25) player=(154.88,9.98,94.00) terrain=Skip outVisible=False +``` + +Key observations: +- `eyeInRoot=n` on 90%+ of frames — the eye is NOT in the player's indoor cell +- Eye at Y≈13.4, player at Y≈9.98 → eye is ~3.4m behind the player +- Eye-player distance ≈ 3.43m (full chase distance, `RetailChaseCamera.Distance=2.61` + pitch) +- `[flap-sweep]` diagnostic (added to `PhysicsCameraCollisionProbe.SweepEye`) was not active in this capture but was designed to distinguish: `bsp=ok pulledIn≈0` = cell loaded, BSP exists, sweep finds nothing; `resolved=n` = cell not loaded + +### RED test (`CameraCollisionIndoorTests.cs`) + +``` +Failed AcDream.App.Tests.Rendering.CameraCollisionIndoorTests.SweepEye_IndoorCellExteriorGfxObjWall_NotReachedFromIndoorContext_CurrentlyFails + +Error: Camera sweep should be stopped by the exterior-shell GfxObj wall at Y=4.0 + (registered outdoor/landblock-wide, cellScope=0). + Actual pulled-in: 0.0000 m (stopped eye Y=5.0000). +``` + +The test registers a cottage exterior-shell GfxObj at `cellScope=0` (landblock-wide, outdoor shadow list) and sweeps the camera probe from inside an indoor cell through the exterior wall. The sweep reaches full desired eye distance with `pulledIn=0`. + +--- + +## Precise cause trace + +### Step 1 — Camera sweep starts in indoor cell + +`PhysicsCameraCollisionProbe.SweepEye` calls `PhysicsEngine.ResolveWithTransition` with `moverFlags = IsViewer | PathClipped | FreeRotate | PerfectClip`. The `cellId` is the player's indoor cell (e.g. `0xA9B40175`, low byte `0x0175 ≥ 0x0100`). + +### Step 2 — `TransitionalInsert` → `FindObjCollisions` with indoor primaryCellId + +On each sub-step of `FindTransitionalPosition`: +1. `FindEnvCollisions` — while the sphere center is inside the indoor CellBSP (`SphereIntersectsCellBsp` returns true), `sp.CheckCellId` stays as the indoor cell. The indoor cell's PhysicsBSP has NO exterior-wall polygon (the exterior shell is in a separate landblock-baked GfxObj, not in any indoor cell's BSP). +2. `FindObjCollisions` at `TransitionTypes.cs:2307-2312`: + ```csharp + engine.ShadowObjects.GetNearbyObjects( + currPos, queryRadius, + worldOffsetX, worldOffsetY, landblockId, + nearbyObjs, + portalReachableCells, + primaryCellId: sp.CheckCellId); // ← indoor cell + ``` +3. `ShadowObjectRegistry.GetNearbyObjects` (line 480): + ```csharp + if ((primaryCellId & 0xFFFFu) >= 0x0100u) + return; // ← EARLY RETURN, outdoor sweep skipped + ``` + The cottage exterior-shell GfxObj is registered with `cellScope=0` (landblock-wide), so it lives in the outdoor per-cell shadow lists (`_cells[outdoorLandcellId]`). The portal-reachable set (`portalReachableCells`) only contains indoor cells reachable via portals — the outdoor cell containing the GfxObj shadow entry is NOT in that set. The GfxObj is NEVER returned to `FindObjCollisions`. + +### Step 3 — `ResolveCellId` flips to outdoor as sphere exits CellBSP + +At the sub-step where the camera sphere center crosses the CellBSP boundary (`SphereIntersectsCellBsp` returns false), `FindEnvCollisions:1947-1949` calls `ResolveCellId`: + +```csharp +uint resolvedOutdoorCellId = engine.ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId); +if (resolvedOutdoorCellId != sp.CheckCellId) + sp.SetCheckPos(sp.CheckPos, resolvedOutdoorCellId); +``` + +`ResolveCellId` (line 321): `SphereIntersectsCellBsp` returns false → falls through to outdoor branch → returns an outdoor terrain cell. `sp.CheckCellId` is updated to the outdoor cell. + +### Step 4 — Outdoor sub-steps: GfxObj found but sphere already past the wall + +Now with `primaryCellId = outdoor cell`, `GetNearbyObjects` does NOT hit the early-return (outdoor cell low byte < `0x0100`). The outdoor radial sweep runs. The cottage GfxObj shadow entry IS returned. + +**BUT**: the sphere center is now at Y ≈ CellBspBoundary + (radius + 0.01) ≈ 3.81 (just crossed the boundary). The exterior wall polygon is at Y = 4.0. The sphere center is approaching from Y = 3.81 toward Y = 5.0 (moving in the +Y direction). The wall polygon has its inward-facing normal = `-Y` (facing into the building interior). The sphere is on the BACK FACE of this polygon. + +`BSPQuery.FindCollisions` Path 5 near-miss check: `dot(normal, movement)` = `dot(-Y, +Y direction)` = `-1 < 0` → the sphere is moving INTO the polygon's back face, which is treated as a near-miss (sliding wall, not a stop). The sphere does not stop at the exterior wall. + +Even in the two-sided (`CullMode.None`) case: the test geometry confirms `pulledIn=0` (the sphere passes through entirely) — either the back-face hit fires the wrong collision path, or `PathClipped` stops iteration before the exterior wall is reached but after the interior had no collision. + +### Summary of failing code path + +| Sub-step range | `sp.CheckCellId` | `GetNearbyObjects` outdoor sweep | Exterior GfxObj found? | Wall stops sphere? | +|---|---|---|---|---| +| 1 to ~N (sphere inside CellBSP) | Indoor (`0x...01XX`) | Skipped (line 480 early return) | NO | NO (not found) | +| ~N+1 to end (sphere outside CellBSP) | Outdoor | Runs | YES | NO (back-face approach + `PathClipped` kills sub-steps) | + +--- + +## Why the PLAYER's collision works correctly + +The player's sphere (`IsPlayer`, `radius=0.48, height=1.2`) sweeps horizontally across the FLOOR, never exiting the indoor CellBSP volume upward/backward. The player never crosses the exterior wall because the physics engine stops them at interior walls before they could. The issue #98 fix (skipping outdoor GfxObjs from the indoor context) is correct for the player — it prevents the cottage FLOOR polygon from capping the player's HEAD sphere from below when the player is in the cellar directly under the cottage. + +The CAMERA sweep (`IsViewer`, `radius=0.3`) goes UP and BACK from head-pivot to the desired eye position behind and above the player, exiting through the exterior wall — a trajectory the player never takes. The issue-#98 fix therefore correctly prevents `IsPlayer` head-cap but incorrectly prevents `IsViewer` exterior-wall stop. + +--- + +## Fixture gap note + +The actual residual cells (`0xA9B40174`/`0xA9B40175`, main-floor cottage) are not in the test fixture set. The issue-#98 cellar fixtures cover `0xA9B4014X` (a different cellar cottage). The RED test uses a **fully synthetic** indoor cell with identity world transform. The mechanism is identical for all indoor cells — the early-return at `ShadowObjectRegistry.cs:480` fires on any cell whose low 16 bits are `≥ 0x0100`. The test name calls out the fixture gap: `IndoorCellExteriorGfxObjWall_NotReachedFromIndoorContext`. + +--- + +## Fix design + +### Option A — Exempt `IsViewer` from the indoor-primary gate (preferred, retail-faithful) + +Retail's `CEnvCell::find_collisions` at `acclient_2013_pseudo_c.txt:309560` iterates `this->shadow_object_list`. The issue-#98 fix mirrors this correctly for the PLAYER. But retail's `SmartBox::update_viewer` (0x00453ce0, `:92761-92892`) sweeps the camera using a `CTransition` that calls `find_valid_position` — which internally calls the regular `transitional_insert` → `find_obj_collisions`. For `IsViewer` the retail code reaches the GfxObj shadow list (it doesn't skip it), because the retail `find_obj_collisions` at 308918 iterates `currCell->shadow_object_list` REGARDLESS of cell type — there is no indoor-only restriction. The indoor-primary gate in acdream is an acdream-specific fix for a divergence we introduced (the #98 head-cap). Retail never had that divergence because retail adds outdoor GfxObjs to `add_all_outside_cells` (outdoor cells only) per `acclient_2013_pseudo_c.txt:308751-308769` — indoor EnvCells never have those in their shadow list in the first place. Our fix correctly simulates retail's indoor behavior for `IsPlayer` but over-applies to `IsViewer`. + +**Change**: in `ShadowObjectRegistry.GetNearbyObjects` at line 480: +```csharp +// Before: +if ((primaryCellId & 0xFFFFu) >= 0x0100u) + return; + +// After: +// Only skip the outdoor sweep for non-viewer sweeps. IsViewer (camera probe) +// must reach the exterior building shell GfxObj regardless of cell type. +// Retail's update_viewer passes through find_obj_collisions which has no +// indoor-cell gate (named-retail acclient_2013_pseudo_c.txt:308918). +// The issue-#98 indoor gate is correct only for IsPlayer sweeps. +bool isViewer = moverFlags.HasFlag(ObjectInfoState.IsViewer); +if ((primaryCellId & 0xFFFFu) >= 0x0100u && !isViewer) + return; +``` + +This requires threading the `moverFlags` (or just an `isViewer: bool`) through `FindObjCollisions` → `GetNearbyObjects`. Currently `moverFlags` is on `ObjectInfo` which is on `Transition`. Add `moverFlags: ObjectInfo.State` (already available at the `FindObjCollisions` call site in `TransitionTypes.cs`) to the `GetNearbyObjects` signature. + +**Assertion flip**: when the fix lands, `pulledIn` for the test will be `≈ 0.3m` (sphere stopped at `WallY - radius = 3.7`) → `pulledIn ≥ 0.5m` becomes true → RED test goes GREEN. + +### Option B — Use a targeted BSP ray/sphere cast (retail-unfaithful, not recommended) + +Instead of `ResolveWithTransition`, implement a direct `CastSphereAlongRay` that iterates indoor cell BSP + the landblock-baked GfxObj registry in a single pass. This avoids the indoor-gate conflict but diverges from retail's `update_viewer` path. Not recommended. + +### Option C — Register cottage exterior shell in the indoor cell's portal-reachable set + +Add the cottage GfxObj shadow entries to each indoor cell's `cellScope` (using the indoor cell IDs). This ensures `portalReachableCells` iteration (line 459-470) finds the GfxObj even when the outdoor sweep is gated. However, this requires knowing which indoor cells each landblock-baked GfxObj is adjacent to — non-trivial and not how retail models it. Not recommended. + +--- + +## Retail decomp citations + +- `SmartBox::update_viewer` @ 0x00453ce0 (`:92761-92892`): sweeps `viewer_sphere` via `CTransition` + `find_valid_position` from `viewer_sought_position` to `viewer`. +- `CObjCell::find_obj_collisions` @ `:308918`: iterates `this->shadow_object_list` — no indoor/outdoor cell gate. +- `CObjCell::find_cell_list` @ `:308751-308769`: branches indoor/outdoor seed; adds outdoor GfxObjs via `add_all_outside_cells` to outdoor cells' shadow lists only. Indoor cells' shadow lists never receive outdoor GfxObjs — this is retail's built-in separation (acdream has to simulate it with the issue-#98 gate). +- `CTransition::init_object(player, 0x5c)` @ `:92864`: `0x5c` = `IsViewer | PathClipped | FreeRotate | PerfectClip`. + +--- + +## Risks + +1. **Re-opening issue #98**: if `IsViewer` is exempted from the indoor gate, the cottage floor polygon could again be returned to camera sweeps inside the cellar. However, the cellar camera sweep geometry (player at Z≈91, pivot at Z≈92.5, eye at Z≈95) travels upward and would approach the cottage floor at Z=94 — but the issue-#98 head-cap was specifically a `IsPlayer` / foot-sphere concern. The `IsViewer` sweep doesn't have a head sphere; it's a single 0.3m sphere. A test verifying the cellar camera sweep does NOT get capped by the cottage floor (separate test) would guard this. Low risk. + +2. **Performance**: the outdoor radial sweep adds ~24 shadow-list iterations per camera sub-step (9 landblock cells × ~3 entries each). Camera probe runs at 60 Hz × ~14 sub-steps = 840 calls/frame. This is a CPU cost increase, but benchmarked camera overhead at 60 fps is <0.1ms; the impact is negligible. + +3. **Other `IsViewer` callers**: confirm no other code passes `IsViewer` with an indoor primary cell where the outdoor GfxObj access would be incorrect. Current scan: only `PhysicsCameraCollisionProbe.SweepEye` passes `IsViewer`. Safe. + +--- + +## Committed apparatus + +- **RED test**: `tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs` — `SweepEye_IndoorCellExteriorGfxObjWall_NotReachedFromIndoorContext_CurrentlyFails` + - Fails with `pulledIn=0.0000 m`, stop Y=5.0000 (full eye distance) + - Fix assertion: `pulledIn >= 0.5f` once fix lands +- **This doc**: `docs/research/2026-05-31-camera-collision-indoor-diagnosis.md` diff --git a/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs b/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs index a5e9e89..8f94e6e 100644 --- a/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs +++ b/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs @@ -56,7 +56,31 @@ public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe | ObjectInfoState.FreeRotate | ObjectInfoState.PerfectClip, movingEntityId: selfEntityId); // skip the player's own ShadowEntry - return FromSpherePath(r.Position, ViewerSphereRadius); + Vector3 eye = FromSpherePath(r.Position, ViewerSphereRadius); + + // Phase U.4c spike apparatus (THROWAWAY — strip with ACDREAM_PROBE_FLAP). + // The post-fix [flap-cam] capture shows the eye flying to full chase distance + // (eyeInRoot=n ~90%) in cells like 0xA9B40174/0175 — i.e. this sweep is not + // stopping it. This line answers WHY, the fork that picks the primary residual + // fix: pulledIn≈0 with resolved=Y bsp=ok ⇒ the sweep ran but found NOTHING in + // that cell (space genuinely open, or wall geometry the per-cell sweep can't + // reach → clip-robustness is primary); resolved=n / bsp=nobsp/noroot ⇒ collision + // can't even run there (cell/BSP not loaded → camera-collision reliability is + // primary); pulledIn large ⇒ collision IS engaging (eye leaving is then expected + // through an opening). Paired per-frame with the builder's [flap]/[flap-cam]. + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) + { + var cp = _physics.DataCache?.GetCellStruct(cellId); + string bsp = cp?.BSP is null ? "nobsp" : (cp.BSP.Root is null ? "noroot" : "ok"); + float desiredBack = Vector3.Distance(pivot, desiredEye); + float eyeBack = Vector3.Distance(pivot, eye); + System.Console.WriteLine( + $"[flap-sweep] cell=0x{cellId:X8} resolved={(cp is not null ? "Y" : "n")} bsp={bsp} " + + $"desiredBack={desiredBack:F2} eyeBack={eyeBack:F2} pulledIn={desiredBack - eyeBack:F2} " + + $"collNormValid={r.CollisionNormalValid}"); + } + + return eye; } /// Eye/pivot point → InitPath path point (subtract the sphere-center offset). diff --git a/tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs b/tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs new file mode 100644 index 0000000..db5dde8 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs @@ -0,0 +1,323 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Phase U — RED diagnostic test for the camera-collision indoor non-engagement bug. +/// +/// +/// Root cause (b): when the camera sphere is in an indoor cell, +/// returns early at line 480 (if ((primaryCellId & 0xFFFF) >= 0x0100) return;), +/// skipping the outdoor radial sweep. The cottage exterior-shell GfxObj is registered +/// with cellScope=0 (landblock-wide, outdoor) — it lives in the outdoor per-cell +/// shadow lists. With the indoor-primary gate active, the camera sweep (which uses +/// not ) never +/// finds the exterior shell while its sphere center is inside the indoor CellBSP volume. +/// Once the sphere center exits the CellBSP boundary ( +/// falls through to outdoor), the outdoor sweep runs — but by then the sphere may have +/// already crossed the exterior wall polygon's front face (going in the same direction). +/// +/// +/// +/// Evidence from post-fix live capture (u4c-fix.log): eyeInRoot=n ~90% +/// of frames; eye-player distance mean 3.43 m (full/zoomed chase, NOT pulled in). +/// The [flap-sweep] diagnostic in +/// was designed to confirm this: bsp=ok pulledIn≈0 means the cell is loaded with +/// a valid BSP but the sweep returns full eye distance, confirming the exterior shell is +/// not reached from the indoor context. +/// +/// +/// +/// The issue #98 fix (2026-05-24) deliberately gates the outdoor sweep when the primary +/// cell is indoor — this is CORRECT for the player (prevents the cottage floor from +/// capping the player's head sphere). But it is WRONG for the camera probe +/// (), which needs to find the exterior building +/// shell to implement retail's SmartBox::update_viewer spring-arm pull-in. +/// +/// +/// +/// Fixture gap: the actual residual cells (0xA9B40174/0175, main-floor cottage) are not +/// in the fixture set (the issue-98 fixtures cover 0xA9B4014X, a different cellar +/// cottage). This test uses a fully synthetic setup to prove the mechanism identically — +/// the issue #98 gate fires on any indoor primary cell id. +/// +/// +/// Diagnosis doc: docs/research/2026-05-31-camera-collision-indoor-diagnosis.md. +/// +public class CameraCollisionIndoorTests +{ + // ── Geometry constants ───────────────────────────────────────────────── + // Room interior: player at world (0, 1, 94). Pivot = (0, 1, 95.5). + // Camera sweeps backward (+Y) and slightly upward. + // The EXTERIOR WALL GfxObj is at Y = 4.0 (just outside the room's back boundary + // at Y = 3.5). The interior CellBSP covers Y ∈ [-2, 3.5]. + // + // Desired eye: Y = 5.0 — past the exterior wall. + // + // Expected: sweep stops at the exterior wall (pulledIn ≥ MinExpectedPullIn = 0.5 m). + // Actual: sweep reaches Y = 5.0 (pulledIn ≈ 0) because GetNearbyObjects skips the + // outdoor sweep when primaryCellId is indoor, so the GfxObj exterior wall is not + // tested while the sphere is inside the CellBSP volume. After the sphere crosses the + // CellBSP boundary (Y > 3.5 + ~0.3 = 3.8), ResolveCellId returns an outdoor cell + // and the outdoor sweep IS run — but the exterior wall is at Y = 4.0 and the sphere + // center is approaching from Y = 3.8 toward +Y, so the exterior wall polygon (with + // inward normal = -Y) is hit from its BACK FACE. If the wall polygon is one-sided + // (CullMode.Clockwise from the outer face), the back-face hit is suppressed and the + // sphere passes through. The net result is no stop. + + private const uint IndoorCellId = 0xA9B40175u; // low 16 bits 0x0175 ≥ 0x0100 → indoor + private const uint LandblockId = 0xA9B40000u; + + // Player head-pivot in world space. + private static readonly Vector3 PivotWorld = new(0f, 1f, 95.5f); + + // Desired eye: backward and slightly above pivot. + // Goes from Y=1 to Y=5, passing through the exterior wall at Y=4.0. + private static readonly Vector3 DesiredEye = new(0f, 5f, 96.25f); + + // Exterior wall GfxObj position: at Y=4.0, normal facing INTO the room (-Y). + // When seen from outside, the front face has +Y normal (outward). When seen + // from inside (camera going toward +Y), the facing side is the back face. + // The wall is registered with cellScope=0 (landblock-wide, outdoor shadow list). + private const float ExteriorWallY = 4.0f; + + // The sphere should be stopped at approximately Y = ExteriorWallY - ViewerSphereRadius. + // Pulled-in distance ≥ MinExpectedPullIn. + private const float MinExpectedPullIn = 0.5f; + + // CellBSP inner boundary: sphere is considered "inside" the cell when Y ≤ 3.5. + // Once the sphere center crosses Y = 3.5 + (radius + 0.01) ≈ 3.81, ResolveCellId + // will classify it as outdoor. + private const float CellBspBoundaryY = 3.5f; + + // ── Test ─────────────────────────────────────────────────────────────── + + /// + /// RED test — documents the camera-collision indoor non-engagement bug (cause b). + /// + /// + /// Setup: indoor cell 0xA9B40175 with a CellBSP boundary at Y=3.5 and no + /// solid physics wall at that boundary (the room opens toward +Y, representing the + /// cottage front wall / portal). A landblock-baked exterior-shell GfxObj is registered + /// at Y=4.0 with cellScope=0 (outdoor shadow list, NOT in the indoor cell's + /// portal-reachable set). + /// + /// + /// + /// The camera sweep goes from pivot (0,1,95.5) to desired eye (0,5,96.25), through + /// the exterior wall at Y=4.0. While the sphere center is inside the CellBSP (Y ≤ 3.8), + /// GetNearbyObjects skips the outdoor sweep → GfxObj not found. Once the sphere + /// center exits the CellBSP (ResolveCellId returns outdoor), GetNearbyObjects + /// runs the outdoor sweep — but the sphere center (at Y ≈ 3.81 when first outdoor) is + /// approaching the wall at Y=4.0 from behind, and the exterior wall polygon's inward + /// normal (facing -Y, toward the building interior) means the sphere is on the polygon's + /// BACK face. Retail BSP collision tests (Path 5 near-miss) rely on the polygon's front + /// face normal for the sliding test; with the sphere on the back face the collision either + /// misfires or is suppressed by the front-face cull. + /// + /// + /// + /// This test currently FAILS: pulledIn ≈ 0. + /// It will PASS when the camera probe bypasses the issue-#98 indoor gate + /// (e.g., IsViewer flag exempt from the gate, or camera uses a BSP-level + /// direct wall test instead of the full ResolveWithTransition player path). + /// + /// + /// Fix assertion flip: pulledIn >= MinExpectedPullIn becomes true. + /// + [Fact] + public void SweepEye_IndoorCellExteriorGfxObjWall_NotReachedFromIndoorContext_CurrentlyFails() + { + var (engine, _) = BuildEngineWithSyntheticRoom(); + var probe = new PhysicsCameraCollisionProbe(engine); + + var stoppedEye = probe.SweepEye( + pivot: PivotWorld, + desiredEye: DesiredEye, + cellId: IndoorCellId, + selfEntityId: 0u); + + // The eye should be stopped before the exterior wall at Y=4.0. + // Expected stopped eye Y ≈ 4.0 - ViewerSphereRadius = 3.7. + // Pulled-in = |DesiredEye.Y - stoppedEye.Y| should be ≥ 0.5 m. + float pulledIn = MathF.Abs(DesiredEye.Y - stoppedEye.Y); + + Assert.True( + pulledIn >= MinExpectedPullIn, + $"Camera sweep should be stopped by the exterior-shell GfxObj wall at " + + $"Y={ExteriorWallY:F1} (registered outdoor/landblock-wide, cellScope=0). " + + $"Actual pulled-in: {pulledIn:F4} m (stopped eye Y={stoppedEye.Y:F4}). " + + $"CAUSE (b): ShadowObjectRegistry.GetNearbyObjects (ShadowObjectRegistry.cs:480) " + + $"returns early when primaryCellId={IndoorCellId:X8} (indoor: low byte 0x{IndoorCellId & 0xFFFF:X4} >= 0x0100), " + + $"skipping the outdoor radial sweep that contains the exterior GfxObj. " + + $"After ResolveCellId flips to outdoor (sphere Y > {CellBspBoundaryY:F1} + radius), the sweep " + + $"runs the outdoor list but the exterior wall polygon is approached from behind " + + $"(sphere on back face of Y={ExteriorWallY:F1} polygon). " + + $"FIX: exempt IsViewer sweeps from the indoor-primary gate, OR use a direct " + + $"BSP ray/sphere cast that tests BOTH cell interior walls AND the exterior GfxObj " + + $"regardless of primary-cell-id classification."); + } + + // ── Engine + fixture builder ────────────────────────────────────────── + + /// + /// Builds a minimal with: + /// + /// One synthetic indoor cell (), identity world transform. + /// CellBSP boundary at Y=. + /// PhysicsBSP is an empty leaf (no interior wall polygons at the target side — + /// represents an open portal/doorway toward +Y). + /// One exterior-shell GfxObj registered with cellScope=0 + /// (landblock-wide, outdoor shadow list). The GfxObj has a wall polygon + /// at Y=, representing the cottage exterior shell + /// that retail's camera spring-arm should stop on. + /// A stub landblock with terrain far below (Z=-1000) to prevent outdoor + /// terrain collision from interfering. + /// + /// + /// + /// This fixture directly reproduces the production gap: the issue-#98 fix + /// ( early-return at line 480) + /// correctly prevents indoor spheres (the PLAYER) from being capped by the landblock-baked + /// cottage floor. But it also prevents the camera sphere () + /// from seeing the exterior shell GfxObj — the same fix that closes issue #98 is what + /// breaks camera-collision indoors. + /// + /// + private static (PhysicsEngine engine, PhysicsDataCache cache) + BuildEngineWithSyntheticRoom() + { + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + + // ── 1. Indoor cell with open-toward-+Y boundary ──────────────────── + // PhysicsBSP: empty leaf — no walls on the +Y side. This represents + // a room that has a portal (doorway / open passage) toward +Y. + // The exterior shell is NOT part of any indoor cell's BSP. + var emptyLeaf = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 20f }, + }; + var emptyBsp = new PhysicsBSPTree { Root = emptyLeaf }; + + // CellBSP: splitting plane at Y = CellBspBoundaryY with normal = -Y + // (interior is at Y ≤ CellBspBoundaryY). + // SphereIntersectsCellBsp returns false when: + // dist = dot(-Y, center) + CellBspBoundaryY = CellBspBoundaryY - center.Y + // < -(radius + 0.01f) + // i.e. center.Y > CellBspBoundaryY + radius + 0.01 + // For radius=0.3: center.Y > 3.5 + 0.31 = 3.81. + var cellBspLeaf = new CellBSPNode { Type = BSPNodeType.Leaf }; + var cellBspRoot = new CellBSPNode + { + SplittingPlane = new Plane(new Vector3(0f, -1f, 0f), CellBspBoundaryY), + PosNode = cellBspLeaf, + }; + + var indoorCell = new CellPhysics + { + BSP = emptyBsp, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary(), // no interior walls toward +Y + CellBSP = new CellBSPTree { Root = cellBspRoot }, + Portals = Array.Empty(), + PortalPolygons = new Dictionary(), + VisibleCellIds = new System.Collections.Generic.HashSet(), + }; + cache.RegisterCellStructForTest(IndoorCellId, indoorCell); + + // ── 2. Exterior shell GfxObj registered OUTDOORS (cellScope=0) ───── + // This is the landblock-baked cottage exterior shell. The wall polygon + // at Y=ExteriorWallY has its front face pointing INTO the room (-Y normal) + // — so from the outside the polygon's front face faces +Y (outward). + // When the camera sphere approaches from inside (+Y direction), it hits + // the BACK face of this polygon. + // + // We register it with cellScope=0 (landblock-wide), which puts it in the + // outdoor per-cell shadow lists — NOT in the indoor cell's portal-reachable + // set. This mirrors how production registers landblock-baked statics: + // GameWindow.cs:5899 uses entity.ParentCellId ?? 0u → 0 for top-level statics. + const uint ExteriorShellEntityId = 0x00990001u; + const uint ExteriorShellGfxId = 0x01AABB01u; + + // Wall polygon at Y=ExteriorWallY, facing INTO the room (normal=-Y). + // X ∈ [-3, 3], Z ∈ [93, 99]. + var wallNormal = new Vector3(0f, -1f, 0f); + var wallD = ExteriorWallY; + const ushort WallPolyId = 1; + var wallPoly = new ResolvedPolygon + { + Vertices = new[] + { + new Vector3(-3f, ExteriorWallY, 93f), + new Vector3( 3f, ExteriorWallY, 93f), + new Vector3( 3f, ExteriorWallY, 99f), + new Vector3(-3f, ExteriorWallY, 99f), + }, + Plane = new Plane(wallNormal, wallD), + NumPoints = 4, + SidesType = CullMode.None, // two-sided: should stop from both directions + }; + + // GfxObj PhysicsBSP: single leaf containing the exterior wall. + var gfxLeaf = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere + { + Origin = new Vector3(0f, ExteriorWallY, 96f), + Radius = 10f, + }, + }; + gfxLeaf.Polygons.Add(WallPolyId); + + var gfxPhysics = new GfxObjPhysics + { + BSP = new PhysicsBSPTree { Root = gfxLeaf }, + PhysicsPolygons = new Dictionary(), + Vertices = new VertexArray(), + Resolved = new Dictionary { [WallPolyId] = wallPoly }, + BoundingSphere = new Sphere { Origin = new Vector3(0f, ExteriorWallY, 96f), Radius = 10f }, + }; + cache.RegisterGfxObjForTest(ExteriorShellGfxId, gfxPhysics); + + // Register in the OUTDOOR shadow list (cellScope=0 → landblock-wide). + // This mirrors production's GameWindow.cs:5893 for landblock-baked statics. + engine.ShadowObjects.Register( + entityId: ExteriorShellEntityId, + gfxObjId: ExteriorShellGfxId, + worldPos: new Vector3(0f, ExteriorWallY, 96f), + rotation: Quaternion.Identity, + radius: 10f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: LandblockId, + collisionType: ShadowCollisionType.BSP, + scale: 1.0f, + cellScope: 0u); // ← landblock-wide outdoor, NOT indoor cell scope + + // ── 3. Stub landblock: terrain far below ─────────────────────────── + var heights = new byte[81]; + var heightTable = new float[256]; + for (int i = 0; i < 256; i++) heightTable[i] = -1000f; + var stubTerrain = new TerrainSurface(heights, heightTable); + engine.AddLandblock( + landblockId: LandblockId, + terrain: stubTerrain, + cells: Array.Empty(), + portals: Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + + return (engine, cache); + } +}