From aa96d7ad77f8199ee85695aee2c31a5d48022c9b Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 5 Jul 2026 19:57:11 +0200 Subject: [PATCH] =?UTF-8?q?fix=20#137=20(window=20climb):=20the=20player's?= =?UTF-8?q?=20collision=20capsule=20topped=20out=20at=201.2m=20=E2=80=94?= =?UTF-8?q?=20head=20sphere=200.63m=20too=20low?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'run into the last corridor window and pop up through its roof' report: the live callers passed sphereHeight: 1.2f into SpherePath.InitPath, whose head-sphere formula (height - radius) put the head sphere center at 0.72 - the capsule top at 1.2m. The top 0.63m of a 1.83m character had NO collision, so at the corridor-end window alcove (0x8A020179 -> 0x8A02017E: 0.70m sill face, 1.3m opening, sloped funnel behind) the step-up's placement never saw the head overlapping the lintel solids and let the player climb in head-through-roof. Dat truth (HumanSetup_CollisionSpheres_DatTruth): Setup 0x02000001 spheres = (0,0,0.475) r=0.48 and (0,0,1.350) r=0.48 - capsule top 1.83 = Setup.Height 1.835. Retail collides with that sphere list verbatim (CPhysicsObj::transition 0x00512dc0 -> init_sphere(GetNumSphere, GetSphere, m_scale)). Fix: PlayerMovementController + the GameWindow remote resolve now pass sphereHeight: 1.835f (capsule top; head center 1.355 vs dat 1.350). InitPath unchanged - captured-input replay fixtures (recorded 1.2 inputs) stay byte-identical. Register TS-46: the (radius, capsule-top) scalar approximation of the Setup sphere list (5mm foot/head offsets; remotes use human dims) with the retire path (plumb the sphere list). Pins: WindowOpening_HeadCannotFit_EntryBlocked (22-frame walked approach wall-slides at the sill, never enters 0x8A02017E) + WindowAlcove_RaisedPlacement_HeadInLintelSolid_Collides (Path-1 placement rejects the raised head in the lintel solids) + the WindowShaft_FullPolyDump / HumanSetup dat inspections. Suites: Core 2562 / App 713 / UI 425 / Net 385, 0 failures. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 29 +++- .../retail-divergence-register.md | 1 + .../Input/PlayerMovementController.cs | 13 +- src/AcDream.App/Rendering/GameWindow.cs | 6 +- .../Issue137CorridorSeamInspectionTests.cs | 90 ++++++++++ .../Issue137CorridorSeamReplayTests.cs | 159 ++++++++++++++++++ 6 files changed, 285 insertions(+), 13 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index b13e0fa9..c9091b1b 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -1245,15 +1245,26 @@ matching the door's open/closed state.) **✅ CORRIDOR GATE PASSED 2026-07-06 evening (user: "not collision anymore. Good.")** — the corridor phantom arc (mechanisms 1–3) is user-verified FIXED. REMAINING #137 scope from the same gate session: -- **Window/opening climb (NEW repro, this is #137's "openings in walls" - half):** running into the last corridor window, the player STEPS UP into - the opening — head (and camera) pop through the opening's roof. Retail - blocks this. Log anchor (`launch-137-gate2.log`): transit - `0x8A020179 → 0x8A02017E at (90.209,-41.774,-5.209)` and back — the - south-wall opening at y≈−41.7, sill ≈0.8 m (world z −5.2) — climbed - despite stepUpHeight 0.6. Investigate the step-up acceptance (sill - height vs budget; suspect the step-up's walkable probe accepting the - sill top or the opening's floor from the neighbor cell). +- **Window/opening climb FIXED same day (pending gate): the player's + collision capsule TOPPED OUT AT 1.2 m.** The callers passed + `sphereHeight: 1.2f` and `InitPath` places the head sphere center at + `height − radius` = 0.72 — the top 0.63 m of a 1.83 m character had NO + collision. The dat human Setup 0x02000001 (dumped in + `HumanSetup_CollisionSpheres_DatTruth`): spheres `(0,0,0.475) r=0.48` + + `(0,0,1.350) r=0.48` (top 1.83 = Setup.Height 1.835); retail collides + with that list verbatim (`CPhysicsObj::transition` 0x00512dc0 → + `init_sphere(GetNumSphere, GetSphere, scale)`). At the corridor-end + window alcove (0x8A020179 → 0x8A02017E: sill face 0.70 m, opening 1.3 m + tall, sloped funnel behind — full-vertex dump in + `WindowShaft_FullPolyDump`), the missing head let the step-up's + placement pass and the player climbed in head-through-lintel. Fix: both + live callers now pass 1.835 (capsule top; head center 1.355 ≈ dat + 1.350); register TS-46 documents the residual 5 mm scalar + approximation. Pins: `WindowOpening_HeadCannotFit_EntryBlocked` (walked + approach wall-slides and never enters 0x8A02017E) + + `WindowAlcove_RaisedPlacement_HeadInLintelSolid_Collides` (the raised + placement rejects on the head-vs-lintel). Captured-input replay + fixtures keep their recorded 1.2 inputs — InitPath unchanged. - Doors half (block/pass per open state) — unchanged. Two RENDER issues also observed at the gate (filed separately below as #176/#177): the purple floor flashing at seams is angle/camera-dependent diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 2cadd2bd..5f91120d 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -225,6 +225,7 @@ accepted-divergence entries (#96, #49, #50). | TS-41 | Grounded remote NPCs WITHOUT an armed moveto are body-driven by UP-synthesized server velocity (`HasServerVelocity` → the SERVERVEL per-tick leg: `Body.Velocity = ServerVelocity`, `MovementManager.UseTime` (the R5-V5 facade relay, ex-loose `MoveToManager.UseTime`) SKIPPED, stale-decay stop via `ApplyServerControlledVelocityCycle(Zero)`); retail has no wire-velocity leg-driver anywhere — `MovementManager::UseTime` runs UNCONDITIONALLY per tick and between-UP translation comes from the motion state (`get_state_velocity`), UPs only hard-snap. **#170 residual fix narrowed this branch: an ARMED moveto (`MovementTypeState != Invalid`) now always takes the MOVETO leg** — the old arbitration starved the verbatim MoveToManager for exactly the duration of a server-side chase (UPs flowing → UseTime never ran → legs stayed Ready while the body glided = the #170 slide; live funnel 16 arms → 1 run install). **R5-V3 (#171) narrowed it again: a STUCK entity (`PositionManager.GetStickyObjectId() != 0`) also takes the MOVETO leg** — after the sticky arrival the moveto is cleaned (Invalid) but `StickyManager::adjust_offset` owns the between-snap translation; SERVERVEL would glide the body against the sticky steer (same starvation class) | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch, `moveToArmed`+`stickyArmed` gate) | ACE moves some entities by position updates alone (scripted paths, missiles) with no UM/moveto stream — without a velocity fallback they freeze between UPs; entities WITH a moveto now get the retail drive | An entity class that carries BOTH wire velocity and an armed moveto with conflicting truths follows the moveto; UP hard-snaps bound the drift. Non-moveto entities keep the non-retail stale-stop heuristics (AP-80 thresholds) | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` call @0x00515998, unconditional); retire in R6 (full per-tick order) | | TS-42 | Per-tick DRAIN ORDER inverted vs retail: acdream's `TickAnimations` runs `HandleTargetting` → `Movement.UseTime` (the R5-V5 MovementManager relay, ex-loose `MoveTo.UseTime`) FIRST and the animation-completion drain (Sequencer.Advance → AnimDone hooks → `MotionTableManager.AnimationDone`/`UseTime` → `CMotionInterp.MotionDone` pops) LAST, so every motion-completion-gated decision (`BeginTurnToHeading`'s `motions_pending` wait) sees a queue that is one frame STALE — the unblock after a stop/swing lands one frame later than retail. Retail order (pinned from the named decomp this session): `UpdatePositionInternal` (CPartArray::Update + `process_hooks` @0x00512d3d — the drain) runs BEFORE `TargetManager::HandleTargetting` @0x00515989 → `MovementManager::UseTime` @0x00515998 → `CPartArray::HandleMovement` @0x005159a4 (zero-tick sweep) in `UpdateObjectInternal` | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` per-entity phase order; the R2-Q4 comment already marks the placement "provisional until R6") | Bounded to exactly ONE frame (~16 ms) of extra latency per completion-gated event; every queue eventually drains identically (RemoteChaseEndToEndHarnessTests conformance) | Motion-completion-gated transitions (chase turn start, post-swing re-arm) systematically lag retail by one frame; under compound churn the lag can cost an extra retry cycle | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 + `UpdatePositionInternal` 0x00512c30 (`process_hooks` @0x00512d3d); retire in R6 (retail UpdateObjectInternal order) | | TS-44 | NPC UpdatePosition hard-snaps (position @`OnLivePositionUpdated` + orientation + velocity/cycle adoption) are SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`) — an adaptation of retail's chain semantics to the legacy snap path: retail routes UP corrections through the InterpolationManager into the SAME per-tick `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES them while armed (0x00555190 order, 0x00555430 assigns m_fOrigin), so a server correction can never fight an armed stick; the legacy NPC path snaps OUTSIDE the chain, producing snap-out/steer-back position flapping + stale-facing stomps (the 2026-07-04 #171 gate residuals). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick, bounded by the 1 s sticky lease | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's mechanism (sticky-overwrites-interp) is unreachable until the NPC path gets the interp-queue architecture (the player-remote branch already has it — the R5-V3 combiner→sticky chain); the gate reproduces the retail-observable behavior on the snap architecture | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target by construction) would drift until unstick+next UP; worst case bounded by the 1 s lease + the next UM re-arm | `PositionManager::adjust_offset` 0x00555190; `InterpolationManager` UP routing (`CPhysicsObj::MoveOrTeleport`); retire when the NPC path unifies onto the interp queue (S6/R6) | +| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001 spheres `(0,0,0.475) r=0.48`, `(0,0,1.350) r=0.48`). Remotes also use the hardcoded HUMAN dims regardless of creature Setup/scale. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — 0.63 m of headless character; the #137 window climb. Fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`), `src/AcDream.App/Input/PlayerMovementController.cs` + `src/AcDream.App/Rendering/GameWindow.cs` (the two `sphereHeight:` call sites) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold for the human capsule and keeps every captured-input replay fixture byte-identical | 5 mm offsets can flip marginal grazes near the r−ε/r+ε knife edges (today's seam class); creature-scale remotes collide with human-sized capsules until setup-derived dims are plumbed | `CPhysicsObj::transition` 0x00512dc0; dat Setup 0x02000001; retire by plumbing the Setup sphere list into `InitPath` | | TS-45 | `SphereCollision` (the shadow-object Sphere response) is a hand-rolled 3-D wall-slide that ALSO calls `SetSlidingNormal` — retail's `CSphere::intersects_sphere` (0x00537A80) dispatches `CSphere::slide_sphere` (0x00537440), which slides in-frame and never writes `collision_info.sliding_normal` (the only in-transition writer is `validate_transition` 0x0050ac21). Same leak class as the #137 mechanism-2 stubs fixed 2026-07-06 (BSPQuery Contact branch); left in place because the response's blocking semantics for sphere-shaped server objects are untested against the real slide and #171 sticky-melee behavior is freshly gated | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollision`, the `ci.SetSlidingNormal` tail) | The in-frame push-out already moves the check position; the extra sliding normal only persists on transition success, and pure-Sphere shadow shapes are rare (most creatures/statics are CylSphere, which routes through the real `SlideSphere` since #172) | A sphere-shaped object touch persists a normal retail would discard — an exactly-anti-parallel follow-up push absorbs to a zero offset (#137 wedge class) at that object until an oblique input clears it | `CSphere::intersects_sphere` 0x00537A80 → `slide_sphere` 0x00537440 (pc:321678+); fix = route the tail through `Transition.SlideSphere` like `CylSlideSphere` does | | TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition` → `PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — no teleport-flag manager teardown for remotes) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second; wiring it properly wants the UP teleport-stamp plumbing (TS-26's stamp work) | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; retire with the TS-26 UP-stamp port | diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 8f07011e..60b38412 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -882,8 +882,17 @@ public sealed class PlayerMovementController // Falls back to simple Z-snap if transition fails. var resolveResult = _physics.ResolveWithTransition( preIntegratePos, postIntegratePos, CellId, - sphereRadius: 0.48f, // human player radius from Setup - sphereHeight: 1.2f, // human player height from Setup + sphereRadius: 0.48f, // human Setup 0x02000001 sphere radius (dat: 0.480) + // #137 window climb (2026-07-06): sphereHeight is the CAPSULE TOP + // (InitPath places the head sphere center at height − radius). The + // dat human Setup 0x02000001 has Spheres[1].Origin.Z = 1.350 + // (top 1.830) and Height = 1.835 — retail collides with that + // sphere list verbatim (CPhysicsObj::transition 0x00512dc0 → + // init_sphere(GetNumSphere, GetSphere, scale)). The old 1.2f put + // the head sphere center at 0.72 — the top 0.63 m of the + // character had NO collision, letting the player climb into a + // 1.3 m window alcove head-through-lintel. Register TS-46. + sphereHeight: 1.835f, stepUpHeight: StepUpHeight, stepDownHeight: StepDownHeight, // L.2.3a: from Setup.StepDownHeight isOnGround: _body.OnWalkable, diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 8c446c7d..f078741a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -10476,14 +10476,16 @@ public sealed class GameWindow : IDisposable if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0) { // Sphere dims match local-player defaults (human Setup - // bounds — ~0.48m radius, ~1.2m height). Good enough for + // 0x02000001: sphere radius 0.480, capsule top 1.835 = + // Setup.Height; see the #137 TS-46 note at the + // PlayerMovementController call). Good enough for // grounded humanoid remotes; can be setup-derived later // if creatures of wildly different sizes need different // collision profiles. var resolveResult = _physicsEngine.ResolveWithTransition( preIntegratePos, postIntegratePos, rm.CellId, sphereRadius: 0.48f, - sphereHeight: 1.2f, + sphereHeight: 1.835f, stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f // K-fix9 (2026-04-26): mirror the K-fix7 gate — diff --git a/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamInspectionTests.cs b/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamInspectionTests.cs index 83a74e6e..f901d4ca 100644 --- a/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamInspectionTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamInspectionTests.cs @@ -29,6 +29,8 @@ public class Issue137CorridorSeamInspectionTests [InlineData(0x8A02016Eu)] [InlineData(0x8A02017Au)] [InlineData(0x8A02011Eu)] // the under-floor room the corridor's floor-portals lead to + [InlineData(0x8A020179u)] // the ramp corridor cell with the window (the #137 window-climb repro) + [InlineData(0x8A02017Eu)] // the cell beyond the window the player climbed into public void CorridorCell_PhysicsPolysAndPortals_DatInspection(uint envCellId) { var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") @@ -177,6 +179,94 @@ public class Issue137CorridorSeamInspectionTests } } + /// + /// #137 window climb: the dat truth for the player's collision spheres. + /// Our InitPath places the head sphere at (sphereHeight − radius) = 0.72 + /// (capsule top 1.2 m); retail collides with the Setup's SPHERE LIST + /// verbatim (CPhysicsObj::transition → init_sphere(GetNumSphere, + /// GetSphere, scale)). Print human Setup 0x02000001's spheres. + /// + [Fact] + public void HumanSetup_CollisionSpheres_DatTruth() + { + var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + if (!Directory.Exists(datDir)) + { + _out.WriteLine($"SKIP: dat directory not found at {datDir}"); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var setup = dats.Get(0x02000001u); + Assert.NotNull(setup); + + _out.WriteLine($"Setup 0x02000001: Height={setup!.Height:F3} Radius={setup.Radius:F3} " + + $"StepUp={setup.StepUpHeight:F3} StepDown={setup.StepDownHeight:F3}"); + _out.WriteLine($"Spheres ({setup.Spheres.Count}):"); + foreach (var s in setup.Spheres) + _out.WriteLine($" origin=({s.Origin.X:F3},{s.Origin.Y:F3},{s.Origin.Z:F3}) r={s.Radius:F3}"); + _out.WriteLine($"CylSpheres ({setup.CylSpheres.Count}):"); + foreach (var c in setup.CylSpheres) + _out.WriteLine($" origin=({c.Origin.X:F3},{c.Origin.Y:F3},{c.Origin.Z:F3}) r={c.Radius:F3} h={c.Height:F3}"); + } + + /// + /// #137 window-climb geometry (2026-07-06): full world-space vertex dump + /// of the shaft cell 0x8A02017E (all physics polys) and 0x8A020179's + /// south-wall family — the opening's lintel/ceiling spans decide where + /// retail blocks the head. + /// + [Fact] + public void WindowShaft_FullPolyDump() + { + var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + if (!Directory.Exists(datDir)) + { + _out.WriteLine($"SKIP: dat directory not found at {datDir}"); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + + foreach (var cellId in new[] { 0x8A02017Eu, 0x8A020179u }) + { + var envCell = dats.Get(cellId); + Assert.NotNull(envCell); + var environment = dats.Get(0x0D000000u | envCell!.EnvironmentId); + Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs)); + + var rot = new System.Numerics.Quaternion( + envCell.Position.Orientation.X, envCell.Position.Orientation.Y, + envCell.Position.Orientation.Z, envCell.Position.Orientation.W); + var world = System.Numerics.Matrix4x4.CreateFromQuaternion(rot) + * System.Numerics.Matrix4x4.CreateTranslation( + envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z); + + _out.WriteLine($"=== 0x{cellId:X8} full physics polys (world verts) ==="); + foreach (var (id, poly) in cs!.PhysicsPolygons) + { + var verts = poly.VertexIds; + if (verts.Count < 3) continue; + var w = new System.Collections.Generic.List(); + foreach (var vid in verts) + if (cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var v)) + w.Add(System.Numerics.Vector3.Transform(v.Origin, world)); + var n = System.Numerics.Vector3.Normalize( + System.Numerics.Vector3.Cross(w[1] - w[0], w[2] - w[0])); + + // 017E: everything. 0179: south-wall family + ceilings only. + if (cellId == 0x8A020179u && MathF.Abs(n.Y) < 0.3f && n.Z > -0.3f) continue; + + var vs = string.Join(" ", w.ConvertAll(p => $"({p.X:F2},{p.Y:F2},{p.Z:F2})")); + _out.WriteLine($" poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) verts={vs}"); + } + } + } + /// /// Mechanism-1 re-characterization (2026-07-06): the live hit normal /// (−1.00, 0.03, −0.03) at world (85.253, −39.776, −5.992) matches NO diff --git a/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamReplayTests.cs b/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamReplayTests.cs index ae39eeba..3724db77 100644 --- a/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamReplayTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamReplayTests.cs @@ -246,6 +246,165 @@ public class Issue137CorridorSeamReplayTests $"advance with the reversed-movement normal = the seam shake."); } + /// + /// #137 window-climb repro (2026-07-06 gate 2, launch-137-gate2.log): + /// running from the ramp top in 0x8A020179 into the corridor-end opening + /// (the portal to the 0x8A02017E shaft, wall plane world y=−41.67), the + /// player stepped INTO the niche — `in=(89.531,−41.506,−5.112) → + /// out=(90.209,−41.774,−5.209) cell=0x8A02017E` — ending with the head + /// (and camera) through the opening's roof. The opening is ~1.3 m tall + /// (z −5.2..−3.9); a 1.68 m character cannot fit — retail blocks entry + /// (the raised probe's HEAD sphere hits the lintel/ceiling). User axiom: + /// "should not be able to run into it". + /// + [Fact] + public void WindowOpening_HeadCannotFit_EntryBlocked() + { + var datDir = FindDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: dat directory not found"); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var engine = BuildCorridorEngine(dats); + + var body = new PhysicsBody(); + body.ContactPlaneValid = true; + body.ContactPlane = new Plane(Vector3.UnitZ, 5.112f); // ramp-top level + body.ContactPlaneCellId = 0x8A020179u; + body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; + + // Walk the live approach (ramp-top toward the corridor-end opening) + // so the engine self-accumulates its contact-plane/walkable state, + // then push into the opening for several held-key frames (the live + // climb happened under a held key, not a single resolve). + var pos = new Vector3(88.60f, -41.10f, -5.05f); + uint cell = 0x8A020179u; + ResolveResult r = default; + bool probeFrames = Env.GetEnvironmentVariable("ACDREAM_TEST_WINDOW_PROBE") == "1"; + for (int i = 0; i < 22; i++) + { + var dir = Vector3.Normalize(new Vector3(90.209f, -41.809f, 0f) - new Vector3(pos.X, pos.Y, 0f)); + var step = new Vector3(dir.X, dir.Y, 0f) * 0.13f; + + var probeBuffer = new System.IO.StringWriter(); + var prevOut = Console.Out; + try + { + if (probeFrames && i >= 9) + { + Console.SetOut(probeBuffer); + PhysicsDiagnostics.ProbeStepWalkEnabled = true; + PhysicsDiagnostics.ProbeIndoorBspEnabled = true; + } + r = engine.ResolveWithTransition( + currentPos: pos, + targetPos: pos + step, + cellId: cell, + sphereRadius: 0.48f, + // #137: the corrected capsule top (dat Setup 0x02000001, + // head sphere center 1.350 → top 1.830; Height 1.835). + // The live climb happened under the old 1.2f (head top + // 1.2 m — no head collision at the lintel). + sphereHeight: 1.835f, + stepUpHeight: 0.6f, + stepDownHeight: 1.5f, + isOnGround: true, + body: body, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide); + } + finally + { + if (probeFrames && i >= 9) + { + PhysicsDiagnostics.ProbeStepWalkEnabled = false; + PhysicsDiagnostics.ProbeIndoorBspEnabled = false; + Console.SetOut(prevOut); + } + } + if (probeFrames && i >= 9 && i <= 10) + _out.WriteLine(probeBuffer.ToString()); + _out.WriteLine($"r{i}: ok={r.Ok} out=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) " + + $"cell=0x{r.CellId:X8} hit={r.CollisionNormalValid} " + + $"n=({r.CollisionNormal.X:F2},{r.CollisionNormal.Y:F2},{r.CollisionNormal.Z:F2})"); + pos = r.Position; + cell = r.CellId; + + Assert.NotEqual(0x8A02017Eu, r.CellId); + Assert.True(r.Position.Y > -41.6f, + $"A 1.68 m character must not enter the 1.3 m-tall opening " + + $"(wall plane y=−41.67); frame {i} got Y={r.Position.Y:F3} " + + $"cell=0x{r.CellId:X8} (live bug: ended at −41.774 inside " + + $"0x8A02017E, head through the roof)."); + } + } + + /// + /// The window-climb's placement half, pinned at the exact site: at the + /// step-up's raised position on the alcove sill (foot −5.019), the HEAD + /// sphere (center −3.339, span −3.82..−2.86) pokes ~6 cm past the south + /// wall plane into the SOLID rock above the alcove ceiling (0x8A020179's + /// lintel band, polys 14/15 at y=−41.67 z∈[−3.90,−3.00]). Retail's + /// step-down placement insert (CTransition::step_down 0x0050b3b3 → + /// placement transitional_insert → BSPTREE::sphere_intersects_solid + /// 0x0053d5f0) REJECTS — that's what makes the 0.7 m sill unclimbable. + /// Our placement passed (the live + offline climb), so our Path-1 solid + /// test misses the head-vs-solid overlap. + /// + [Fact] + public void WindowAlcove_RaisedPlacement_HeadInLintelSolid_Collides() + { + var datDir = FindDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: dat directory not found"); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var engine = BuildCorridorEngine(dats); + + var cell = engine.DataCache!.GetCellStruct(0x8A020179u); + Assert.NotNull(cell); + Assert.NotNull(cell!.BSP?.Root); + + // The raised (post-sill-climb) pose from the offline repro's r9. + var footWorld = new Vector3(89.683f, -41.247f, -4.539f); // foot sphere CENTER + var headWorld = new Vector3(89.683f, -41.247f, -3.339f); // head sphere CENTER + + var footLocal = Vector3.Transform(footWorld, cell.InverseWorldTransform); + var headLocal = Vector3.Transform(headWorld, cell.InverseWorldTransform); + + var t = new Transition(); + t.SpherePath.InitPath( + new Vector3(89.683f, -41.247f, -5.019f), + new Vector3(89.683f, -41.247f, -5.019f), + 0x8A020179u, 0.48f, 1.2f); + t.SpherePath.InsertType = InsertType.Placement; + + Matrix4x4.Decompose(cell.WorldTransform, out _, out var cellRot, out var cellOrigin); + + var result = BSPQuery.FindCollisions( + cell.BSP!.Root, + cell.Resolved, + t, + new DatReaderWriter.Types.Sphere { Origin = footLocal, Radius = 0.48f }, + new DatReaderWriter.Types.Sphere { Origin = headLocal, Radius = 0.48f }, + footLocal, + Vector3.UnitZ, + 1.0f, + cellRot, + engine, + worldOrigin: cellOrigin); + + _out.WriteLine($"placement result={result} footLocal=({footLocal.X:F3},{footLocal.Y:F3},{footLocal.Z:F3}) " + + $"headLocal=({headLocal.X:F3},{headLocal.Y:F3},{headLocal.Z:F3})"); + + Assert.Equal(TransitionState.Collided, result); + } + /// /// 2026-07-06 gate session repro (launch-137-corridor-gate.log): standing /// at (84.851, −39.764, −6.000) — the foot sphere already straddling the