diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 9b67b514..6033efe9 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -177,6 +177,68 @@ missing is the plugin-API surface.
---
+## #32 — Retail edge-slide / cliff-slide / precipice-slide incomplete
+
+**Status:** IN-PROGRESS
+**Severity:** HIGH
+**Filed:** 2026-04-29
+**Component:** physics / collision
+
+**Description:** When walking along walls, roof edges, cliff edges, or failed
+step-down boundaries, retail often slides along the boundary. acdream still
+hard-blocks or accepts too much in several of these cases.
+
+**Root cause / status:** Tracked under Phase L.2c. Wall-adjacent
+`step_up_slide` now feels acceptable in live testing. Local/remote movement
+passes the retail-default `EdgeSlide` flag. The first precipice-slide slice now
+preserves terrain/BSP walkable polygon vertices and runs the retail back-probe
+before `SPHEREPATH::precipice_slide`; `ACDREAM_DUMP_EDGE_SLIDE=1` now reports
+whether a failed step-down had polygon context. Remaining gaps: real-DAT
+building-edge fixtures, fuller `cliff_slide` coverage, and `NegPolyHit`
+dispatch. Named retail anchors include `CTransition::edge_slide`,
+`CTransition::cliff_slide`, `SPHEREPATH::precipice_slide`, and
+`SPHEREPATH::step_up_slide`.
+
+**Files:** `src/AcDream.Core/Physics/TransitionTypes.cs`,
+`src/AcDream.Core/Physics/BSPQuery.cs`,
+`tests/AcDream.Core.Tests/`.
+
+**Research:** `docs/plans/2026-04-29-movement-collision-conformance.md`,
+`docs/research/2026-04-30-precipice-slide-pseudocode.md`.
+
+**Acceptance:** Synthetic and real-DAT tests cover wall-slide, roof-edge slide,
+cliff/precipice slide, failed step-up/step-down, and the jump-clears-edge case.
+
+---
+
+## #33 — Live entity collision shape collapses to one cylinder
+
+**Status:** OPEN
+**Severity:** MEDIUM
+**Filed:** 2026-04-29
+**Component:** physics / entities
+
+**Description:** Live world entities do not yet use exact retail
+`CSphere` / `CCylSphere` shape semantics. Several paths collapse the entity to
+a simplified root-centered cylinder or fallback radius, which is not enough for
+retail object and creature collision parity.
+
+**Root cause / status:** Tracked under Phase L.2d. Requires auditing object
+shape extraction, `Setup.Radius` fallback, building object identity, and live
+entity broadphase records against named retail.
+
+**Files:** `src/AcDream.Core/Physics/CollisionPrimitives.cs`,
+`src/AcDream.Core/Physics/ShadowObjectRegistry.cs`,
+`src/AcDream.Core/Physics/PhysicsDataCache.cs`.
+
+**Research:** `docs/plans/2026-04-29-movement-collision-conformance.md`.
+
+**Acceptance:** Live object collision uses the appropriate retail sphere or
+cylsphere data where available. Tests prove at least one multi-shape object and
+one live creature case no longer use the single-cylinder fallback.
+
+---
+
## #2 — Lightning visual mismatch (sky PES path disproved)
@@ -359,6 +421,40 @@ If hypothesis (a) is correct, this issue effectively rolls into **#28** — the
# Recently closed
+## #31 — [DONE 2026-04-29] Low outdoor cell id can go stale after transition movement
+
+**Closed:** 2026-04-29
+**Commit:** `(this commit)`
+**Resolution:** `ResolveWithTransition` now refreshes outdoor cell ownership
+from the resolved world position while the sphere sweep runs. Intra-landblock
+24m outdoor seams update the low cell id, and full-cell callers crossing a
+landblock seam get the destination landblock prefix plus the correct outdoor
+low cell.
+
+---
+
+## #34 — [DONE 2026-04-29] Missing routine local/server correction diagnostic
+
+**Closed:** 2026-04-29
+**Commit:** `(this commit)`
+**Resolution:** Added `ACDREAM_DUMP_MOVE_TRUTH=1`, which logs local resolved
+position/contact/cell, outbound movement fields, server `UpdatePosition` echo,
+and local/server correction delta for the player in grep-friendly
+`move-truth OUT` / `move-truth ECHO` lines.
+
+---
+
+## #30 — [DONE 2026-04-29] AutonomousPosition contact byte is too often grounded
+
+**Closed:** 2026-04-29
+**Commit:** `(this commit)`
+**Resolution:** `GameWindow` now derives the movement contact byte from
+`MovementResult.IsOnGround` and passes it explicitly to both `MoveToState.Build`
+and `AutonomousPosition.Build`. Added packet tests proving both builders encode
+an explicit airborne contact byte.
+
+---
+
## #27 — [DONE 2026-04-26] Cloud meshes appeared missing or faint vs retail
**Closed:** 2026-04-26
diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md
index 56b888d9..5f134da7 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -100,67 +100,129 @@ crib-sheet version.
---
-## Project Structure (target)
+## Project Structure (current + target)
```
src/
AcDream.Core/ Layer 2-4: no GL, no Silk.NET, pure logic
Physics/
- PhysicsBody.cs ← ported from decompiled (done)
- CollisionPrimitives.cs ← ported from decompiled (done)
- MotionInterpreter.cs ← ported from decompiled (done)
- AnimationSequencer.cs ← ported from decompiled (done)
- CellBsp.cs ← TODO: port from decompiled
- Transition.cs ← TODO: port from decompiled
- TerrainSurface.cs ← verified against ACME (done)
+ PhysicsBody.cs -> body state / integration foundation (done)
+ CollisionPrimitives.cs -> retail primitive helpers (partial, active)
+ MotionInterpreter.cs -> motion state machine (done, still L.1 polish)
+ AnimationSequencer.cs -> animation playback + root-motion data (done, L.1 active)
+ TerrainSurface.cs -> triangle-aware terrain contact (done)
+ BSPQuery.cs -> partial retail BSP dispatcher (active in L.2)
+ TransitionTypes.cs -> SpherePath / CollisionInfo / transition helpers (active in L.2)
+ PhysicsDataCache.cs -> GfxObj / Setup / CellStruct collision data (done, active)
+ ShadowObjectRegistry.cs -> broadphase for nearby physics objects (active)
+ PhysicsEngine.cs -> ResolveWithTransition active player path
+ CellBsp.cs -> not a first-class runtime owner yet (L.2e)
World/
- GameEntity.cs ← TODO: unified entity (replaces scattered state)
- WorldState.cs ← TODO: owns all entities
- CellTracker.cs ← TODO: per-entity cell management
- SceneryGenerator.cs ← verified against decompiled (done)
- LandblockLoader.cs ← done
+ GameEntity.cs -> target unified entity, not current reality
+ WorldState.cs -> target entity owner
+ CellTracker.cs -> target per-entity cell management
+ SceneryGenerator.cs -> verified against decompiled (done)
+ LandblockLoader.cs -> done
Terrain/
- LandblockMesh.cs ← verified against ACME (done)
- TerrainBlending.cs ← verified against ACME (done)
+ LandblockMesh.cs -> verified against ACME (done)
+ TerrainBlending.cs -> verified against ACME (done)
Meshing/
- GfxObjMesh.cs ← cross-checked against ACME (done)
- SetupMesh.cs ← cross-checked (done)
+ GfxObjMesh.cs -> cross-checked against ACME (done)
+ SetupMesh.cs -> cross-checked (done)
Textures/
- SurfaceDecoder.cs ← done
+ SurfaceDecoder.cs -> done
Dat/
- MotionResolver.cs ← done (move here from Meshing/)
+ MotionResolver.cs -> done (target move from Meshing/)
AcDream.Core.Net/ Layer 2: networking
- WorldSession.cs ← done (wire-compatible with ACE)
- NetClient.cs ← done
- Messages/ ← done (CreateObject, MoveToState, etc.)
+ WorldSession.cs -> done (wire-compatible with ACE)
+ NetClient.cs -> done
+ Messages/ -> done (CreateObject, MoveToState, etc.)
AcDream.Plugin.Abstractions/ Layer 5: plugin interfaces
- IAcDreamPlugin.cs ← done
- IPluginHost.cs ← done
- IGameState.cs ← done
- IEvents.cs ← done
+ IAcDreamPlugin.cs -> done
+ IPluginHost.cs -> done
+ IGameState.cs -> done
+ IEvents.cs -> done
AcDream.App/ Layer 1 + Layer 4 wiring
Rendering/
- GameWindow.cs ← TODO: thin down to GL calls only
- TerrainRenderer.cs ← done
- StaticMeshRenderer.cs ← done
- TextureCache.cs ← done
- ChaseCamera.cs ← done
- FlyCamera.cs ← done
+ GameWindow.cs -> still owns too much runtime wiring
+ TerrainRenderer.cs -> done
+ StaticMeshRenderer.cs -> done
+ TextureCache.cs -> done
+ ChaseCamera.cs -> done
+ FlyCamera.cs -> done
Streaming/
- StreamingController.cs ← done
- GpuWorldState.cs ← done
+ StreamingController.cs -> done
+ GpuWorldState.cs -> done
Input/
- PlayerMovementController.cs ← done (uses ported physics)
+ PlayerMovementController.cs -> active movement driver
Plugins/
- AppPluginHost.cs ← done
+ AppPluginHost.cs -> done
```
---
-## GameEntity: The Unified Entity (TODO — the big refactor)
+## Movement And Collision Architecture
+
+Phase L.2 is the current organizing program for physics, collision,
+boundaries, buildings, sliding, cell ownership, movement packets, and server
+authority. Detailed plan: `docs/plans/2026-04-29-movement-collision-conformance.md`.
+
+The active player movement spine is:
+
+```text
+InputDispatcher / PlayerMovementController
+ -> MotionInterpreter + local body prediction
+ -> PhysicsEngine.ResolveWithTransition
+ -> TransitionTypes + BSPQuery + ShadowObjectRegistry
+ -> ResolveResult contact/cell state
+ -> MoveToState / AutonomousPosition outbound messages
+ -> WorldSession server echo or correction handling
+```
+
+What exists and is active:
+
+- `PhysicsEngine.ResolveWithTransition` is the path used for local player
+ collision resolution.
+- `BSPQuery` contains the partial retail-style BSP collision dispatcher used by
+ the transition path.
+- `TransitionTypes` carries `SpherePath`, `CollisionInfo`, `ObjectInfo`,
+ transition validation, step-up/down, contact-plane handling, and partial
+ slide behavior.
+- `PhysicsDataCache` loads GfxObj, Setup, and CellStruct physics data from DATs.
+- `ShadowObjectRegistry` gives movement a broadphase over nearby objects and
+ buildings.
+- `TerrainSurface` uses triangle-aware terrain contact; older "bilinear terrain
+ Z" descriptions are historical B.3 language, not current architecture.
+
+What remains incomplete:
+
+- `CELLARRAY`, `CObjCell::find_cell_list`, adjacent-cell checks, and low outdoor
+ cell id updates across 24m seams.
+- `cell_bsp` / `CellBSP` as the authoritative runtime owner for indoor and
+ building collision.
+- Building portal transit and normal walking through building entry/exit
+ boundaries.
+- Full retail `edge_slide`, `cliff_slide`, `precipice_slide`, and `NegPolyHit`
+ dispatch behavior.
+- Exact `CSphere` / `CCylSphere` object-shape parity, especially for live
+ entities that currently collapse to a simplified cylinder fallback.
+- Routine local/server correction diagnostics. ACE accepting a position is a
+ compatibility signal, not proof of fine retail collision parity.
+
+Ownership by phase:
+
+- B.3 is shipped MVP history: first resolver foundation and tests.
+- L.1 owns animation/motion parity, including root-motion coupling.
+- L.2 owns the movement/collision conformance stack listed above.
+- G.3 owns dungeon streaming and portal-space delivery after L.2e gives it
+ trustworthy cell/building boundaries.
+
+---
+
+## GameEntity: The Unified Entity (target refactor)
Currently, entity state is scattered across:
- `WorldEntity` (position, rotation, mesh refs)
@@ -198,21 +260,21 @@ public sealed class GameEntity
{
Motion.ApplyCurrentMovement(); // set velocity from motion state
Physics.UpdateObject(dt); // integrate position
- // TODO: Transition.FindValidPosition // collision resolve
- Cell.UpdateCell(Physics.Position); // check cell transitions
+ PhysicsEngine.ResolveWithTransition(); // current L.2 collision spine
+ Cell.UpdateCell(Physics.Position); // target: retail cell ownership
Animation.Advance(dt); // advance animation frames
RebuildMeshRefs(); // compute per-part transforms
}
}
```
-Every entity in the world — player, NPC, monster, lifestone, door, chest —
-is a `GameEntity`. The renderer iterates them and draws. The plugin API
-exposes them as `WorldEntitySnapshot`. GameWindow becomes thin.
+Target state: every entity in the world — player, NPC, monster, lifestone,
+door, chest — becomes a `GameEntity`. The renderer iterates them and draws.
+The plugin API exposes them as `WorldEntitySnapshot`. GameWindow becomes thin.
---
-## Per-Frame Update Order (matches retail)
+## Per-Frame Update Order (current runtime)
```
1. Network tick
@@ -224,15 +286,16 @@ exposes them as `WorldEntitySnapshot`. GameWindow becomes thin.
create terrain + scenery GameEntities
3. Input tick (player mode only)
- └── Read WASD/mouse → MotionInterpreter.DoMotion →
+ └── InputDispatcher scopes → PlayerMovementController →
+ MotionInterpreter/body prediction → ResolveWithTransition →
send MoveToState/AutonomousPosition to server
-4. Entity tick (ALL entities, 30Hz fixed step)
- └── For each GameEntity: entity.Update(dt)
- This runs: motion → physics → collision → cell → animation
+4. Entity / animation tick
+ └── Current code still has scattered world/entity state. L.1 owns
+ animation parity; L.2 owns movement/collision conformance.
5. Render tick
- └── For each GameEntity: read MeshRefs, draw
+ └── Read current entity mesh refs, draw
TerrainRenderer.Draw, StaticMeshRenderer.Draw
(frustum cull, translucency pass, etc.)
@@ -248,89 +311,26 @@ exposes them as `WorldEntitySnapshot`. GameWindow becomes thin.
---
-## Execution Plan: How to Get There
+## Roadmap Model
-### Phase R1: GameEntity Refactor (the foundation)
-**Goal:** Replace the scattered entity state with unified GameEntity.
+The old R1-R8 architecture sequence was a useful early refactor sketch, but it
+is no longer the execution plan. The strategic source of truth is now
+`docs/plans/2026-04-11-roadmap.md`, with per-phase details in `docs/plans/`
+and `docs/superpowers/specs/`.
-1. Create `GameEntity` class in `AcDream.Core/World/`
-2. Move `AnimatedEntity` fields into `GameEntity.Animation`
-3. Move `WorldEntity` fields into `GameEntity.Physics` + position
-4. Move `_entitiesByServerGuid` into `WorldState`
-5. Move animation tick from `GameWindow.TickAnimations` into `GameEntity.Update`
-6. GameWindow.OnRender reads `GameEntity.MeshRefs` instead of `WorldEntity.MeshRefs`
+Current movement/collision ownership:
-**Test:** Everything looks the same as before. No visual change.
+- **B.3** is shipped MVP history: first collision resolver foundation.
+- **L.1** owns animation/motion parity, including root-motion coupling.
+- **L.2** owns movement and collision conformance:
+ `docs/plans/2026-04-29-movement-collision-conformance.md`.
+- **G.3** owns dungeon streaming and portal-space delivery after L.2e lands
+ trustworthy `cell_bsp`, `CELLARRAY`, adjacent-cell checks, and building
+ entry/exit boundaries.
-### Phase R2: Thin GameWindow
-**Goal:** GameWindow does only GL calls + input dispatch.
-
-1. Extract entity creation from `OnLiveEntitySpawned` into `WorldState.SpawnEntity`
-2. Extract motion updates from `OnLiveMotionUpdated` into `WorldState.UpdateMotion`
-3. Extract player movement from the giant OnUpdate block into `PlayerController`
-4. GameWindow.OnUpdate calls: network.Tick → streaming.Tick → input.Tick → worldState.Tick → render
-
-**Test:** Everything works the same. GameWindow.cs drops from 2000+ to ~500 lines.
-
-### Phase R3: CellBSP + Wall Collision
-**Goal:** Entities can't walk through walls.
-
-1. Port CellBSP from decompiled code (sphere_intersects_cell)
-2. Port Transition.FindValidPosition (swept sphere collision)
-3. Wire into GameEntity.Update between physics and cell tracking
-4. Indoor transitions become correct (wall stops you, doorway lets you through)
-
-**Test:** Walk into building wall → stopped. Walk through doorway → enter.
-
-### Phase R4: Complete Animation State Machine
-**Goal:** Every animation works for every entity type.
-
-1. Port full MotionInterp.PerformMovement from decompiled (all 5 movement types)
-2. Port Links table resolution for smooth transitions
-3. Port idle modifiers (fidgets)
-4. Jump animation (wire jump motion command through the pipeline)
-
-**Test:** All entity types animate correctly. Transitions are smooth.
-
-### Phase R5: Lighting from Retail
-**Goal:** Sun, ambient, per-vertex lighting match retail.
-
-1. Port AdjustPlanes (FUN_00532440) — face normals + per-vertex lighting
-2. Extract global lighting constants from decompiled DAT addresses
-3. Replace hardcoded shader constants with ported values
-
-**Test:** Side-by-side with retail client shows matching lighting.
-
-### Phase R6: Server Compliance
-**Goal:** ACE accepts all movement, no rubber-banding.
-
-1. Server-authoritative Z (trust server position, local is cosmetic)
-2. Proper MoveToState with full RawMotionState packing
-3. Keepalive ping (5s idle)
-4. Graceful session management
-
-**Test:** Walk around, other clients see smooth movement. No ACE errors.
-
-### Phase R7: Interaction
-**Goal:** Click NPCs, open doors, pick up items, chat.
-
-1. Use/UseWithTarget game actions
-2. Door open animation (server sends UpdateMotion → animate)
-3. Chat send/receive
-4. Basic inventory (pickup/drop)
-
-**Test:** Open a door, talk to an NPC, send a chat message.
-
-### Phase R8: Plugin API Completion
-**Goal:** Plugins can observe and control everything.
-
-1. IGameState exposes all GameEntity fields
-2. IEvents fires for all world changes
-3. IActions covers: Move, Cast, Use, Say, Pickup, Drop
-4. IPacketPipeline hooks all 4 stages
-5. Lua macro engine (MoonSharp) ships as a built-in plugin
-
-**Test:** A Lua script auto-loots gems. A C# plugin displays an overlay.
+The GameEntity / thin GameWindow refactor remains a valid target architecture,
+but it is not a prerequisite for L.2. Do not resurrect old R1-R8 phase numbers
+for new work; add or update roadmap phases instead.
---
@@ -339,11 +339,12 @@ exposes them as `WorldEntitySnapshot`. GameWindow becomes thin.
```
For every AC-specific behavior:
-1. DECOMPILE → Find the function in docs/research/decompiled/
-2. CROSS-CHECK → Verify against ACE + ACME + holtburger
+0. GREP NAMED → Search docs/research/named-retail/ by class::method
+1. FALLBACK → Use older docs/research/decompiled/ chunks only if needed
+2. CROSS-CHECK → Verify against ACE + ACME + holtburger where relevant
3. PSEUDOCODE → Translate to readable pseudocode
4. PORT → Faithful C# translation
-5. TEST → Conformance test against decompiled golden values
+5. TEST → Conformance test against retail/decomp golden values
6. INTEGRATE → Surgical wiring into the existing system
7. VERIFY → Visual + functional test
```
@@ -359,9 +360,9 @@ For acdream-specific code (renderer, plugin API, streaming):
| Domain | Primary Oracle | Secondary |
|--------|---------------|-----------|
-| Physics/collision | Decompiled acclient.exe | ACE Physics/ |
-| Animation | Decompiled + ACE Animation/ | — |
-| Terrain | ACME ClientReference.cs | Decompiled |
+| Physics/collision | `docs/research/named-retail/` | ACE Physics/ + older decompiled chunks |
+| Animation | `docs/research/named-retail/` + ACE Animation/ | — |
+| Terrain | ACME ClientReference.cs | named retail / older decompiled chunks |
| Rendering | WorldBuilder (Silk.NET) | ACViewer |
| Protocol | holtburger | AC2D |
| Server behavior | ACE | — |
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index c4cb6ee8..a75fd3fe 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -1,6 +1,6 @@
# acdream — strategic roadmap
-**Status:** Living document. Updated 2026-04-11 after Phase 6, 7.1, 9.1, 9.2 landed.
+**Status:** Living document. Updated 2026-04-29 for Phase L.2 movement/collision conformance planning.
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
---
@@ -31,7 +31,7 @@
| A.1 | Streaming landblock loader — runtime-configurable visible window (default 5×5, `ACDREAM_STREAM_RADIUS`), camera-centered offline / player-centered live, hysteresis-based unloads, pending-spawn list for late CreateObject events | Live ✓ |
| A.2 | Frustum culling — per-landblock AABB test (Gribb-Hartmann), terrain + static-mesh renderers skip culled landblocks, perf overlay in window title | Visual ✓ |
| A.3 | Background net receive thread — dedicated daemon thread buffers UDP into Channel, render thread drains | Visual ✓ |
-| B.3 | Physics collision engine — TerrainSurface (heightmap Z), CellSurface (indoor floor polygon projection), PhysicsEngine (resolver with step-height + cell transitions). Populated from streaming pipeline. | Tests ✓ |
+| B.3 | Physics MVP resolver foundation — terrain contact, CellSurface prototype, streaming-populated collision inputs, and first `PhysicsEngine` resolver path. Not the complete retail collision system. | Tests ✓ |
| B.2 | Player movement mode — Tab-toggled WASD ground walking, walk/run/idle animations, third-person chase camera, MoveToState + AutonomousPosition outbound, portal entry. Outdoor-only MVP. | Live ✓ |
| D.1 | 2D ortho overlay + font rendering (StbTrueTypeSharp atlas + TextRenderer + DebugOverlay) | Visual ✓ |
| E.1 | Motion-hook expansion — AnimationSequencer fires all 27 hook types per crossed frame; PosFrames root motion + vel/omega exposure; IAnimationHookSink + AnimationHookRouter fan-out | Tests ✓ |
@@ -94,7 +94,7 @@ Plus polish that doesn't get its own phase number:
**Sub-pieces:**
- **✓ SHIPPED — B.1 — Outbound ack pump.** Shipped as Phase 4.9 — per-packet ACK_SEQUENCE, not periodic. Server no longer drops idle clients.
- **✓ SHIPPED — B.2 — Player movement mode.** Tab-toggled WASD ground walking with collision-resolved outdoor terrain, walk/run/idle/turn-right animations, third-person chase camera, outbound MoveToState (0xF61C) + AutonomousPosition (0xF753) server messages, portal entry works. Outdoor→indoor transition disabled for MVP (CellSurface floor polygons too aggressive without portal-based detection). Minor polish remaining: strafe animation, turn-left animation. Spec: `docs/superpowers/specs/2026-04-12-player-movement-design.md`.
-- **✓ SHIPPED — B.3 — Physics collision engine.** TerrainSurface (heightmap bilinear Z), CellSurface (indoor floor polygon projection via barycentric interpolation), PhysicsEngine (top-level resolver with step-height enforcement, outdoor↔indoor cell transitions, gravity reporting). Populated from streaming pipeline. 16 unit tests with fake data. Spec: `docs/superpowers/specs/2026-04-12-physics-collision-engine-design.md`.
+- **✓ SHIPPED — B.3 — Physics MVP resolver foundation.** Terrain contact, CellSurface prototype, streaming-populated collision inputs, and first `PhysicsEngine` resolver path. This shipped enough foundation for outdoor walking and early portal experiments, but it is not the complete retail collision system. Current conformance work lives under **Phase L.2 — Movement & Collision Conformance**. Spec history: `docs/superpowers/specs/2026-04-12-physics-collision-engine-design.md`.
- **B.4 — `Use` / `UseWithTarget` / `PickUp`.** Outbound interaction messages. Drives opening doors, looting, talking to vendors.
- **B.5 — Chat.** `SendTell`, `SendChat` outbound + receive/display inbound (display side depends on Phase D.1).
@@ -204,7 +204,7 @@ Research: R9 + R12 + R13.
- **✓ SHIPPED — G.1 — Sky + weather + day-night.** Deterministic client-side from Portal Year time. Sky dome geometry + keyframe gradients + rain/snow particles. See `r12-weather-daynight.md`. Full data + visual stack shipped: Region dat loader, keyframe interp, WeatherSystem with 5-kind PDF + transitions + storm flashes, WorldSession→WorldTimeService sync via ConnectRequest+TimeSync, SkyRenderer with sky-object arcs + UV scroll, rain/snow billboard renderer, F7/F10 debug cycle keys.
- **✓ SHIPPED — G.2 — Dynamic lighting.** 8-light D3D-style fixed pipeline. Hard-cutoff at Range, no attenuation inside. Cell ambient. Shader UBO per frame. See `r13-dynamic-lighting.md`. SceneLightingUbo std140 at binding=1 feeds terrain + mesh + mesh_instanced + sky shaders. LightingHookSink auto-registers Setup.Lights at entity stream-in, flips IsLit on SetLightHook, unregisters on landblock unload.
-- **G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with `LoginComplete` re-send, "pink bubble" loading state. See `r09-dungeon-portal-space.md`.
+- **G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with `LoginComplete` re-send, "pink bubble" loading state. **Blocked on L.2e** for trustworthy `cell_bsp`, indoor/outdoor portal transit, adjacent-cell ownership, and building entry/exit collision boundaries. See `r09-dungeon-portal-space.md`.
**Acceptance:** walk outside at dusk, see the sky gradient + sun moving; enter a torch-lit dungeon via portal; leave back to daylight.
@@ -318,6 +318,11 @@ queues, speed scaling, and PosFrame root motion.
**Plan of record:** `docs/plans/animation-system-audit.md`.
+**Coupling to L.2:** L.1 owns animation/motion parity. L.2 owns collision,
+contact truth, movement packets, and server-visible placement. They meet where
+root motion or observer movement changes the predicted body path; any such
+change must keep both phase plans in sync.
+
**Sub-pieces:**
- **L.1a — Audit & inventory.** Map retail named-decomp evidence, ACE
cross-references, existing acdream hook points, and current gaps for each
@@ -349,6 +354,55 @@ queues, speed scaling, and PosFrame root motion.
---
+### Phase L.2 — Movement & Collision Conformance
+
+**Status:** ACTIVE.
+
+**Goal:** make acdream's movement and collision behavior retail-faithful across
+terrain, buildings, walls, roof edges, cell seams, portal boundaries, outbound
+movement packets, and server correction. This is the holistic bucket for the
+work previously scattered across B.3 physics follow-ups, L.1 motion coupling,
+and G.3 dungeon/portal ownership.
+
+**Plan of record:** `docs/plans/2026-04-29-movement-collision-conformance.md`.
+
+**Current foundation:** `PhysicsEngine.ResolveWithTransition`,
+`BSPQuery`, `TransitionTypes`, `PhysicsDataCache`, and
+`ShadowObjectRegistry` exist and are active. They are partial retail ports and
+diagnostic scaffolding, not yet the final collision system.
+
+**Sub-lanes:**
+- **L.2a — Truth & diagnostics.** Local placement/contact/cell logs, object-hit
+ probes, correction-delta diagnostics, retail-observer capture workflow, and
+ real-DAT fixture capture.
+- **L.2b — Movement wire/contact authority.** Fix outbound contact truth,
+ full-cell id handling, packet cadence, and routine server correction handling.
+- **L.2c — Transition parity: edge/slide/neg-poly.** Port and test retail
+ `edge_slide`, `cliff_slide`, `precipice_slide`, step-up/down slide, and
+ `NegPolyHit` dispatch behavior.
+- **L.2d — Shape fidelity: sphere/cylsphere/building objects.** Finish
+ `CSphere` / `CCylSphere` parity, live-entity object shapes, building object
+ collision identity, and `Setup.Radius` fallback audit.
+- **L.2e — Cell ownership: outdoor seams, `CELLARRAY`, `cell_bsp`.** Update
+ low outdoor cell id across 24m seams, port adjacent-cell checks, activate
+ `cell_bsp`, and hand G.3 a trustworthy building/portal boundary model.
+- **L.2f — Real-DAT and live retail-observer conformance.** Promote synthetic
+ tests to real-world fixtures and verify local acdream view plus retail
+ observer view. ACE accepting a position is a compatibility check, not proof
+ of fine-grained retail collision parity.
+
+**Acceptance:**
+- A developer can trace the active movement path: input/motion -> body
+ prediction -> `ResolveWithTransition` -> contact/cell result -> outbound
+ packets -> server echo/correction.
+- Buildings, edge-slide, wall-slide, cell seams, packet authority, and dungeon
+ portal ownership each have an L.2 lane.
+- Every AC-specific algorithm port cites named retail decomp, or a documented
+ fallback when named retail lacks the body.
+- `dotnet build` and `dotnet test` are green for each implementation slice.
+
+---
+
### Phase J — Long-tail (deferred / low-priority)
Not detailed here; each gets its own brainstorm when it becomes relevant.
@@ -431,7 +485,10 @@ port in any phase — no separate listing here.
| Holtburg sign half-buried | **5 FIXED** ✓ |
| Can't walk past the loaded 3×3 window | **A.1 FIXED** ✓ (5×5 default, `ACDREAM_STREAM_RADIUS` to tune) |
| Frame hitch crossing landblock boundary | **Phase A.3** (synchronous loader for now; async returns when DatCollection is thread-safe) |
-| Walking around doesn't move me on the server | **Phase B.3 FIXED** ✓ |
+| Walking around doesn't move me on the server | **Phase B.2/B.3 FIXED** ✓ for coarse server movement; fine retail collision parity is **Phase L.2** |
+| Sliding along buildings / walls feels wrong | **Phase L.2c + L.2d** |
+| Roof edge / cliff / precipice blocks or slides wrong | **Phase L.2c** |
+| Crossing outdoor cell seams reports the wrong cell | **Phase L.2e** |
| Can't talk to NPCs | **Phase H.3** (emote scripts + dialogs) |
| Can't open a door | **Phase F** (object-use action) |
| Portals render as a rotating black disk | **Phase E.3** (particle system) |
@@ -444,7 +501,7 @@ port in any phase — no separate listing here.
| Combat doesn't show in the chat log | **I.7 FIXED** ✓ |
| Accented character names show as `?` or garbled | **I.5 FIXED** ✓ (Windows-1252 codec) |
| No sound | **Phase E.2** |
-| Dungeons / foundry interior missing | **Phase G.3** |
+| Dungeons / foundry interior missing | **Phase G.3** after **L.2e** cell/building ownership |
| Can't fight monsters | **Phase F.3** (combat math + damage) |
| Can't cast spells | **Phase F.4** |
| No inventory panel | **Phase F.2 + F.5** |
diff --git a/docs/plans/2026-04-29-movement-collision-conformance.md b/docs/plans/2026-04-29-movement-collision-conformance.md
new file mode 100644
index 00000000..90de9436
--- /dev/null
+++ b/docs/plans/2026-04-29-movement-collision-conformance.md
@@ -0,0 +1,215 @@
+# Phase L.2 - Movement & Collision Conformance
+
+**Status:** ACTIVE planning document, created 2026-04-29.
+**Roadmap owner:** Phase L.2 in `docs/plans/2026-04-11-roadmap.md`.
+**Scope:** player movement prediction, retail collision/transition behavior,
+building boundaries, edge and wall sliding, cell ownership, outbound movement
+packets, and server-correction diagnostics.
+
+## Purpose
+
+Phase B.3 shipped the first usable physics foundation: terrain contact,
+basic resolver behavior, streaming-populated collision inputs, and enough
+movement wire support to walk on ACE. That was not the complete retail
+collision system.
+
+Phase L.2 is the conformance program that turns that foundation into a
+retail-faithful movement stack. It is the single organizing bucket for work
+that otherwise looks scattered across B.3 physics, L.1 animation/motion, and
+G.3 dungeon/portal space.
+
+The active movement spine is:
+
+```text
+input + motion command
+ -> local body prediction / root-motion source
+ -> PhysicsEngine.ResolveWithTransition
+ -> TransitionTypes + BSPQuery + ShadowObjectRegistry contact/cell result
+ -> MoveToState / AutonomousPosition outbound packets
+ -> server echo or correction diagnostics
+```
+
+Live ACE accepting a position, or the absence of visible rubber-banding, is
+not proof of retail collision parity. ACE can tolerate coarse or locally
+invalid fine-grained movement. L.2 therefore requires retail-decomp evidence,
+synthetic conformance tests, real-DAT fixtures, and live retail-observer checks.
+
+## Current Foundation
+
+Already active in acdream:
+
+- `PhysicsEngine.ResolveWithTransition` is the local player collision path.
+- `BSPQuery` contains a partial retail-style BSP dispatcher and step/contact
+ logic.
+- `TransitionTypes` carries `SpherePath`, `CollisionInfo`, `ObjectInfo`,
+ transition validation, step-up/down, and partial slide behavior.
+- `PhysicsDataCache` loads GfxObj, Setup, and CellStruct physics data from DATs.
+- `ShadowObjectRegistry` gives the resolver a broadphase over nearby world
+ objects.
+- `TerrainSurface` uses triangle-aware terrain sampling rather than the older
+ bilinear placeholder.
+
+Known incomplete areas:
+
+- Full `CELLARRAY` ownership and `CObjCell::find_cell_list` / adjacent-cell
+ checks are not ported.
+- `cell_bsp` / `CellBSP` is not fully represented as a first-class runtime
+ owner.
+- Building entry/exit and indoor/outdoor portal transit are not solved by the
+ normal walking path.
+- Retail `edge_slide`, `cliff_slide`, and `precipice_slide` behavior is
+ incomplete; failed edge/step-down cases often hard-block instead of sliding.
+- `NegPolyHit` handling is a stub relative to the retail transition dispatch.
+- Live entities collapse to a simplified cylinder shape; exact retail
+ sphere/cylsphere and object-shape behavior is not yet matched.
+- Outbound contact/cell fields can be too optimistic, so server agreement does
+ not necessarily mean local conformance.
+
+## Lane Model
+
+L.2 uses five working lanes. The roadmap breaks them into six sub-lanes because
+real-DAT and live verification spans every lane.
+
+| Lane | Owns | Roadmap slice |
+|---|---|---|
+| Diagnostics | Truth probes, dump flags, server-correction logging, retail observer harness | L.2a, L.2f |
+| Transition parity | `FindTransitionalPosition`, step-up/down, edge-slide, cliff-slide, precipice-slide, `NegPolyHit` dispatch | L.2c |
+| Geometry fidelity | `CSphere`, `CCylSphere`, object shape extraction, building object collision, walkable polygon context | L.2d |
+| Cell/building ownership | outdoor cell seams, low-cell id updates, `CELLARRAY`, `cell_bsp`, building entry/exit | L.2e |
+| Movement/network authority | contact byte, full cell id, MoveToState / AutonomousPosition cadence, root motion vs velocity prediction, correction response | L.2b, L.2f |
+
+## Roadmap Slices
+
+### L.2a - Truth & Diagnostics
+
+Goal: make every bad movement outcome explainable.
+
+- Add targeted diagnostics for local placement, contact plane, object hit,
+ water, cell id, outbound packet fields, server echo, and correction delta.
+- Keep diagnostics opt-in via env vars and devtools panels.
+- Record enough data for side-by-side retail-observer runs without drowning
+ normal logs.
+- Build real-DAT fixture capture for known walls, building ledges, rooftops,
+ slopes, landblock seams, and dungeon entrances.
+
+### L.2b - Movement Wire / Contact Authority
+
+Goal: stop sending movement packets that claim more certainty than the local
+resolver has earned.
+
+- Fix outbound contact state so `AutonomousPosition` and `MoveToState` do not
+ always claim grounded contact.
+- Track local result cell id and outbound full cell id separately from the last
+ server placement until correction proves they agree.
+- Reconcile packet cadence with retail/holtburger references.
+- Wire routine server correction handling and diagnostics, not only portal
+ reseating.
+
+### L.2c - Transition Parity: Edge / Slide / Neg-Poly
+
+Goal: match retail movement at walls, roof edges, step boundaries, and
+precipices.
+
+- Port and test `edge_slide`, `cliff_slide`, `precipice_slide`, and
+ `step_up_slide` behavior from named retail.
+- Preserve walkable polygon context needed for precipice/edge decisions.
+- Replace `NegPolyHit` stub behavior with the retail dispatch path.
+- Confirm the user-visible rule: walk-only motion is blocked by step,
+ edge, walkable, and collision rules; jumping clears `OnWalkable` and only
+ succeeds when the airborne path actually clears geometry.
+
+Current shipped slice (2026-04-30): wall-adjacent `step_up_slide` feels
+acceptable in live testing; player/remote movers pass `EdgeSlide`; terrain and
+BSP step-down/find-walkable now preserve walkable polygon vertices; failed
+step-down edge cases perform the retail back-probe before
+`SPHEREPATH::precipice_slide`. Remaining L.2c work is real-DAT building-edge
+fixtures, fuller `cliff_slide` coverage, and `NegPolyHit` dispatch.
+
+### L.2d - Shape Fidelity: Sphere / CylSphere / Building Objects
+
+Goal: object collisions use retail shape semantics, not one simplified
+fallback.
+
+- Finish `CSphere` / `CCylSphere` parity for static and live objects.
+- Stop treating all live entities as one root-centered cylinder.
+- Preserve enough building identity to model `CBuildingObj` collision and
+ `bldg_check` behavior.
+- Audit `Setup.Radius` and cylinder fallback behavior against retail before
+ relying on them for conformance.
+
+### L.2e - Cell Ownership: Outdoor Seams, CELLARRAY, cell_bsp
+
+Goal: the resolver knows which cell owns the movement and which adjacent cells
+must be checked.
+
+- Update low outdoor cell id across 24m cell boundaries and landblock seams.
+- Port the retail adjacent-cell search: `find_cell_list`, `check_other_cells`,
+ and `adjust_check_pos`.
+- Promote `cell_bsp` / `CellBSP` from partial data to active runtime owner.
+- Hand G.3 a trustworthy building/portal boundary so dungeon streaming is not
+ asked to solve collision ownership after the fact.
+
+### L.2f - Real-DAT and Live Retail-Observer Conformance
+
+Goal: prove the stack against real terrain/building/cell data and what a retail
+client sees when observing acdream.
+
+- Add real-DAT fixtures for representative movement cases.
+- Use retail client observer runs to verify motion packets, animation/movement
+ coupling, and server-visible placement.
+- Treat ACE acceptance as a coarse compatibility check only.
+- Require conformance notes in tests or research docs for every AC-specific
+ algorithm ported under L.2.
+
+## Named Retail Anchors
+
+Primary source: `docs/research/named-retail/acclient_2013_pseudo_c.txt`.
+Struct source: `docs/research/named-retail/acclient.h`.
+Address lookup: `docs/research/named-retail/symbols.json`.
+
+Use these names before falling back to older `docs/research/decompiled/`
+chunks:
+
+- `CTransition::find_transitional_position` - `0x0050BDF0`
+- `CTransition::transitional_insert` - `0x0050B6F0`
+- `CTransition::step_up` - `0x0050B610`
+- `CTransition::step_down` - `0x0050B2A0`
+- `CTransition::edge_slide` - `0x0050B3D0`
+- `CTransition::cliff_slide` - `0x0050A6D0`
+- `SPHEREPATH::step_up_slide` - `0x0050C3B0`
+- `SPHEREPATH::precipice_slide` - `0x0050CC80`
+- `SPHEREPATH::adjust_check_pos` - `0x0050CC00`
+- `CTransition::adjust_offset` - `0x0050A370`
+- `CTransition::check_other_cells` - `0x0050AE50`
+- `CPhysicsObj::is_valid_walkable` - `0x0050F530`
+- `CObjCell::find_cell_list` - `0x0052B4E0`
+- `CBuildingObj::find_building_collisions`
+- `CCellStruct::point_in_cell`
+- `CCellStruct::sphere_intersects_cell`
+- `CCellStruct::box_intersects_cell`
+- `CCylSphere::intersects_sphere`
+- `CSphere::intersects_sphere`
+- `CSphere::slide_sphere`
+
+## Implementation Order
+
+1. Land L.2a diagnostics first. Do not make another physics change blind.
+2. Fix L.2b packet/contact truth so logs and server echoes describe reality.
+3. Port L.2c transition parity in narrow slices with named-retail citations and
+ conformance tests.
+4. Improve L.2d shape fidelity where transition parity depends on object
+ contact semantics.
+5. Land L.2e cell/building ownership before G.3 dungeon/portal work relies on
+ indoor/outdoor walking.
+6. Promote each synthetic case to L.2f real-DAT and live observer coverage.
+
+## Acceptance
+
+- A developer can name the active movement path and the current incomplete
+ pieces without reading old chat logs.
+- `dotnet build` and `dotnet test` stay green for each implementation slice.
+- Every AC-specific port cites named retail decomp or a documented fallback.
+- Real-DAT fixtures cover buildings, walls, roof edges, outdoor seams, and at
+ least one dungeon/building entrance path before L.2 is marked shipped.
+- Retail observer view and acdream local view both agree on contact, position,
+ and movement state for the representative cases.
diff --git a/docs/research/2026-04-30-precipice-slide-pseudocode.md b/docs/research/2026-04-30-precipice-slide-pseudocode.md
new file mode 100644
index 00000000..8ddc4c8f
--- /dev/null
+++ b/docs/research/2026-04-30-precipice-slide-pseudocode.md
@@ -0,0 +1,110 @@
+# Precipice Slide Pseudocode
+
+Date: 2026-04-30
+
+Phase: L.2c - Movement & Collision Conformance
+
+## Retail Anchors
+
+- Named retail: `CTransition::edge_slide`, `acclient_2013_pseudo_c.txt:273001`
+- Named retail: `CTransition::cliff_slide`, `acclient_2013_pseudo_c.txt:272397`
+- Named retail: `SPHEREPATH::precipice_slide`, `acclient_2013_pseudo_c.txt:274316`
+- ACE cross-check: `Transition.EdgeSlide`, `Transition.CliffSlide`,
+ `SpherePath.PrecipiceSlide`
+- ACE cross-check: `Polygon.find_crossed_edge`
+
+## Edge-Slide Flow
+
+When a grounded mover has contact state but the next candidate position has no
+walkable surface within step-down reach, retail does not immediately accept the
+fall or hard-stop. It enters `CTransition::edge_slide`.
+
+```text
+edge_slide(transitionState, stepDownHeight, walkableZ):
+ if object is not OnWalkable or EdgeSlide is disabled:
+ clear walkable
+ restore candidate check position
+ clear current contact plane
+ mark cell array valid
+ transitionState = OK
+ return handled
+
+ if current collision has a contact plane below walkableZ:
+ transitionState = cliff_slide(contact plane)
+ clear walkable and restore candidate check position
+ clear current contact plane
+ return not-final
+
+ if sphere_path.walkable exists:
+ transitionState = precipice_slide()
+ clear current contact plane and restore candidate check position
+ return transitionState == Collided
+
+ if current collision has any contact plane:
+ clear walkable
+ restore candidate check position
+ clear current contact plane
+ transitionState = OK
+ return handled
+
+ move CheckPos back from failed candidate to the current sphere center
+ step_down(stepDownHeight, walkableZ) to rediscover the walkable polygon
+ clear current contact plane
+ restore the failed candidate check position
+
+ if a walkable polygon was discovered:
+ set walkable_check_pos from the candidate sphere in walkable space
+ transitionState = precipice_slide()
+ return transitionState == Collided
+
+ clear walkable
+ mark cell array valid
+ transitionState = Collided
+ return handled
+```
+
+## Precipice Slide
+
+`SPHEREPATH::precipice_slide` is the edge-normal half of edge-slide. The crucial
+input is the walkable polygon that the mover just left; without that polygon,
+there is no crossed edge to slide along.
+
+```text
+precipice_slide():
+ normal = zero
+ found = walkable.find_crossed_edge(walkable_check_pos, walkable_up, normal)
+
+ if not found:
+ clear walkable
+ return Collided
+
+ clear walkable
+ step_up = false
+
+ normal = walkable_pos.frame.LocalToGlobalVec(normal)
+
+ blockOffset = LandDefs.GetBlockOffset(curr cell, check cell)
+ movementOffset = global_sphere.center - global_curr_center.center + blockOffset
+
+ if dot(normal, movementOffset) > 0:
+ normal = -normal
+
+ return global_sphere.slide_sphere(transition, normal, global_curr_center.center)
+```
+
+## Porting Notes
+
+acdream already had the `Polygon.find_crossed_edge` math inside `BSPQuery`, but
+the live diagnostic showed `walkableValid=False` at the failed step-down edge
+branch. The port must therefore preserve or rediscover the walkable polygon,
+not just pass the `EdgeSlide` flag.
+
+For the first L.2c slice:
+
+- terrain supplies the exact current triangle vertices alongside its plane;
+- BSP step-down/find-walkable records world-space polygon vertices when the
+ caller supplies the object's world origin;
+- the failed step-down edge branch performs the retail back-probe to current
+ position before calling precipice slide;
+- `CELLARRAY`, full `cell_bsp` ownership, and cross-cell building portals remain
+ L.2e work.
diff --git a/memory/project_collision_port.md b/memory/project_collision_port.md
index dc1fcd1a..1866b886 100644
--- a/memory/project_collision_port.md
+++ b/memory/project_collision_port.md
@@ -1,131 +1,128 @@
-# Collision System Port — Status and Plan
+# Collision System Port - Status and Plan
-## Current State (2026-04-14)
+## Current State (2026-04-29)
-The collision system has been patched multiple times but does NOT match
-retail. The user has explicitly requested a **full faithful port** of
-the retail collision system — no shortcuts, no simplifications.
+The collision system is no longer a pure placeholder and should not be treated
+as "delete everything and start over." A partial retail transition port exists:
-## What Went Wrong
+- `PhysicsEngine.ResolveWithTransition` is the active player movement resolver.
+- `BSPQuery` contains a partial retail-style BSP dispatcher.
+- `TransitionTypes` carries the active `SpherePath`, `CollisionInfo`,
+ transition, step, contact, and partial slide logic.
+- `PhysicsDataCache` loads GfxObj, Setup, and CellStruct physics data.
+- `ShadowObjectRegistry` gives the resolver a broadphase over nearby objects.
+- `TerrainSurface` uses triangle-aware terrain contact.
-Instead of porting the decompiled code line-by-line (as CLAUDE.md
-mandates), I wrote simplified approximations:
-- Static overlap instead of swept-sphere FindTimeOfCollision
-- Custom FindObjCollisions instead of porting Sphere.IntersectsSphere
-- Custom BSP query instead of porting BSPTree.find_collisions dispatcher
-- Ad-hoc push-out instead of proper SlideSphere crease-projection
-- Incremental patches that don't address root architectural issues
+This foundation is useful, but it is not complete retail collision parity.
+The project now tracks the remaining work as Phase L.2 - Movement & Collision
+Conformance:
-Each patch fixed one symptom but introduced new edge cases. The result
-is a patchwork that handles ~60-70% of cases but fails on the rest.
+- Plan: `docs/plans/2026-04-29-movement-collision-conformance.md`
+- Roadmap owner: `docs/plans/2026-04-11-roadmap.md`
+- Tactical follow-ups: `docs/ISSUES.md` #30-#34
-## What Must Happen Next
+## Durable Lesson
-**Delete the existing collision code and start fresh.** Port from ACE's
-complete C# implementation, cross-referencing the decompiled code for
-ground truth. ACE has the ENTIRE system already in C#:
+Do not guess at AC physics, movement packets, terrain/cell ownership, or
+collision constants. The previous patchwork failures came from simplified
+approximations:
-### Files to port from ACE (in order):
+- static overlap instead of swept-sphere transition behavior
+- custom object collision instead of retail `CSphere` / `CCylSphere`
+- incomplete BSP dispatch
+- ad-hoc push-out instead of retail slide / edge / precipice handling
+- server "no rubber-band" treated as proof of local collision correctness
-1. **Sphere.cs** — `IntersectsSphere` (FUN_005387c0), `SlideSphere` (both variants), `StepSphereUp`, `StepSphereDown`, `LandOnSphere`, `CollideWithPoint`, `CollidesWithSphere`
+The named retail decomp is now the primary source. Search
+`docs/research/named-retail/acclient_2013_pseudo_c.txt` by `class::method`
+before using older decompiled chunks or reference repos.
-2. **BSPTree.cs** — `find_collisions` (6-path dispatcher)
+## Active Approach
-3. **BSPNode.cs** — `sphere_intersects_poly` (tree traversal with movement), `find_walkable`, `hits_walkable`, `sphere_intersects_solid`
+Continue by conformance lanes rather than rewriting blindly:
-4. **BSPLeaf.cs** — leaf-level polygon tests
+1. **Truth & diagnostics (L.2a).** Add local placement/contact/cell,
+ object-hit, outbound-packet, server echo, and correction-delta probes.
+2. **Movement wire/contact authority (L.2b).** Fix contact byte and full-cell
+ truth before using ACE acceptance as evidence.
+3. **Transition parity (L.2c).** Port edge-slide, cliff-slide,
+ precipice-slide, step-up/down slide, and `NegPolyHit` dispatch.
+4. **Shape fidelity (L.2d).** Finish `CSphere` / `CCylSphere` semantics,
+ live-entity shapes, and building object identity.
+5. **Cell ownership (L.2e).** Port `CELLARRAY`, `find_cell_list`,
+ `check_other_cells`, `adjust_check_pos`, low-cell updates, and `cell_bsp`.
+6. **Real-DAT and live observer conformance (L.2f).** Promote every synthetic
+ case to real-world fixtures and retail-observer checks.
-5. **Polygon.cs** — `pos_hits_sphere`, `adjust_sphere_to_plane`, `check_walkable`
+## What To Preserve
-6. **Transition.cs** — `FindTransitionalPosition`, `TransitionalInsert`, `StepUp`, `StepDown`, `ValidateTransition`, `AdjustOffset`
+- `CollisionPrimitives.cs` low-level helpers, while auditing remaining shape
+ gaps against named retail.
+- `PhysicsDataCache.cs` DAT-backed collision data loading.
+- `ShadowObjectRegistry.cs` broadphase concept.
+- `TransitionTypes.cs` data structures and partial transition port.
+- `BSPQuery.cs` partial dispatcher as the current porting surface.
+- `PhysicsBody.cs`, `MotionInterpreter.cs`, and `PlayerWeenie.cs` foundations.
-7. **SpherePath.cs** — `SetCheckPos`, `AddOffsetToCheckPos`, `CacheLocalSpaceSphere`, `SetCollide`, `SetWalkable`, `SetNegPolyHit`
+## Known Gaps
-8. **CollisionInfo.cs** — `SetContactPlane`, `SetSlidingNormal`, `SetCollisionNormal`
+- Full `CELLARRAY` and adjacent-cell ownership are missing.
+- `cell_bsp` is not yet a first-class runtime owner.
+- Building portal transit and building entry/exit collision are incomplete.
+- `edge_slide`, `cliff_slide`, `precipice_slide`, and `NegPolyHit` behavior are
+ incomplete.
+- Live entity shape fidelity is simplified.
+- Outbound movement contact/cell fields can be overconfident.
+- Routine local/server correction diagnostics are missing.
-9. **ObjectInfo.cs** — `ValidateWalkable`
+## Retail Anchors
-10. **LandCell.cs** — `FindEnvCollisions` (outdoor terrain)
+Primary:
-11. **EnvCell.cs** — `FindEnvCollisions` (indoor BSP)
+- `docs/research/named-retail/acclient_2013_pseudo_c.txt`
+- `docs/research/named-retail/acclient.h`
+- `docs/research/named-retail/symbols.json`
-12. **ObjCell.cs** — `FindObjCollisions`, `find_cell_list`
+Key names:
-### ACE source locations:
-- `references/ACE/Source/ACE.Server/Physics/Sphere.cs`
-- `references/ACE/Source/ACE.Server/Physics/BSP/BSPTree.cs`
-- `references/ACE/Source/ACE.Server/Physics/BSP/BSPNode.cs`
-- `references/ACE/Source/ACE.Server/Physics/BSP/BSPLeaf.cs`
-- `references/ACE/Source/ACE.Server/Physics/Polygon.cs`
-- `references/ACE/Source/ACE.Server/Physics/Transition.cs`
-- `references/ACE/Source/ACE.Server/Physics/SpherePath.cs`
-- `references/ACE/Source/ACE.Server/Physics/Collision/CollisionInfo.cs`
-- `references/ACE/Source/ACE.Server/Physics/Collision/ObjectInfo.cs`
+- `CTransition::find_transitional_position`
+- `CTransition::transitional_insert`
+- `CTransition::step_up`
+- `CTransition::step_down`
+- `CTransition::edge_slide`
+- `CTransition::cliff_slide`
+- `SPHEREPATH::step_up_slide`
+- `SPHEREPATH::precipice_slide`
+- `SPHEREPATH::adjust_check_pos`
+- `CTransition::check_other_cells`
+- `CObjCell::find_cell_list`
+- `CPhysicsObj::is_valid_walkable`
+- `CBuildingObj::find_building_collisions`
+- `CCellStruct::sphere_intersects_cell`
+- `CCylSphere::intersects_sphere`
+- `CSphere::intersects_sphere`
+- `CSphere::slide_sphere`
-### Decompiled ground truth (named-retail is now primary, 2026-04-25):
-- **`docs/research/named-retail/acclient_2013_pseudo_c.txt`** — grep for
- `BSPTree::`, `BSPNode::`, `BSPLeaf::`, `CPolygon::`, `CCylSphere::`,
- `Transition::`, `CPhysicsObj::`, `SpherePath::` to find named bodies.
-- **`docs/research/named-retail/acclient.h`** — verbatim retail struct
- layouts for the BSP / Sphere / Transition types.
-- **`docs/research/named-retail/symbols.json`** — name↔address lookup.
-- `docs/research/decompiled/chunk_00530000.c` — older Ghidra fallback for
- BSP / Polygon / Sphere collision (FUN_xxx names).
-- `docs/research/decompiled/chunk_00500000.c` — older Ghidra fallback for
- PhysicsObj / transition callers.
-- `docs/research/acclient_function_map.md` — hand-curated cross-port index
- (ACE / ACME mappings + struct-offset notes).
+Older fallback:
-### Pseudocode (already written):
-- `docs/research/transition_pseudocode.md` — full system documented
+- `docs/research/decompiled/chunk_00530000.c`
+- `docs/research/decompiled/chunk_00500000.c`
+- `docs/research/acclient_function_map.md`
-## What to Keep
+Reference aids:
-- `CollisionPrimitives.cs` — 9 low-level functions already faithfully ported from decompiled code. These are CORRECT and match retail.
-- `PhysicsDataCache.cs` — GfxObj/Setup/CellStruct physics data loading from dats. Correct.
-- `ShadowObjectRegistry.cs` — cell-based spatial index. Correct concept, may need refinement.
-- `TransitionTypes.cs` data structures — SpherePath, CollisionInfo, ObjectInfo, PhysicsGlobals. Mostly correct, may need field additions.
-- `PhysicsBody.cs` — Euler integration. Correct.
-- `MotionInterpreter.cs` — Motion state machine. Correct.
-- `PlayerWeenie.cs` — Run/Jump formulas. Correct.
+- `references/ACE/Source/ACE.Server/Physics/`
+- `references/holtburger/` for movement wire behavior
+- `references/AC2D/` for the older client-side movement packet reference
-## What to Replace
+## Mandatory Workflow
-- `BSPQuery.cs` — replace with faithful port of BSPTree/BSPNode/BSPLeaf
-- `TransitionTypes.cs` Transition methods — replace FindTransitionalPosition, TransitionalInsert, FindEnvCollisions, FindObjCollisions, SlideSphere, AdjustOffset with faithful ports
-- `PhysicsEngine.ResolveWithTransition` — may need restructuring
+For every AC-specific function:
-## Approach (MANDATORY — per CLAUDE.md)
-
-For EVERY function:
-
-1. **GREP NAMED FIRST, then DECOMPILE FALLBACK.** Search the named
- retail decomp first: `grep -n "ClassName::Method" docs/research/named-retail/acclient_2013_pseudo_c.txt`.
- For struct layouts: `grep -n "^struct ClassName" docs/research/named-retail/acclient.h`.
- Only if the named pseudo-C lacks a function (rare), fall back to the
- older `docs/research/decompiled/` chunks via the function map at
- `docs/research/acclient_function_map.md`.
-
-2. **CROSS-REFERENCE ACE.** Read ACE's C# port of the same function.
- ACE provides naming and structure. Note any differences.
-
-3. **WRITE PSEUDOCODE.** Translate the decompiled C into readable
- pseudocode BEFORE porting to C#. Add to
- `docs/research/collision_port_pseudocode.md`.
-
-4. **PORT FAITHFULLY.** Translate pseudocode to C# line-by-line.
- Same variable names, same control flow, same boundary conditions.
- Do NOT "improve" or "simplify" the algorithm.
-
-5. **VERIFY.** When ACE and the decompiled code disagree, the
- decompiled code wins. Document the difference.
-
-### Execution order:
-
-1. Sphere collision (Sphere.cs) — FUN_005387c0 and sub-functions
-2. BSP tree (BSPTree/Node/Leaf) — find_collisions dispatcher
-3. Polygon tests (Polygon.cs) — pos_hits_sphere, adjust_sphere_to_plane
-4. Transition orchestrator (Transition.cs) — FindTransitionalPosition
-5. Cell collision (LandCell/EnvCell/ObjCell) — FindEnvCollisions, FindObjCollisions
-6. Wire into PhysicsEngine.ResolveWithTransition
-7. Test: terrain → indoor walls → objects → step-up → every object type
+1. Grep named retail first.
+2. Cross-reference ACE / holtburger / AC2D where relevant.
+3. Write readable pseudocode before porting.
+4. Port faithfully; do not simplify.
+5. Add conformance tests.
+6. Integrate surgically into the active L.2 lane.
+7. Verify with synthetic tests, real-DAT fixtures, and live observer evidence.
diff --git a/memory/project_movement_collision_conformance.md b/memory/project_movement_collision_conformance.md
new file mode 100644
index 00000000..5db6326d
--- /dev/null
+++ b/memory/project_movement_collision_conformance.md
@@ -0,0 +1,74 @@
+# Movement & Collision Conformance Crib
+
+## Phase
+
+Active phase: **L.2 - Movement & Collision Conformance**.
+
+Plan: `docs/plans/2026-04-29-movement-collision-conformance.md`.
+
+Roadmap: `docs/plans/2026-04-11-roadmap.md`.
+
+## One-Sentence Framing
+
+B.3 shipped the MVP resolver foundation; L.2 is the holistic conformance
+program for physics, collision, buildings, edge/wall sliding, cell ownership,
+movement packets, and server correction.
+
+## Active Movement Spine
+
+```text
+InputDispatcher / PlayerMovementController
+ -> MotionInterpreter + local body prediction
+ -> PhysicsEngine.ResolveWithTransition
+ -> TransitionTypes + BSPQuery + ShadowObjectRegistry
+ -> ResolveResult contact/cell state
+ -> MoveToState / AutonomousPosition outbound messages
+ -> WorldSession server echo or correction diagnostics
+```
+
+## Lane Ownership
+
+- L.2a: truth probes, diagnostics, fixture capture.
+- L.2b: movement wire/contact truth, cell id on packets, correction handling.
+- L.2c: transition parity, edge-slide, cliff-slide, precipice-slide,
+ `NegPolyHit`.
+- L.2d: `CSphere` / `CCylSphere`, live entity shapes, building object identity.
+- L.2e: outdoor seams, `CELLARRAY`, `find_cell_list`, adjacent-cell checks,
+ `cell_bsp`, building entry/exit boundaries.
+- L.2f: real-DAT fixtures and live retail-observer conformance.
+
+## Non-Negotiables
+
+- Grep named retail before changing AC-specific physics or movement behavior.
+- Do not treat ACE accepting a position as proof of retail collision parity.
+- Do not reintroduce rewrite-from-zero collision guidance. Continue the partial
+ retail port by L.2 lanes.
+- G.3 dungeon/portal delivery waits on L.2e for trustworthy cell/building
+ ownership.
+- L.1 animation work must coordinate with L.2 when root motion or observer
+ movement changes the predicted body path.
+
+## Shipped Slices
+
+- 2026-04-29: L.2a/L.2b first diagnostic slice. `ACDREAM_DUMP_MOVE_TRUTH=1`
+ logs `move-truth OUT` for outbound `MoveToState` / `AutonomousPosition` and
+ `move-truth ECHO` for player `UpdatePosition` echoes, including local/server
+ delta. `GameWindow` now passes explicit grounded/airborne contact bytes from
+ `MovementResult.IsOnGround` to both movement packet builders.
+- 2026-04-29: L.2e first cell-ownership fix. `ResolveWithTransition` refreshes
+ outdoor cell ownership from world position during the sphere sweep, so 24m
+ outdoor seams update low cell ids and full-cell callers crossing landblock
+ seams get the destination landblock prefix plus the correct outdoor low cell.
+- 2026-04-30: L.2c edge-slide plumbing. User live-tested wall-adjacent slide as
+ acceptable. Local player and remote dead-reckoning now pass retail-default
+ `ObjectInfoState.EdgeSlide`; `ACDREAM_DUMP_EDGE_SLIDE=1` logs failed
+ step-down edge cases and now reports whether walkable polygon context is
+ present before cliff/precipice handling.
+- 2026-04-30: L.2c precipice-slide context. Named retail
+ `SPHEREPATH::precipice_slide` and ACE `Polygon.find_crossed_edge` are now
+ captured in `docs/research/2026-04-30-precipice-slide-pseudocode.md`.
+ Terrain supplies exact walkable triangle vertices, BSP step-down/find-walkable
+ stores world-space walkable vertices for static object tops, and failed
+ step-down edge cases run the retail back-probe before precipice slide.
+ `cliff_slide` has a first port, but `NegPolyHit`, `CELLARRAY`, full
+ `cell_bsp`, and real-DAT building portal conformance remain open L.2 work.
diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index 8084231a..64f9f5c8 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -97,11 +97,25 @@ public sealed class PlayerMovementController
///
/// Maximum Z increase per movement step before the move is rejected.
- /// AC's default StepUpHeight for human characters is ~2 units.
- /// Using 5 for the MVP to be forgiving — prevents walking up vertical
- /// walls but allows stairs, ramps, and terrain slopes.
+ /// Retail's step_up_height for human characters is ~0.4 m (hip-
+ /// level). Setting this too high lets the player teleport up small
+ /// buildings via the step-up scan finding any walkable polygon within
+ /// reach (Bug 3 in L.2.3 testing — walking into a steep slope mounted
+ /// the building's flat top instead of sliding off the slope).
+ /// Authoritative source is the player's Setup.StepUpHeight set
+ /// in GameWindow.cs at world-entry time.
///
- public float StepUpHeight { get; set; } = 5.0f;
+ public float StepUpHeight { get; set; } = 0.4f;
+
+ ///
+ /// L.2.3a (2026-04-29): how far below the foot the step-down probe
+ /// reaches when transitioning between surfaces. Retail's
+ /// step_down_height for human characters is ~0.4 m. With the
+ /// previous 4 cm hardcoded value, walking off the top of a stair onto
+ /// the ground 25 cm below produced a one-frame contact-plane gap — the
+ /// animation system briefly flickered to falling.
+ ///
+ public float StepDownHeight { get; set; } = 0.4f;
///
/// Current portal-space state. Set to PortalSpace when the server sends
@@ -411,19 +425,112 @@ public sealed class PlayerMovementController
sphereRadius: 0.48f, // human player radius from Setup
sphereHeight: 1.2f, // human player height from Setup
stepUpHeight: StepUpHeight,
- stepDownHeight: 0.04f, // retail default
+ stepDownHeight: StepDownHeight, // L.2.3a: from Setup.StepDownHeight
isOnGround: _body.OnWalkable,
body: _body, // persist ContactPlane across frames for slope tracking
+ // L.2c 2026-04-30: retail PhysicsGlobals.DefaultState includes
+ // EdgeSlide, and PhysicsObj.get_object_info copies that bit into
+ // OBJECTINFO. Keep it explicit here so edge/cliff handling runs
+ // under the same flag profile as retail player movement.
+ //
// Commit C 2026-04-29 — local player is always IsPlayer.
// The PK/PKLite/Impenetrable bits come from PlayerDescription's
// PlayerKillerStatus property; not yet parsed (non-PK pair → walks
// through other non-PK players, which is retail's default for
// ACE's character creation defaults too).
- moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer);
+ moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer
+ | AcDream.Core.Physics.ObjectInfoState.EdgeSlide);
// Apply resolved position.
_body.Position = resolveResult.Position;
+ // L.3a (2026-04-30): retail wall-bounce / velocity reflection.
+ //
+ // Retail's CPhysicsObj::handle_all_collisions runs after every
+ // SetPositionInternal. It reads the wall normal that the
+ // transition's slide computed and reflects the body's velocity:
+ //
+ // v_new = v - (1 + elasticity) * dot(v, n) * n
+ //
+ // This is what gives retail its "bouncy" feel — fast head-on
+ // jumps push the player back from the wall, glancing angles
+ // produce a small deflection. acdream's transition resolver
+ // SLID position correctly but never updated velocity, so the
+ // player kept driving into walls until the controller's input
+ // changed direction. Felt sticky / fragile.
+ //
+ // Suppression rule (apply_bounce): grounded movement on a wall
+ // SHOULDN'T bounce — sliding along a corridor is expected. Only
+ // airborne wall hits reflect. Mirrors retail's `var_10_1` guard
+ // and ACE PhysicsObj.cs:2656-2660 `apply_bounce`.
+ //
+ // Inelastic flag (spell projectiles, missiles) zeros velocity
+ // entirely instead of reflecting. The player never has it set.
+ //
+ // Sources:
+ // acclient_2013_pseudo_c.txt:282699-282715 (handle_all_collisions)
+ // acclient.h:2834 (INELASTIC_PS = 0x20000)
+ // ACE PhysicsObj.cs:2656-2721 (line-for-line port)
+ // PhysicsGlobals.DefaultElasticity = 0.05f, MaxElasticity = 0.1f
+ if (resolveResult.CollisionNormalValid)
+ {
+ bool prevOnWalkable = _body.OnWalkable;
+ bool nowOnWalkable = resolveResult.IsOnGround;
+
+ // apply_bounce: bounce ONLY when the body stays airborne both
+ // before and after this step. That is: jumping into a wall
+ // mid-flight, hitting a ceiling, etc. Specifically NOT:
+ //
+ // - prev grounded + now grounded → wall-slide along corridor
+ // (bounce would feel sticky on every wall touch).
+ // - prev airborne + now grounded → terrain landing
+ // (terrain normal is mostly +Z; reflecting downward velocity
+ // would push the body upward and prevent the landing snap
+ // from firing — player perpetually micro-bouncing on the
+ // floor instead of resting).
+ // - prev grounded + now airborne → walked off cliff
+ // (gravity should take over, not lateral bounce).
+ //
+ // Sledding mode reverts to retail's broader rule (bounce
+ // unless both grounded), since sledding intentionally bounces
+ // off ramps.
+ //
+ // This is more conservative than retail's strict
+ // `!(prev && now && !sledding)` rule — retail bounces on
+ // landing too, but at elasticity 0.05 the visual effect is
+ // imperceptible there. acdream's per-frame architecture
+ // amplifies the artifact (the post-reflection upward Z
+ // defeats the controller's `Velocity.Z <= 0` landing-snap
+ // gate), so we suppress it on landing to avoid the
+ // micro-bounce death spiral.
+ bool applyBounce = _body.State.HasFlag(PhysicsStateFlags.Sledding)
+ ? !(prevOnWalkable && nowOnWalkable)
+ : (!prevOnWalkable && !nowOnWalkable);
+
+ if (applyBounce)
+ {
+ if (_body.State.HasFlag(PhysicsStateFlags.Inelastic))
+ {
+ // Full stop on impact. Spell projectiles / missiles.
+ _body.Velocity = Vector3.Zero;
+ }
+ else
+ {
+ var v = _body.Velocity;
+ var n = resolveResult.CollisionNormal;
+ float dotVN = Vector3.Dot(v, n);
+ if (dotVN < 0f)
+ {
+ // Reflect the into-wall component back out.
+ // Player elasticity is 0.05 → 105% of perpendicular
+ // velocity reflects (subtle bounce).
+ float k = -(dotVN * (_body.Elasticity + 1f));
+ _body.Velocity = v + n * k;
+ }
+ }
+ }
+ }
+
bool justLanded = false;
if (resolveResult.IsOnGround)
{
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index ba3c9781..771c37d3 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -409,6 +409,8 @@ public sealed class GameWindow : IDisposable
private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _debugVm;
private static readonly bool DevToolsEnabled =
Environment.GetEnvironmentVariable("ACDREAM_DEVTOOLS") == "1";
+ private static readonly bool DumpMoveTruthEnabled =
+ Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOVE_TRUTH") == "1";
// Phase I.3 — real ICommandBus for live sessions. Constructed when
// the live session spins up (so SendChatCmd handlers can close over
@@ -464,6 +466,19 @@ public sealed class GameWindow : IDisposable
private uint? _playerCurrentAnimCommand;
private float _playerCurrentAnimSpeed = 1f;
private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character
+ private MovementTruthOutbound? _lastMovementTruthOutbound;
+
+ private readonly record struct MovementTruthOutbound(
+ string Kind,
+ uint Sequence,
+ System.DateTime TimeUtc,
+ System.Numerics.Vector3 LocalWorldPosition,
+ uint LocalCellId,
+ System.Numerics.Vector3 WirePosition,
+ uint WireCellId,
+ bool IsOnGround,
+ byte ContactByte,
+ System.Numerics.Vector3 Velocity);
// K-fix7 (2026-04-26): server-authoritative Run + Jump skill values
// received from PlayerDescription. -1 = "not yet received, fall back
@@ -3076,6 +3091,7 @@ public sealed class GameWindow : IDisposable
0f);
var worldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ) + origin;
var rot = new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
+ DumpMovementTruthServerEcho(update, worldPos);
// Capture the pre-update render position for the soft-snap residual
// calculation below. Assign entity.Position to the server truth up
@@ -4853,6 +4869,7 @@ public sealed class GameWindow : IDisposable
uint wireCellId = ((uint)lbX << 24) | ((uint)lbY << 16) | (result.CellId & 0xFFFFu);
var wirePos = new System.Numerics.Vector3(localX, localY, result.Position.Z);
var wireRot = YawToAcQuaternion(_playerController.Yaw);
+ byte contactByte = result.IsOnGround ? (byte)1 : (byte)0;
if (result.MotionStateChanged)
{
@@ -4885,7 +4902,10 @@ public sealed class GameWindow : IDisposable
instanceSequence: _liveSession.InstanceSequence,
serverControlSequence: _liveSession.ServerControlSequence,
teleportSequence: _liveSession.TeleportSequence,
- forcePositionSequence: _liveSession.ForcePositionSequence);
+ forcePositionSequence: _liveSession.ForcePositionSequence,
+ contactLongJump: contactByte);
+ DumpMovementTruthOutbound(
+ "MTS", seq, result, wirePos, wireCellId, contactByte);
_liveSession.SendGameAction(body);
}
@@ -4900,7 +4920,10 @@ public sealed class GameWindow : IDisposable
instanceSequence: _liveSession.InstanceSequence,
serverControlSequence: _liveSession.ServerControlSequence,
teleportSequence: _liveSession.TeleportSequence,
- forcePositionSequence: _liveSession.ForcePositionSequence);
+ forcePositionSequence: _liveSession.ForcePositionSequence,
+ lastContact: contactByte);
+ DumpMovementTruthOutbound(
+ "AP", seq, result, wirePos, wireCellId, contactByte);
_liveSession.SendGameAction(body);
}
@@ -4924,6 +4947,76 @@ public sealed class GameWindow : IDisposable
}
}
+ private void DumpMovementTruthOutbound(
+ string kind,
+ uint sequence,
+ AcDream.App.Input.MovementResult result,
+ System.Numerics.Vector3 wirePosition,
+ uint wireCellId,
+ byte contactByte)
+ {
+ if (!DumpMoveTruthEnabled) return;
+
+ var velocity = _playerController?.BodyVelocity ?? System.Numerics.Vector3.Zero;
+ _lastMovementTruthOutbound = new MovementTruthOutbound(
+ kind,
+ sequence,
+ System.DateTime.UtcNow,
+ result.Position,
+ result.CellId,
+ wirePosition,
+ wireCellId,
+ result.IsOnGround,
+ contactByte,
+ velocity);
+
+ Console.WriteLine(System.FormattableString.Invariant($"move-truth OUT kind={kind} seq={sequence} local={Fmt(result.Position)} localCell=0x{result.CellId:X8} wire={Fmt(wirePosition)} wireCell=0x{wireCellId:X8} grounded={result.IsOnGround} contact={contactByte} vel={Fmt(velocity)} f={FmtCmd(result.ForwardCommand)} s={FmtCmd(result.SidestepCommand)} t={FmtCmd(result.TurnCommand)}"));
+ }
+
+ private void DumpMovementTruthServerEcho(
+ AcDream.Core.Net.WorldSession.EntityPositionUpdate update,
+ System.Numerics.Vector3 serverWorldPosition)
+ {
+ if (!DumpMoveTruthEnabled || update.Guid != _playerServerGuid) return;
+
+ var now = System.DateTime.UtcNow;
+ var localPosition = _playerController?.Position;
+ var localCellId = _playerController?.CellId;
+ var deltaLocal = localPosition.HasValue
+ ? serverWorldPosition - localPosition.Value
+ : (System.Numerics.Vector3?)null;
+
+ string localText = localPosition.HasValue ? Fmt(localPosition.Value) : "-";
+ string localCellText = localCellId.HasValue
+ ? System.FormattableString.Invariant($"0x{localCellId.Value:X8}")
+ : "-";
+ string deltaLocalText = deltaLocal.HasValue ? Fmt(deltaLocal.Value) : "-";
+ string deltaLocalLen = deltaLocal.HasValue
+ ? System.FormattableString.Invariant($"{deltaLocal.Value.Length():F3}")
+ : "-";
+
+ string lastText = "-";
+ if (_lastMovementTruthOutbound is { } last)
+ {
+ var deltaOut = serverWorldPosition - last.LocalWorldPosition;
+ var ageMs = (now - last.TimeUtc).TotalMilliseconds;
+ lastText = System.FormattableString.Invariant($"{last.Kind}:{last.Sequence} ageMs={ageMs:F0} outGrounded={last.IsOnGround} outContact={last.ContactByte} outCell=0x{last.WireCellId:X8} deltaOut={Fmt(deltaOut)} distOut={deltaOut.Length():F3}");
+ }
+
+ string state = _playerController?.State.ToString() ?? "-";
+ string velocityText = update.Velocity.HasValue ? Fmt(update.Velocity.Value) : "-";
+
+ Console.WriteLine(System.FormattableString.Invariant($"move-truth ECHO guid=0x{update.Guid:X8} server={Fmt(serverWorldPosition)} serverCell=0x{update.Position.LandblockId:X8} local={localText} localCell={localCellText} deltaLocal={deltaLocalText} distLocal={deltaLocalLen} serverVel={velocityText} state={state} lastOut={lastText}"));
+ }
+
+ private static string Fmt(System.Numerics.Vector3 v) =>
+ System.FormattableString.Invariant($"({v.X:F3},{v.Y:F3},{v.Z:F3})");
+
+ private static string FmtCmd(uint? command) =>
+ command.HasValue
+ ? System.FormattableString.Invariant($"0x{command.Value:X8}")
+ : "-";
+
///
/// Convert our internal yaw (math convention: 0=+X East, PI/2=+Y North)
/// to AC's quaternion heading convention.
@@ -5755,15 +5848,19 @@ public sealed class GameWindow : IDisposable
preIntegratePos, postIntegratePos, rm.CellId,
sphereRadius: 0.48f,
sphereHeight: 1.2f,
- stepUpHeight: 2.0f, // retail default for unknown remotes
- stepDownHeight: 0.04f, // PhysicsGlobals.DefaultStepHeight
+ 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 —
// airborne remotes must NOT pre-seed the
// ContactPlane, otherwise AdjustOffset's snap-to-plane
// branch zeroes the +Z offset every step (same bug
// we hit on the local jump).
isOnGround: !rm.Airborne,
- body: rm.Body); // persist ContactPlane across frames for slope tracking
+ body: rm.Body, // persist ContactPlane across frames for slope tracking
+ // Retail default physics state includes EdgeSlide.
+ // Remote dead-reckoning should exercise the same
+ // edge/cliff branch as local movement.
+ moverFlags: AcDream.Core.Physics.ObjectInfoState.EdgeSlide);
rm.Body.Position = resolveResult.Position;
if (resolveResult.CellId != 0)
@@ -7016,7 +7113,13 @@ public sealed class GameWindow : IDisposable
_playerController.SetCharacterSkills(_lastSeenRunSkill, _lastSeenJumpSkill);
Console.WriteLine($"live: {loggingTag} — applied server skills run={_lastSeenRunSkill} jump={_lastSeenJumpSkill}");
}
- // Read the real step height from the player's Setup dat.
+ // Read the real step heights from the player's Setup dat.
+ // L.2.3a (2026-04-29): retail's Setup.StepUpHeight for humans is
+ // ~0.4 m, NOT 2 m. With 2 m fallback the step-up scan reached
+ // small-building roofs and teleported the player onto them. Same
+ // for StepDownHeight — was hardcoded 0.04 m, causing stair-top
+ // contact-plane gaps. Both now come from Setup with retail-realistic
+ // 0.4 m fallbacks.
if (_dats is not null && (playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
{
var playerSetup = _dats.Get(playerEntity.SourceGfxObjOrSetupId);
@@ -7024,11 +7127,26 @@ public sealed class GameWindow : IDisposable
_physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, playerSetup);
_playerController.StepUpHeight = (playerSetup is not null && playerSetup.StepUpHeight > 0f)
? playerSetup.StepUpHeight
- : 2f;
+ : 0.4f;
+ _playerController.StepDownHeight = (playerSetup is not null && playerSetup.StepDownHeight > 0f)
+ ? playerSetup.StepDownHeight
+ : 0.4f;
+ // L.2.3f (2026-04-29): diagnostic — confirm what the actual
+ // values from the player's Setup dat are. Retail's spec says ~0.4 m
+ // for humans, but we want to verify rather than guess. If the
+ // dat-derived value is large (e.g. 1.5 m+) it explains why the
+ // player can mount steep roofs via the step-up scan reach.
+ Console.WriteLine(
+ $"physics: player step heights — StepUp={_playerController.StepUpHeight:F3} m " +
+ $"(Setup.StepUpHeight={(playerSetup?.StepUpHeight ?? 0f):F3}), " +
+ $"StepDown={_playerController.StepDownHeight:F3} m " +
+ $"(Setup.StepDownHeight={(playerSetup?.StepDownHeight ?? 0f):F3})");
}
else
{
- _playerController.StepUpHeight = 2f;
+ _playerController.StepUpHeight = 0.4f;
+ _playerController.StepDownHeight = 0.4f;
+ Console.WriteLine($"physics: player step heights — defaulting to 0.4 m (no setup dat)");
}
int plbX = _liveCenterX + (int)MathF.Floor(playerEntity.Position.X / 192f);
int plbY = _liveCenterY + (int)MathF.Floor(playerEntity.Position.Y / 192f);
diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs
index d574887b..4b68d428 100644
--- a/src/AcDream.Core.Net/Messages/CreateObject.cs
+++ b/src/AcDream.Core.Net/Messages/CreateObject.cs
@@ -120,7 +120,13 @@ public static class CreateObject
ushort ServerControlSequence = 0,
ushort ForcePositionSequence = 0,
uint? PhysicsState = null,
- uint? ObjectDescriptionFlags = null);
+ uint? ObjectDescriptionFlags = null,
+ // L.3b (2026-04-30): per-object friction + elasticity from the
+ // wire. Default to null when their PhysicsDescriptionFlag bits
+ // weren't set; subscribers fall back to PhysicsBody constructor
+ // defaults (0.05f elasticity, 0.5f friction).
+ float? Friction = null,
+ float? Elasticity = null);
///
/// The relevant subset of the server-sent MovementData /
@@ -286,6 +292,13 @@ public static class CreateObject
// "ObjectDescriptionFlags" at the WeenieHeader trailer.
uint? physicsState = null;
uint? objectDescriptionFlags = null;
+ // L.3b (2026-04-30): per-object friction + elasticity. Wire-encoded
+ // when their PhysicsDescriptionFlag bits are set. Default values
+ // come from PhysicsBody constructors; these overrides drive the
+ // velocity-reflection bounce magnitude per object (e.g., bouncier
+ // platforms vs. inert walls).
+ float? friction = null;
+ float? elasticity = null;
try
{
@@ -453,8 +466,25 @@ public static class CreateObject
objScale = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
- if ((physicsFlags & PhysicsDescriptionFlag.Friction) != 0) pos += 4;
- if ((physicsFlags & PhysicsDescriptionFlag.Elasticity) != 0) pos += 4;
+ if ((physicsFlags & PhysicsDescriptionFlag.Friction) != 0)
+ {
+ if (body.Length - pos < 4) return PartialResult();
+ friction = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ pos += 4;
+ }
+ if ((physicsFlags & PhysicsDescriptionFlag.Elasticity) != 0)
+ {
+ // L.3b (2026-04-30): capture instead of skipping. The wire
+ // float is the per-object elasticity used by the velocity-
+ // reflection bounce (CPhysicsObj::set_elasticity at
+ // acclient_2013_pseudo_c.txt:277817, clamped to [0, 0.1]).
+ // Was previously dropped — every object got the default
+ // 0.05f, so server-set bouncier surfaces felt identical to
+ // walls.
+ if (body.Length - pos < 4) return PartialResult();
+ elasticity = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ pos += 4;
+ }
if ((physicsFlags & PhysicsDescriptionFlag.Translucency) != 0) pos += 4;
if ((physicsFlags & PhysicsDescriptionFlag.Velocity) != 0) pos += 12; // vec3
if ((physicsFlags & PhysicsDescriptionFlag.Acceleration) != 0) pos += 12;
@@ -510,14 +540,18 @@ public static class CreateObject
return new Parsed(guid, position, setupTableId, animParts,
textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId,
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq,
- physicsState, objectDescriptionFlags);
+ physicsState, objectDescriptionFlags,
+ friction, elasticity);
// Local helper: if we ran out of fields past PhysicsData, still
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
Parsed PartialResult() => new(
guid, position, setupTableId, animParts,
textureChanges, subPalettes, basePaletteId, objScale, null, null, motionState, motionTableId,
- PhysicsState: physicsState, ObjectDescriptionFlags: objectDescriptionFlags);
+ PhysicsState: physicsState,
+ ObjectDescriptionFlags: objectDescriptionFlags,
+ Friction: friction,
+ Elasticity: elasticity);
}
catch
{
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 030573b8..47ef10bc 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -63,7 +63,13 @@ public sealed class WorldSession : IDisposable
// ObjectDescriptionFlags: retail PWD._bitfield (acclient.h:6431-6463)
// — drives IsPlayer/IsPK/IsPKLite/IsImpenetrable for PvP gating.
uint? PhysicsState = null,
- uint? ObjectDescriptionFlags = null);
+ uint? ObjectDescriptionFlags = null,
+ // L.3b (2026-04-30): per-object physics tuning from the wire.
+ // Friction defaults to PhysicsBody constructor value (0.5f).
+ // Elasticity defaults to 0.05f. When set, drives the velocity-
+ // reflection bounce magnitude (clamped to [0, 0.1] retail-side).
+ float? Friction = null,
+ float? Elasticity = null);
/// Fires when the session finishes parsing a CreateObject.
public event Action? EntitySpawned;
@@ -657,7 +663,9 @@ public sealed class WorldSession : IDisposable
parsed.Value.MotionState,
parsed.Value.MotionTableId,
parsed.Value.PhysicsState,
- parsed.Value.ObjectDescriptionFlags));
+ parsed.Value.ObjectDescriptionFlags,
+ parsed.Value.Friction,
+ parsed.Value.Elasticity));
}
}
else if (op == DeleteObject.Opcode)
diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs
index dfef08bd..b86ccc4f 100644
--- a/src/AcDream.Core/Physics/BSPQuery.cs
+++ b/src/AcDream.Core/Physics/BSPQuery.cs
@@ -377,30 +377,33 @@ public static class BSPQuery
///
/// ACE: Polygon.cs find_crossed_edge.
///
- private static bool FindCrossedEdge(
- ResolvedPolygon poly,
- CollisionSphere sphere,
- Vector3 up,
- ref Vector3 normal)
+ internal static bool FindCrossedEdge(
+ Plane polyPlane,
+ ReadOnlySpan verts,
+ Vector3 sphereCenter,
+ Vector3 up,
+ out Vector3 normal)
{
- float angleUp = Vector3.Dot(poly.Plane.Normal, up);
+ normal = Vector3.Zero;
+
+ float angleUp = Vector3.Dot(polyPlane.Normal, up);
if (MathF.Abs(angleUp) < PhysicsGlobals.EPSILON) return false;
- float angle = (Vector3.Dot(poly.Plane.Normal, sphere.Center) + poly.Plane.D) / angleUp;
- var center = sphere.Center - up * angle;
+ float angle = (Vector3.Dot(polyPlane.Normal, sphereCenter) + polyPlane.D) / angleUp;
+ var center = sphereCenter - up * angle;
- int n = poly.Vertices.Length;
+ int n = verts.Length;
int prevIdx = n - 1;
for (int i = 0; i < n; i++)
{
- var v = poly.Vertices[i];
- var lv = poly.Vertices[prevIdx];
+ var v = verts[i];
+ var lv = verts[prevIdx];
prevIdx = i;
var edge = v - lv;
var disp = center - lv;
- var cross = Vector3.Cross(poly.Plane.Normal, edge);
+ var cross = Vector3.Cross(polyPlane.Normal, edge);
if (Vector3.Dot(disp, cross) < 0f)
{
@@ -412,6 +415,47 @@ public static class BSPQuery
return false;
}
+ private static bool FindCrossedEdge(
+ ResolvedPolygon poly,
+ CollisionSphere sphere,
+ Vector3 up,
+ ref Vector3 normal)
+ {
+ if (!FindCrossedEdge(poly.Plane, poly.Vertices, sphere.Center, up, out var crossedNormal))
+ return false;
+
+ normal = crossedNormal;
+ return true;
+ }
+
+ private static Vector3 TransformNormal(Vector3 normal, Quaternion localToWorld)
+ {
+ var worldNormal = Vector3.Transform(normal, localToWorld);
+ return worldNormal.LengthSquared() > PhysicsGlobals.EpsilonSq
+ ? Vector3.Normalize(worldNormal)
+ : Vector3.UnitZ;
+ }
+
+ private static Vector3[] TransformVertices(
+ ReadOnlySpan vertices,
+ Quaternion localToWorld,
+ float scale,
+ Vector3 worldOrigin)
+ {
+ var result = new Vector3[vertices.Length];
+ for (int i = 0; i < vertices.Length; i++)
+ result[i] = Vector3.Transform(vertices[i] * scale, localToWorld) + worldOrigin;
+ return result;
+ }
+
+ private static Plane BuildWorldPlane(Vector3 worldNormal, ReadOnlySpan worldVertices)
+ {
+ float d = worldVertices.Length > 0
+ ? -Vector3.Dot(worldNormal, worldVertices[0])
+ : 0f;
+ return new Plane(worldNormal, d);
+ }
+
// -------------------------------------------------------------------------
// adjust_to_placement_poly
// ACE: Polygon.cs adjust_to_placement_poly
@@ -1037,7 +1081,8 @@ public static class BSPQuery
CollisionSphere checkPos,
Vector3 up,
float scale,
- Quaternion localToWorld = default)
+ Quaternion localToWorld = default,
+ Vector3 worldOrigin = default)
{
if (localToWorld == default) localToWorld = Quaternion.Identity;
@@ -1061,14 +1106,12 @@ public static class BSPQuery
var offset = Vector3.Transform(adjusted, localToWorld) * scale;
path.AddOffsetToCheckPos(offset);
- var worldNormal = Vector3.Transform(polyHit.Plane.Normal, localToWorld);
- collisions.SetContactPlane(
- new Plane(worldNormal, polyHit.Plane.D * scale),
- path.CheckCellId, false);
+ var worldNormal = TransformNormal(polyHit.Plane.Normal, localToWorld);
+ var worldVertices = TransformVertices(polyHit.Vertices, localToWorld, scale, worldOrigin);
+ var worldPlane = BuildWorldPlane(worldNormal, worldVertices);
+ collisions.SetContactPlane(worldPlane, path.CheckCellId, false);
- path.WalkableValid = true;
- path.WalkablePlane = new Plane(worldNormal, polyHit.Plane.D * scale);
- path.WalkableAllowance = PhysicsGlobals.FloorZ;
+ path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ);
return TransitionState.Adjusted;
}
@@ -1085,34 +1128,28 @@ public static class BSPQuery
/// BSPTree.step_sphere_up — attempt to step over a low obstacle.
///
///
- /// Sets the StepUp flag on SpherePath with the collision normal.
- /// The Transition's outer loop will pick this up and attempt the step.
- /// If StepUp is already pending, falls back to setting the collision normal
- /// directly (StepUpSlide equivalent).
+ /// Calls which probes upward then steps
+ /// down to find a walkable landing surface. If the step-up succeeds the
+ /// sphere's CheckPos is already updated and we return OK. If it fails we
+ /// fall back to StepUpSlide: clear the contact plane and slide along the
+ /// collision normal.
///
///
- /// ACE: BSPTree.cs step_sphere_up.
+ ///
+ /// ACE: BSPTree.step_sphere_up calls transition.StepUp(globNormal);
+ /// on false → SpherePath.StepUpSlide(transition).
+ /// Named-retail: BSPTREE::step_sphere_up.
+ ///
///
private static TransitionState StepSphereUp(
- Transition transition,
- Vector3 collisionNormal)
+ Transition transition,
+ Vector3 collisionNormal,
+ PhysicsEngine engine)
{
- var path = transition.SpherePath;
- var ci = transition.CollisionInfo;
-
- // ACE calls transition.StepUp(globNormal); if false -> path.StepUpSlide(transition).
- // In acdream, StepUp is a flag field on SpherePath.
- // If no StepUp is pending yet, request one.
- if (!path.StepUp)
- {
- path.StepUp = true;
- path.StepUpNormal = collisionNormal;
+ if (transition.DoStepUp(collisionNormal, engine!))
return TransitionState.OK;
- }
- // StepUpSlide: can't step up, set collision normal and report adjusted.
- ci.SetCollisionNormal(collisionNormal);
- return TransitionState.Adjusted;
+ return transition.SpherePath.StepUpSlide(transition);
}
// -------------------------------------------------------------------------
@@ -1364,7 +1401,9 @@ public static class BSPQuery
Vector3 localCurrCenter,
Vector3 localSpaceZ,
float scale,
- Quaternion localToWorld = default)
+ Quaternion localToWorld = default,
+ PhysicsEngine? engine = null,
+ Vector3 worldOrigin = default)
{
if (root is null) return TransitionState.OK;
// Default quaternion (0,0,0,0) → treat as identity
@@ -1415,7 +1454,7 @@ public static class BSPQuery
// ----------------------------------------------------------------
if (path.StepDown)
{
- return StepSphereDown(root, resolved, transition, sphere0, localSpaceZ, scale, localToWorld);
+ return StepSphereDown(root, resolved, transition, sphere0, localSpaceZ, scale, localToWorld, worldOrigin);
}
// ----------------------------------------------------------------
@@ -1438,14 +1477,12 @@ public static class BSPQuery
var worldOffset = L2W(localOffset) * scale;
path.AddOffsetToCheckPos(worldOffset);
- var worldNormal = L2W(hitPoly.Plane.Normal);
- collisions.SetContactPlane(
- new Plane(worldNormal, hitPoly.Plane.D * scale),
- path.CheckCellId, false);
+ var worldNormal = TransformNormal(hitPoly.Plane.Normal, localToWorld);
+ var worldVertices = TransformVertices(hitPoly.Vertices, localToWorld, scale, worldOrigin);
+ var worldPlane = BuildWorldPlane(worldNormal, worldVertices);
+ collisions.SetContactPlane(worldPlane, path.CheckCellId, false);
- path.WalkableValid = true;
- path.WalkablePlane = new Plane(worldNormal, hitPoly.Plane.D * scale);
- path.WalkableAllowance = PhysicsGlobals.FloorZ;
+ path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ);
return TransitionState.Adjusted;
}
@@ -1453,12 +1490,15 @@ public static class BSPQuery
}
// ----------------------------------------------------------------
- // Path 5: Contact — sphere_intersects_poly + wall-slide
- // ACE retail uses StepSphereUp here, deferring to a retry loop that
- // executes the step-up motion. We haven't ported that execution, so
- // we apply the same wall-slide response as Path 6 — this at least
- // gives correct blocking + sliding behavior for walls, buildings,
- // and tree trunks while the player is on the ground.
+ // Path 5: Contact (grounded) — sphere_intersects_poly + step_sphere_up
+ //
+ // A grounded mover hits a polygon. Retail calls BSPTREE::step_sphere_up,
+ // which runs CTransition::step_up (upward probe + step-down scan). If the
+ // obstacle is short enough the sphere climbs it; if too tall, it falls back
+ // to StepUpSlide (clear contact-plane, slide along StepUpNormal).
+ //
+ // ACE: BSPTree.find_collisions → step_sphere_up (BSPTree.cs, path 5 branch).
+ // Named-retail: BSPTREE::find_collisions Contact branch → step_sphere_up.
// ----------------------------------------------------------------
if (obj.State.HasFlag(ObjectInfoState.Contact))
{
@@ -1470,26 +1510,19 @@ public static class BSPQuery
if (hit0 || hitPoly0 is not null)
{
- // Wall-slide response (same as Path 6 below).
- var localNormal = hitPoly0!.Plane.Normal;
- var localMovement = sphere0.Center - localCurrCenter;
+ var worldNormal = L2W(hitPoly0!.Plane.Normal);
+ // L.2.3b (2026-04-29): recursion guard. Retail
+ // (acclient_2013_pseudo_c.txt:272954) gates step_sphere_up on
+ // `if (sp.step_up == 0 && sp.step_down == 0)`. Without this,
+ // the inner TransitionalInsert spawned by DoStepDown re-enters
+ // FindObjCollisions, hits the same wall, and recursively
+ // re-invokes step-up — churning the contact plane until
+ // numAttempts decays. Mid-recursion we fall back to wall-slide.
+ if (engine is not null && !path.StepUp && !path.StepDown)
+ return StepSphereUp(transition, worldNormal, engine);
- float movementIntoWall = Vector3.Dot(localMovement, localNormal);
- Vector3 projectedMovement = localMovement - localNormal * movementIntoWall;
-
- Vector3 slidPos = localCurrCenter + projectedMovement;
- float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly0.Plane.D;
- float minDist = sphere0.Radius + 0.01f;
- if (slidDist < minDist)
- {
- slidPos += localNormal * (minDist - slidDist);
- }
-
- Vector3 localDelta = slidPos - sphere0.Center;
- Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale;
- path.AddOffsetToCheckPos(worldDelta);
-
- var worldNormal = L2W(localNormal);
+ // No engine OR step-up/step-down already in progress — fall
+ // back to wall-slide so the inner sphere doesn't recurse.
collisions.SetCollisionNormal(worldNormal);
collisions.SetSlidingNormal(worldNormal);
return TransitionState.Slid;
@@ -1505,25 +1538,11 @@ public static class BSPQuery
if (hit1 || hitPoly1 is not null)
{
- var localNormal = hitPoly1!.Plane.Normal;
- var localMovement = sphere1.Center - localCurrCenter;
+ var worldNormal = L2W(hitPoly1!.Plane.Normal);
+ // L.2.3b: same recursion guard as the foot-sphere branch.
+ if (engine is not null && !path.StepUp && !path.StepDown)
+ return StepSphereUp(transition, worldNormal, engine);
- float movementIntoWall = Vector3.Dot(localMovement, localNormal);
- Vector3 projectedMovement = localMovement - localNormal * movementIntoWall;
-
- Vector3 slidPos = localCurrCenter + projectedMovement;
- float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly1.Plane.D;
- float minDist = sphere1.Radius + 0.01f;
- if (slidDist < minDist)
- {
- slidPos += localNormal * (minDist - slidDist);
- }
-
- Vector3 localDelta = slidPos - sphere1.Center;
- Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale;
- path.AddOffsetToCheckPos(worldDelta);
-
- var worldNormal = L2W(localNormal);
collisions.SetCollisionNormal(worldNormal);
collisions.SetSlidingNormal(worldNormal);
return TransitionState.Slid;
@@ -1553,50 +1572,19 @@ public static class BSPQuery
hitPoly0!, contact0, scale, localToWorld);
}
- // ─── Wall-slide response ─────────────────────────────────
- // Instead of just pushing the sphere out of penetration
- // (which undoes the whole step), compute the wall-slide
- // position: where the sphere WOULD be if the movement had
- // been projected along the wall tangent.
+ // ─── SetCollide response ─────────────────────────────────
+ // Airborne sphere hits a polygon. Per retail, call SetCollide
+ // which saves backup position, records StepUpNormal = worldNormal,
+ // and sets WalkInterp=1. TransitionalInsert's Collide branch will
+ // then re-test as Placement to confirm we can land on the surface.
//
- // In local space:
- // curr = localCurrCenter
- // target = sphere0.Center
- // movement = target - curr
- // normal = polygon plane normal (outward)
- // projectedMovement = movement - (movement · normal) * normal
- // slidPos = curr + projectedMovement
- //
- // Then ensure slidPos is outside the plane by at least radius+eps.
- var localNormal = hitPoly0!.Plane.Normal;
- var localMovement = sphere0.Center - localCurrCenter;
-
- // Project movement along wall tangent
- float movementIntoWall = Vector3.Dot(localMovement, localNormal);
- Vector3 projectedMovement = localMovement - localNormal * movementIntoWall;
-
- // Slid position in local space
- Vector3 slidPos = localCurrCenter + projectedMovement;
-
- // Ensure slid position is OUTSIDE the plane by radius + epsilon
- float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly0.Plane.D;
- float minDist = sphere0.Radius + 0.01f;
- if (slidDist < minDist)
- {
- slidPos += localNormal * (minDist - slidDist);
- }
-
- // Delta from current CheckPos sphere center to slid position (local)
- Vector3 localDelta = slidPos - sphere0.Center;
- // Transform to world and apply
- Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale;
- path.AddOffsetToCheckPos(worldDelta);
-
- var worldNormal = L2W(localNormal);
+ // ACE: BSPTree.find_collisions default branch → SpherePath.SetCollide
+ // + return Adjusted.
+ // Named-retail: BSPTREE::find_collisions airborne branch → set_collide.
+ var worldNormal0 = L2W(hitPoly0!.Plane.Normal);
+ path.SetCollide(worldNormal0);
path.WalkableAllowance = PhysicsGlobals.LandingZ;
- collisions.SetCollisionNormal(worldNormal);
- collisions.SetSlidingNormal(worldNormal);
- return TransitionState.Slid;
+ return TransitionState.Adjusted;
}
if (sphere1 is not null)
@@ -1609,29 +1597,11 @@ public static class BSPQuery
if (hit1 || hitPoly1 is not null)
{
- // Head sphere hit: apply the same wall-slide as above.
- var localNormal = hitPoly1!.Plane.Normal;
- var localMovement = sphere1.Center - localCurrCenter;
-
- float movementIntoWall = Vector3.Dot(localMovement, localNormal);
- Vector3 projectedMovement = localMovement - localNormal * movementIntoWall;
-
- Vector3 slidPos = localCurrCenter + projectedMovement;
- float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly1.Plane.D;
- float minDist = sphere1.Radius + 0.01f;
- if (slidDist < minDist)
- {
- slidPos += localNormal * (minDist - slidDist);
- }
-
- Vector3 localDelta = slidPos - sphere1.Center;
- Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale;
- path.AddOffsetToCheckPos(worldDelta);
-
- var worldNormal = L2W(localNormal);
- collisions.SetCollisionNormal(worldNormal);
- collisions.SetSlidingNormal(worldNormal);
- return TransitionState.Slid;
+ // Head sphere hit: same SetCollide response.
+ var worldNormal1 = L2W(hitPoly1!.Plane.Normal);
+ path.SetCollide(worldNormal1);
+ path.WalkableAllowance = PhysicsGlobals.LandingZ;
+ return TransitionState.Adjusted;
}
}
}
diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs
index 9c939150..753db366 100644
--- a/src/AcDream.Core/Physics/PhysicsBody.cs
+++ b/src/AcDream.Core/Physics/PhysicsBody.cs
@@ -31,6 +31,14 @@ public enum PhysicsStateFlags : uint
ReportCollisions = 0x00000010,
Gravity = 0x00000400, // bit 10 — apply downward gravity
Hidden = 0x00001000,
+ ///
+ /// L.3a (2026-04-30): retail INELASTIC_PS bit (acclient.h:2834).
+ /// When set, wall-collisions zero the velocity instead of reflecting.
+ /// Used by spell projectiles and missiles that should embed/explode on
+ /// impact rather than bounce. The player NEVER has this flag set —
+ /// player wall-hits use the reflection path with elasticity ~0.05.
+ ///
+ Inelastic = 0x00020000, // bit 17 — retail INELASTIC_PS
Sledding = 0x00800000, // bit 23 — sledding (modified friction)
}
@@ -44,6 +52,7 @@ public enum TransientStateFlags : uint
None = 0,
Contact = 0x00000001, // bit 0 — touching any surface
OnWalkable = 0x00000002, // bit 1 — standing on a walkable surface
+ Sliding = 0x00000004, // bit 2 — carry sliding normal into next transition
Active = 0x00000080, // bit 7 — object needs per-frame update
}
@@ -87,6 +96,9 @@ public sealed class PhysicsBody
/// Ground contact-plane normal (+0x130/134/138).
public Vector3 GroundNormal { get; set; } = Vector3.UnitZ;
+ /// Last wall/object sliding normal (retail transient Sliding state).
+ public Vector3 SlidingNormal { get; set; }
+
// ── persisted contact-plane state (retail PhysicsObj fields) ───────────
//
// Retail's PhysicsObj carries its last contact plane FORWARD across frames.
@@ -113,6 +125,18 @@ public sealed class PhysicsBody
/// Whether the contact plane is a water surface (affects step behavior).
public bool ContactPlaneIsWater { get; set; }
+ /// Whether the previous walkable polygon is available for edge slide.
+ public bool WalkablePolygonValid { get; set; }
+
+ /// Most recent walkable polygon plane (world-space).
+ public System.Numerics.Plane WalkablePlane { get; set; }
+
+ /// Most recent walkable polygon vertices (world-space).
+ public Vector3[]? WalkableVertices { get; set; }
+
+ /// Up vector used by the most recent walkable polygon probe.
+ public Vector3 WalkableUp { get; set; } = Vector3.UnitZ;
+
/// Elasticity coefficient (+0xB0).
public float Elasticity { get; set; } = 0.05f;
@@ -256,6 +280,16 @@ public sealed class PhysicsBody
///
/// Cross-checked with ACE PhysicsObj.calc_friction which uses 0.25f as
/// the threshold instead; the decompile uses 0.0. We match the decompile.
+ ///
+ /// L.3c attempt (2026-04-30, REVERTED): tried bumping to 0.25f per
+ /// retail acclient_2013_pseudo_c.txt:276705. Build green but
+ /// PlayerMovementControllerTests showed forward locomotion dropping
+ /// from ~3m/s to ~0.16m/s — friction now hammers normal walking.
+ /// Retail's friction block is gated by an additional state check at
+ /// line 276702 (`(this->state & ...) == 0`) that we didn't decode
+ /// fully; locomotion is probably skipped from the friction path
+ /// while actively walking. Filed as L.3c-followup; keeping the
+ /// matching-the-decompile-as-read 0.0 threshold for now.
///
public void calc_friction(float dt, float velocityMag2)
{
diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs
index 225bf3f6..771f208d 100644
--- a/src/AcDream.Core/Physics/PhysicsDataCache.cs
+++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs
@@ -209,6 +209,14 @@ public sealed class PhysicsDataCache
public int GfxObjCount => _gfxObj.Count;
public int SetupCount => _setup.Count;
public int CellStructCount => _cellStruct.Count;
+
+ ///
+ /// Register a pre-built directly.
+ /// Intended for unit-test fixtures that construct synthetic BSP trees
+ /// without needing real DAT content.
+ ///
+ public void RegisterGfxObjForTest(uint gfxObjId, GfxObjPhysics physics)
+ => _gfxObj[gfxObjId] = physics;
}
///
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index 4a934031..2fee39be 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -4,6 +4,13 @@ using System.Numerics;
namespace AcDream.Core.Physics;
+internal readonly record struct TerrainWalkableSample(
+ System.Numerics.Plane Plane,
+ Vector3[] Vertices,
+ float WaterDepth,
+ bool IsWater,
+ uint CellId);
+
///
/// Top-level physics resolver that combines and
/// to resolve entity movement with step-height
@@ -162,6 +169,83 @@ public sealed class PhysicsEngine
return null;
}
+ ///
+ /// Sample the outdoor terrain walkable triangle at the given world-space
+ /// XY position. This carries the same plane as
+ /// plus world-space triangle vertices for retail precipice-slide.
+ ///
+ internal TerrainWalkableSample? SampleTerrainWalkable(float worldX, float worldY)
+ {
+ foreach (var kvp in _landblocks)
+ {
+ var lb = kvp.Value;
+ float localX = worldX - lb.WorldOffsetX;
+ float localY = worldY - lb.WorldOffsetY;
+ if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
+ {
+ var sample = lb.Terrain.SampleSurfacePolygon(localX, localY);
+ var vertices = new Vector3[sample.Vertices.Length];
+ for (int i = 0; i < sample.Vertices.Length; i++)
+ {
+ var v = sample.Vertices[i];
+ vertices[i] = new Vector3(
+ v.X + lb.WorldOffsetX,
+ v.Y + lb.WorldOffsetY,
+ v.Z);
+ }
+
+ var normal = sample.Normal;
+ float d = -Vector3.Dot(normal, vertices[0]);
+ var plane = new System.Numerics.Plane(normal, d);
+
+ float waterDepth = lb.Terrain.SampleWaterDepth(localX, localY);
+ bool isWater = waterDepth >= 0.45f;
+ uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY);
+ uint fullCellId = (kvp.Key & 0xFFFF0000u) | lowCellId;
+
+ return new TerrainWalkableSample(
+ plane,
+ vertices,
+ waterDepth,
+ isWater,
+ fullCellId);
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// Resolve the outdoor cell id that owns a world-space position.
+ /// Indoor ids are preserved because EnvCell ownership still comes from
+ /// portal/cell BSP state; outdoor ids are derived from the registered
+ /// landblock that currently contains the point.
+ ///
+ internal uint ResolveOutdoorCellId(Vector3 worldPos, uint fallbackCellId)
+ {
+ if (fallbackCellId == 0)
+ return 0;
+
+ uint fallbackLow = fallbackCellId & 0xFFFFu;
+ if (fallbackLow >= 0x0100u)
+ return fallbackCellId;
+
+ foreach (var kvp in _landblocks)
+ {
+ var lb = kvp.Value;
+ float localX = worldPos.X - lb.WorldOffsetX;
+ float localY = worldPos.Y - lb.WorldOffsetY;
+ if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
+ {
+ uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY);
+ return (fallbackCellId & 0xFFFF0000u) == 0
+ ? lowCellId
+ : (kvp.Key & 0xFFFF0000u) | lowCellId;
+ }
+ }
+
+ return fallbackCellId;
+ }
+
///
/// Resolve an entity's movement from by
/// applying (XY only) and computing the correct Z
@@ -434,8 +518,29 @@ public sealed class PhysicsEngine
body.ContactPlaneIsWater);
}
+ // Retail CPhysicsObj::get_object_info also seeds SlidingNormal when
+ // transient_state has bit 2 set. This matters for one-step/frame hits:
+ // a wall collision at the end of one transition must project the next
+ // frame's movement along the wall instead of hard-stopping again.
+ if (body is not null
+ && body.TransientState.HasFlag(TransientStateFlags.Sliding)
+ && body.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
+ {
+ transition.CollisionInfo.SetSlidingNormal(body.SlidingNormal);
+ }
+
transition.SpherePath.InitPath(currentPos, targetPos, cellId, sphereRadius, sphereHeight);
+ if (isOnGround && body is not null
+ && body.WalkablePolygonValid
+ && body.WalkableVertices is { Length: >= 3 })
+ {
+ transition.SpherePath.SetWalkable(
+ body.WalkablePlane,
+ body.WalkableVertices,
+ body.WalkableUp);
+ }
+
bool ok = transition.FindTransitionalPosition(this);
var sp = transition.SpherePath;
@@ -464,14 +569,54 @@ public sealed class PhysicsEngine
{
body.ContactPlaneValid = false;
}
+
+ if (sp.HasLastWalkablePolygon && sp.LastWalkableVertices is not null)
+ {
+ body.WalkablePolygonValid = true;
+ body.WalkablePlane = sp.LastWalkablePlane;
+ body.WalkableVertices = (Vector3[])sp.LastWalkableVertices.Clone();
+ body.WalkableUp = sp.LastWalkableUp;
+ }
+ else if (!isOnGround && !ci.ContactPlaneValid && !ci.LastKnownContactPlaneValid)
+ {
+ body.WalkablePolygonValid = false;
+ body.WalkableVertices = null;
+ }
+
+ if (ci.SlidingNormalValid
+ && ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
+ {
+ body.SlidingNormal = ci.SlidingNormal;
+ body.TransientState |= TransientStateFlags.Sliding;
+ }
+ else
+ {
+ body.SlidingNormal = Vector3.Zero;
+ body.TransientState &= ~TransientStateFlags.Sliding;
+ }
}
+ // L.3a (2026-04-30): surface the wall normal so callers can apply
+ // retail's velocity-reflection bounce (CPhysicsObj::handle_all_collisions
+ // at acclient_2013_pseudo_c.txt:282699-282715, ACE PhysicsObj.cs:
+ // 2692-2697). The reflection itself is applied in
+ // PlayerMovementController after the position commit, gated on
+ // apply_bounce = !(prevOnWalkable && newOnWalkable) — airborne wall
+ // hits bounce, grounded wall slides don't.
+ bool collisionNormalValid = ci.CollisionNormalValid;
+ Vector3 collisionNormal = ci.CollisionNormal;
+
if (ok)
{
bool onGround = ci.ContactPlaneValid
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
- return new ResolveResult(sp.CheckPos, sp.CheckCellId, onGround);
+ return new ResolveResult(
+ sp.CheckPos,
+ ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId),
+ onGround,
+ collisionNormalValid,
+ collisionNormal);
}
// Transition failed (e.g., stuck in corner, too many steps).
@@ -483,6 +628,12 @@ public sealed class PhysicsEngine
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable)
|| isOnGround;
- return new ResolveResult(sp.CheckPos, sp.CheckCellId != 0 ? sp.CheckCellId : cellId, partialOnGround);
+ uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId;
+ return new ResolveResult(
+ sp.CheckPos,
+ ResolveOutdoorCellId(sp.CheckPos, partialCellId),
+ partialOnGround,
+ collisionNormalValid,
+ collisionNormal);
}
}
diff --git a/src/AcDream.Core/Physics/ResolveResult.cs b/src/AcDream.Core/Physics/ResolveResult.cs
index cc7fef83..63d18452 100644
--- a/src/AcDream.Core/Physics/ResolveResult.cs
+++ b/src/AcDream.Core/Physics/ResolveResult.cs
@@ -6,8 +6,25 @@ namespace AcDream.Core.Physics;
/// Result of : the validated
/// position after collision, the cell the entity ended up in,
/// and whether they're standing on a surface.
+///
+///
+/// L.3a (2026-04-30): added optional collision-normal fields so the
+/// caller (typically )
+/// can apply retail's velocity-reflection bounce
+/// (v_new = v - (1 + elasticity) * dot(v, n) * n) to the
+/// PhysicsBody after the geometric resolve completes. ACE port mirror:
+/// references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2692-2697;
+/// retail equivalent: CPhysicsObj::handle_all_collisions at
+/// acclient_2013_pseudo_c.txt:282699-282715.
+///
///
public readonly record struct ResolveResult(
Vector3 Position,
uint CellId,
- bool IsOnGround);
+ bool IsOnGround,
+ /// True when a wall collision occurred during this resolve
+ /// and is meaningful.
+ bool CollisionNormalValid = false,
+ /// Outward surface normal of the wall the sphere hit. Used
+ /// by the velocity-reflection step. Pointing away from the wall.
+ Vector3 CollisionNormal = default);
diff --git a/src/AcDream.Core/Physics/TerrainSurface.cs b/src/AcDream.Core/Physics/TerrainSurface.cs
index 6a375069..caa54935 100644
--- a/src/AcDream.Core/Physics/TerrainSurface.cs
+++ b/src/AcDream.Core/Physics/TerrainSurface.cs
@@ -1,7 +1,13 @@
using System;
+using System.Numerics;
namespace AcDream.Core.Physics;
+public readonly record struct TerrainSurfacePolygon(
+ float Z,
+ Vector3 Normal,
+ Vector3[] Vertices);
+
///
/// Outdoor terrain height resolver for a single landblock. Performs
/// per-triangle barycentric Z interpolation matching the visual terrain
@@ -250,6 +256,72 @@ public sealed class TerrainSurface
return (z, normal);
}
+ ///
+ /// Sample the terrain triangle at (localX, localY), including the three
+ /// local-space vertices that bound the sampled point. Edge-slide needs
+ /// these vertices so the retail crossed-edge test can identify which edge
+ /// the sphere left when a step-down probe fails.
+ ///
+ public TerrainSurfacePolygon SampleSurfacePolygon(float localX, float localY)
+ {
+ float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f);
+ float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f);
+ int cx = Math.Clamp((int)fx, 0, CellsPerSide - 1);
+ int cy = Math.Clamp((int)fy, 0, CellsPerSide - 1);
+
+ float tx = fx - cx;
+ float ty = fy - cy;
+
+ float hBL = _z[cx, cy ];
+ float hBR = _z[cx + 1, cy ];
+ float hTR = _z[cx + 1, cy + 1];
+ float hTL = _z[cx, cy + 1];
+
+ bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy);
+
+ Vector3 bl = new(cx * CellSize, cy * CellSize, hBL);
+ Vector3 br = new((cx + 1) * CellSize, cy * CellSize, hBR);
+ Vector3 tr = new((cx + 1) * CellSize, (cy + 1) * CellSize, hTR);
+ Vector3 tl = new(cx * CellSize, (cy + 1) * CellSize, hTL);
+
+ float z;
+ Vector3[] vertices;
+
+ if (splitSWtoNE)
+ {
+ if (tx > ty)
+ {
+ z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty;
+ vertices = new[] { bl, br, tr };
+ }
+ else
+ {
+ z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty;
+ vertices = new[] { bl, tr, tl };
+ }
+ }
+ else
+ {
+ if (tx + ty <= 1f)
+ {
+ z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty;
+ vertices = new[] { bl, br, tl };
+ }
+ else
+ {
+ z = hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty);
+ vertices = new[] { br, tr, tl };
+ }
+ }
+
+ var normal = Vector3.Normalize(
+ Vector3.Cross(vertices[1] - vertices[0], vertices[2] - vertices[0]));
+ if (normal.Z < 0f)
+ normal = -normal;
+
+ return new TerrainSurfacePolygon(z, normal, vertices);
+ }
+
///
/// Retail per-point water depth in meters — the amount the character's
/// feet are allowed to sink below the contact plane before the
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index 57d6e6d2..d48a0a01 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -64,6 +64,27 @@ public sealed class ObjectInfo
public bool EdgeSlide => State.HasFlag(ObjectInfoState.EdgeSlide);
public bool PathClipped => State.HasFlag(ObjectInfoState.PathClipped);
public bool FreeRotate => State.HasFlag(ObjectInfoState.FreeRotate);
+
+ ///
+ /// Return the Z threshold for a walkable surface appropriate to the
+ /// current movement context.
+ ///
+ ///
+ /// Retail: OBJECTINFO::get_walkable_z — returns FloorZ when the mover
+ /// is on a walkable surface (Contact+OnWalkable), LandingZ otherwise.
+ /// ACE: ObjectInfo.GetWalkableZ (Transition.cs:760).
+ ///
+ ///
+ public float GetWalkableZ()
+ => OnWalkable ? PhysicsGlobals.FloorZ : PhysicsGlobals.LandingZ;
+
+ ///
+ /// Stop any accumulated velocity on this object info.
+ /// ACE: ObjectInfo.StopVelocity — clears Velocity on the physics body.
+ /// acdream: velocity is tracked on PhysicsBody, not here. No-op for now;
+ /// will be wired when velocity is threaded through TransitionalInsert.
+ ///
+ public void StopVelocity() { /* velocity lives on PhysicsBody, not here */ }
}
///
@@ -167,7 +188,10 @@ public sealed class SpherePath
// Walkable tracking
public bool WalkableValid;
public Plane WalkablePlane;
+ public Vector3[]? WalkableVertices;
+ public Vector3 WalkableUp = Vector3.UnitZ;
public float WalkableAllowance = PhysicsGlobals.FloorZ;
+ public bool HasWalkablePolygon => WalkableValid && WalkableVertices is { Length: >= 3 };
// Backup for restore
public Vector3 BackupCheckPos;
@@ -210,6 +234,97 @@ public sealed class SpherePath
SetCheckPos(BackupCheckPos, BackupCheckCellId);
}
+ ///
+ /// Called when an airborne sphere hits a polygon but the polygon is not yet
+ /// walkable — save backup, record the collision normal in StepUpNormal, and
+ /// flag Collide so TransitionalInsert can re-test as Placement.
+ /// ACE: SpherePath.SetCollide (acclient_2013_pseudo_c.txt ~321594, ACE SpherePath.cs:279-286).
+ ///
+ public void SetCollide(Vector3 collisionNormal)
+ {
+ Collide = true;
+ BackupCheckPos = CheckPos;
+ BackupCheckCellId = CheckCellId;
+ StepUpNormal = collisionNormal;
+ WalkInterp = 1.0f;
+ }
+
+ public void SetWalkable(Plane plane, Vector3[] vertices, Vector3 up)
+ {
+ WalkableValid = true;
+ WalkablePlane = plane;
+ WalkableVertices = (Vector3[])vertices.Clone();
+ WalkableUp = up;
+ WalkableAllowance = PhysicsGlobals.FloorZ;
+ }
+
+ public void ClearWalkable()
+ {
+ WalkableValid = false;
+ WalkableVertices = null;
+ }
+
+ ///
+ /// Slide fallback when step-up fails. Clears the contact-plane state that
+ /// caused the step-up attempt and runs the full sphere-slide computation
+ /// to actually move the sphere along the wall.
+ ///
+ ///
+ /// L.2.3d (2026-04-29): the previous version only set
+ /// as a flag; it never applied a slide offset. The user observed "running
+ /// close to the wall now I stop" — the sphere stayed pinned at the wall
+ /// and the slide normal got overwritten by ValidateTransition's
+ /// default-to-UnitZ branch. ACE actually computes the slide offset and
+ /// applies it to via Sphere.SlideSphere;
+ /// we delegate to which does
+ /// the same thing.
+ ///
+ ///
+ /// ACE: SpherePath.StepUpSlide + Sphere.SlideSphere
+ /// (SpherePath.cs:309-317, Sphere.cs:558-604).
+ ///
+ public TransitionState StepUpSlide(Transition transition)
+ {
+ var ci = transition.CollisionInfo;
+ ci.ContactPlaneValid = false;
+ ci.ContactPlaneIsWater = false;
+ return transition.SlideSphereInternal(StepUpNormal, GlobalCurrCenter[0].Origin);
+ }
+
+ ///
+ /// Slide along the edge of the walkable polygon the mover just left.
+ /// Retail anchor: SPHEREPATH::precipice_slide
+ /// (acclient_2013_pseudo_c.txt:274316).
+ ///
+ public TransitionState PrecipiceSlide(Transition transition)
+ {
+ if (!HasWalkablePolygon || WalkableVertices is null)
+ {
+ ClearWalkable();
+ return TransitionState.Collided;
+ }
+
+ if (!BSPQuery.FindCrossedEdge(
+ WalkablePlane,
+ WalkableVertices,
+ GlobalSphere[0].Origin,
+ WalkableUp,
+ out var collisionNormal))
+ {
+ ClearWalkable();
+ return TransitionState.Collided;
+ }
+
+ ClearWalkable();
+ StepUp = false;
+
+ var offset = GlobalSphere[0].Origin - GlobalCurrCenter[0].Origin;
+ if (Vector3.Dot(collisionNormal, offset) > 0f)
+ collisionNormal = -collisionNormal;
+
+ return transition.SlideSphereInternal(collisionNormal, GlobalCurrCenter[0].Origin);
+ }
+
///
/// Initialize the path for a simple point-to-point movement.
///
@@ -282,6 +397,9 @@ public sealed class Transition
public SpherePath SpherePath = new();
public CollisionInfo CollisionInfo = new();
+ private static bool DumpEdgeSlideEnabled =>
+ Environment.GetEnvironmentVariable("ACDREAM_DUMP_EDGE_SLIDE") == "1";
+
// -----------------------------------------------------------------------
// Public entry point
// -----------------------------------------------------------------------
@@ -491,11 +609,57 @@ public sealed class Transition
// ── Phase 3: both env and objects returned OK ──────────────
// Handle Collide flag (BSP path 6 set it on a non-contact hit).
- // ACE: if Collide is set, re-test as Placement to confirm position.
- // Simplified: just clear it and accept.
+ // ACE: Transition.TransitionalInsert Collide branch (Transition.cs:891-930).
+ // Named-retail: CTransition::transitional_insert Collide branch.
if (sp.Collide)
{
sp.Collide = false;
+
+ bool reset = false;
+ if (ci.ContactPlaneValid && DoCheckWalkable(PhysicsGlobals.LandingZ, engine))
+ {
+ // CheckPos is walkable — re-test as Placement to snap/validate.
+ var savedInsert = sp.InsertType;
+ sp.InsertType = InsertType.Placement;
+
+ var placeState = TransitionalInsert(numAttempts, engine);
+
+ sp.InsertType = savedInsert;
+
+ if (placeState != TransitionState.OK)
+ {
+ // Placement rejected — fall through to restore.
+ placeState = TransitionState.OK;
+ reset = true;
+ }
+ else if (!reset)
+ {
+ // Placement accepted — return current state.
+ sp.ClearWalkable();
+ return placeState;
+ }
+ }
+ else
+ reset = true;
+
+ sp.ClearWalkable();
+
+ if (reset)
+ {
+ sp.RestoreCheckPos();
+ ci.ContactPlaneValid = false;
+ ci.ContactPlaneIsWater = false;
+
+ if (ci.LastKnownContactPlaneValid)
+ {
+ ci.LastKnownContactPlaneValid = false;
+ oi.StopVelocity();
+ }
+ else
+ ci.SetCollisionNormal(sp.StepUpNormal);
+
+ return TransitionState.Collided;
+ }
}
// Handle neg-poly hit (backward-facing polygon contact).
@@ -514,35 +678,62 @@ public sealed class Transition
if (!ci.ContactPlaneValid && oi.Contact && !sp.StepDown
&& sp.CheckCellId != 0 && oi.StepDown)
{
- float zVal = PhysicsGlobals.LandingZ;
+ // L.2.3i (2026-04-29): retail uses FloorZ when OnWalkable,
+ // LandingZ when not. acdream was unconditionally LandingZ —
+ // which let the step-down probe accept steep polygons
+ // (~85° permissive instead of ~49° strict) as the player's
+ // new contact, contributing to the "walks up steep roofs"
+ // bug. Per CTransition::transitional_insert step-down OK
+ // branch (acclient_2013_pseudo_c.txt:273258-273265) and
+ // ACE Transition.cs:849-856.
+ float zVal = oi.GetWalkableZ();
float stepDownHeight = oi.StepDownHeight;
sp.WalkableAllowance = zVal;
sp.SaveCheckPos();
float radsum = sp.GlobalSphere[0].Radius * 2f;
+ // L.2.3h (2026-04-29): pass runPlacement=false. This
+ // branch's job is to maintain ground contact during normal
+ // movement (e.g., walking over small bumps or near walls).
+ // The Placement check inside DoStepDown is too strict for
+ // this use — minor wall overlap from a prior wall-slide
+ // would fail Placement and trigger the L.2.3e edge-block,
+ // leaving the player stuck near walls. DoStepUp still runs
+ // Placement for the step-UP-through-walls protection.
if (radsum >= stepDownHeight)
{
- if (DoStepDown(stepDownHeight, zVal, engine))
+ if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false))
{
- sp.WalkableValid = false;
+ sp.ClearWalkable();
return TransitionState.OK;
}
}
else
{
stepDownHeight *= 0.5f;
- if (DoStepDown(stepDownHeight, zVal, engine)
- || DoStepDown(stepDownHeight, zVal, engine))
+ if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false)
+ || DoStepDown(stepDownHeight, zVal, engine, runPlacement: false))
{
- sp.WalkableValid = false;
+ sp.ClearWalkable();
return TransitionState.OK;
}
}
- // Step-down failed: stay at current position.
- sp.RestoreCheckPos();
- return TransitionState.OK;
+ // L.2c (2026-04-30): step-down failed — the move would put
+ // the player off an edge with no walkable surface within reach.
+ // Retail's EdgeSlide path then needs either:
+ // - a steep contact plane for CliffSlide, or
+ // - SpherePath.Walkable polygon context for PrecipiceSlide.
+ //
+ // acdream does not yet preserve the full walkable polygon
+ // context from terrain/BSP step-down, so this is still the
+ // conservative stop-at-edge fallback. The diagnostic below is
+ // intentionally narrow: it tells the next L.2c slice whether
+ // we are missing precipice context, a steep contact plane, or
+ // merely the EdgeSlide flag.
+ DumpEdgeSlideStepDownFailed(stepDownHeight, zVal);
+ return EdgeSlideAfterStepDownFailed(engine, stepDownHeight, zVal);
}
return TransitionState.OK;
@@ -553,6 +744,121 @@ public sealed class Transition
return TransitionState.Slid;
}
+ private TransitionState EdgeSlideAfterStepDownFailed(
+ PhysicsEngine engine,
+ float stepDownHeight,
+ float zVal)
+ {
+ var sp = SpherePath;
+ var ci = CollisionInfo;
+ var oi = ObjectInfo;
+
+ // Retail lets non-EdgeSlide movers continue over the boundary. Player
+ // movement carries EdgeSlide, so the local avatar takes the slide path.
+ if (!oi.OnWalkable || !oi.EdgeSlide)
+ {
+ sp.ClearWalkable();
+ sp.RestoreCheckPos();
+ ci.ContactPlaneValid = false;
+ ci.ContactPlaneIsWater = false;
+ return TransitionState.OK;
+ }
+
+ if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal)
+ {
+ var cliffPlane = ci.ContactPlane;
+ sp.ClearWalkable();
+ sp.RestoreCheckPos();
+ ci.ContactPlaneValid = false;
+ ci.ContactPlaneIsWater = false;
+ return CliffSlide(cliffPlane);
+ }
+
+ if (sp.HasWalkablePolygon)
+ {
+ ci.ContactPlaneValid = false;
+ ci.ContactPlaneIsWater = false;
+ return sp.PrecipiceSlide(this);
+ }
+
+ if (ci.ContactPlaneValid)
+ {
+ sp.ClearWalkable();
+ sp.RestoreCheckPos();
+ ci.ContactPlaneValid = false;
+ ci.ContactPlaneIsWater = false;
+ return TransitionState.OK;
+ }
+
+ // Retail back-probes from the current sphere center to rediscover the
+ // walkable polygon we just left, then restores the failed candidate and
+ // runs precipice_slide against that polygon.
+ Vector3 backToCurrent = sp.GlobalCurrCenter[0].Origin - sp.GlobalSphere[0].Origin;
+ sp.AddOffsetToCheckPos(backToCurrent);
+
+ _ = DoStepDown(stepDownHeight, zVal, engine, runPlacement: false);
+
+ ci.ContactPlaneValid = false;
+ ci.ContactPlaneIsWater = false;
+ sp.RestoreCheckPos();
+
+ if (sp.HasWalkablePolygon)
+ return sp.PrecipiceSlide(this);
+
+ sp.ClearWalkable();
+ return TransitionState.Collided;
+ }
+
+ private TransitionState CliffSlide(Plane contactPlane)
+ {
+ var sp = SpherePath;
+ var ci = CollisionInfo;
+
+ if (!ci.LastKnownContactPlaneValid)
+ return TransitionState.OK;
+
+ Vector3 contactNormal = Vector3.Cross(contactPlane.Normal, ci.LastKnownContactPlane.Normal);
+ contactNormal.Z = 0f;
+
+ Vector3 collideNormal = new(-contactNormal.Y, contactNormal.X, 0f);
+ if (collideNormal.LengthSquared() < PhysicsGlobals.EpsilonSq)
+ return TransitionState.OK;
+
+ collideNormal = Vector3.Normalize(collideNormal);
+
+ Vector3 offset = sp.GlobalSphere[0].Origin - sp.GlobalCurrCenter[0].Origin;
+ float angle = Vector3.Dot(collideNormal, offset);
+
+ if (angle <= 0f)
+ {
+ sp.AddOffsetToCheckPos(collideNormal * angle);
+ ci.SetCollisionNormal(collideNormal);
+ }
+ else
+ {
+ sp.AddOffsetToCheckPos(collideNormal * -angle);
+ ci.SetCollisionNormal(-collideNormal);
+ }
+
+ return TransitionState.Adjusted;
+ }
+
+ private void DumpEdgeSlideStepDownFailed(float stepDownHeight, float zVal)
+ {
+ if (!DumpEdgeSlideEnabled) return;
+
+ var sp = SpherePath;
+ var ci = CollisionInfo;
+ var oi = ObjectInfo;
+
+ Console.WriteLine(
+ System.FormattableString.Invariant(
+ $"edge-slide: stepdown-failed cur={Fmt(sp.CurPos)} check={Fmt(sp.CheckPos)} cell=0x{sp.CheckCellId:X8} edgeFlag={oi.EdgeSlide} contactFlag={oi.Contact} onWalkable={oi.OnWalkable} contactPlane={ci.ContactPlaneValid} lastPlane={ci.LastKnownContactPlaneValid} walkableValid={sp.WalkableValid} walkablePoly={sp.HasWalkablePolygon} stepDown={stepDownHeight:F3} zVal={zVal:F3}"));
+ }
+
+ private static string Fmt(Vector3 value) =>
+ System.FormattableString.Invariant($"({value.X:F3},{value.Y:F3},{value.Z:F3})");
+
// -----------------------------------------------------------------------
// Environment collision — outdoor terrain
// -----------------------------------------------------------------------
@@ -569,6 +875,10 @@ public sealed class Transition
var sp = SpherePath;
var ci = CollisionInfo;
+ uint resolvedOutdoorCellId = engine.ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId);
+ if (resolvedOutdoorCellId != sp.CheckCellId)
+ sp.SetCheckPos(sp.CheckPos, resolvedOutdoorCellId);
+
Vector3 footCenter = sp.GlobalSphere[0].Origin;
float sphereRadius = sp.GlobalSphere[0].Radius;
@@ -614,7 +924,9 @@ public sealed class Transition
localSphere1,
localCurrCenter,
Vector3.UnitZ, // local space Z is up
- 1.0f); // scale = 1.0 for cell geometry
+ 1.0f, // scale = 1.0 for cell geometry
+ Quaternion.Identity,
+ engine); // engine needed for Path 5 step-up
if (cellState != TransitionState.OK)
{
@@ -638,10 +950,10 @@ public sealed class Transition
//
// ACE reference: Landblock.GetZ (Landblock.cs:125-137) calls
// find_terrain_poly and uses walkable.Plane — the actual triangle's
- // plane, not a reconstructed flat one. SampleTerrainPlane returns
- // the same thing analytically from the triangle's corner heights.
- var planeOpt = engine.SampleTerrainPlane(footCenter.X, footCenter.Y);
- if (planeOpt is null)
+ // plane, not a reconstructed flat one. SampleTerrainWalkable returns
+ // that plane plus the triangle vertices needed by precipice slide.
+ var terrainWalkable = engine.SampleTerrainWalkable(footCenter.X, footCenter.Y);
+ if (terrainWalkable is null)
return TransitionState.OK; // no terrain loaded here — allow pass-through
// Per-point water depth: 0.9 on fully water cells, 0.45 on partial-
@@ -651,12 +963,11 @@ public sealed class Transition
// contact plane before the push-up fires. In retail, this is what
// makes characters appear submerged in water — there is NO separate
// water surface mesh; the character just sits lower than terrain.
- float waterDepth = engine.SampleWaterDepth(footCenter.X, footCenter.Y);
- bool isWater = waterDepth >= 0.45f;
-
- return ValidateWalkable(footCenter, sphereRadius, planeOpt.Value,
- isWater, waterDepth,
- cellId: sp.CheckCellId);
+ return ValidateWalkable(footCenter, sphereRadius, terrainWalkable.Value.Plane,
+ terrainWalkable.Value.IsWater,
+ terrainWalkable.Value.WaterDepth,
+ cellId: terrainWalkable.Value.CellId,
+ walkableVertices: terrainWalkable.Value.Vertices);
}
///
@@ -667,12 +978,19 @@ public sealed class Transition
///
private TransitionState ValidateWalkable(Vector3 sphereCenter, float sphereRadius,
System.Numerics.Plane contactPlane,
- bool isWater, float waterDepth, uint cellId)
+ bool isWater, float waterDepth, uint cellId,
+ Vector3[]? walkableVertices = null)
{
var sp = SpherePath;
var ci = CollisionInfo;
var oi = ObjectInfo;
+ void CacheWalkableContext()
+ {
+ if (walkableVertices is not null && contactPlane.Normal.Z >= PhysicsGlobals.FloorZ)
+ sp.SetWalkable(contactPlane, walkableVertices, Vector3.UnitZ);
+ }
+
// Low point of the sphere.
var lowPoint = sphereCenter - new Vector3(0f, 0f, sphereRadius);
@@ -695,7 +1013,10 @@ public sealed class Transition
// Resting on surface: record contact plane.
bool walkableNormal = contactPlane.Normal.Z >= sp.WalkableAllowance;
if (sp.StepDown || !oi.OnWalkable || walkableNormal)
+ {
ci.SetContactPlane(contactPlane, cellId, isWater);
+ CacheWalkableContext();
+ }
if (!oi.Contact && !sp.StepDown)
{
@@ -717,6 +1038,7 @@ public sealed class Transition
if (sp.StepDown || !oi.OnWalkable || walkable)
{
ci.SetContactPlane(contactPlane, cellId, isWater);
+ CacheWalkableContext();
if (sp.StepDown)
{
@@ -744,11 +1066,6 @@ public sealed class Transition
// Object collision — static BSP objects
// -----------------------------------------------------------------------
- // Reused per-call to avoid per-step allocation; safe because Transition
- // is single-threaded per movement resolve.
- private readonly List _nearbyObjs = new();
- private static int _debugQueryCount = 0;
-
///
/// Query the ShadowObjectRegistry for nearby static objects and run
/// collision against each using the retail BSPTree.find_collisions 6-path
@@ -778,23 +1095,17 @@ public sealed class Transition
out uint landblockId, out float worldOffsetX, out float worldOffsetY))
return TransitionState.OK;
+ // Use a local list: DoStepUp calls TransitionalInsert → FindObjCollisions
+ // recursively, so reusing a single field list would corrupt the outer
+ // iteration. Allocate per call (cheap — typically 0-5 entries).
+ var nearbyObjs = new List();
float queryRadius = sphereRadius + movement.Length() + 5f;
engine.ShadowObjects.GetNearbyObjects(
currPos, queryRadius,
worldOffsetX, worldOffsetY, landblockId,
- _nearbyObjs);
+ nearbyObjs);
- // Log every 120 frames — tracks player position over time.
- _debugQueryCount++;
- if (movement.LengthSquared() > 0.0001f && _debugQueryCount % 120 == 0)
- {
- Console.WriteLine(
- $"ObjColl @({currPos.X:F1},{currPos.Y:F1},{currPos.Z:F1}) " +
- $"lb=0x{landblockId:X8} nearby={_nearbyObjs.Count}/{engine.ShadowObjects.TotalRegistered}");
- }
-
-
- foreach (var obj in _nearbyObjs)
+ foreach (var obj in nearbyObjs)
{
// Broad-phase: can the moving sphere reach this object?
Vector3 deltaToCurr = currPos - obj.Position;
@@ -868,7 +1179,9 @@ public sealed class Transition
localCurrCenter,
localSpaceZ,
obj.Scale, // scale for local→world offsets
- obj.Rotation); // local→world rotation
+ obj.Rotation, // local→world rotation
+ engine,
+ worldOrigin: obj.Position);
}
else
{
@@ -995,6 +1308,14 @@ public sealed class Transition
/// normal variant). ACE: Sphere.SlideSphere(Transition, ref Vector3, Vector3).
/// Decompiled: FUN_00538180.
///
+ ///
+ /// L.2.3d: exposed as internal so
+ /// can apply the same slide computation ACE's Sphere.SlideSphere uses
+ /// for failed step-up. Mirror of ACE Sphere.cs:558-604 (Plane variant).
+ ///
+ internal TransitionState SlideSphereInternal(Vector3 collisionNormal, Vector3 currPos)
+ => SlideSphere(collisionNormal, currPos);
+
private TransitionState SlideSphere(Vector3 collisionNormal, Vector3 currPos)
{
var sp = SpherePath;
@@ -1194,7 +1515,8 @@ public sealed class Transition
/// Ported from pseudocode section 5 (StepDown).
/// ACE: Transition.StepDown(float stepDownHeight, float zVal).
///
- private bool DoStepDown(float stepDownHeight, float walkableZ, PhysicsEngine engine)
+ private bool DoStepDown(float stepDownHeight, float walkableZ, PhysicsEngine engine,
+ bool runPlacement = true)
{
var sp = SpherePath;
@@ -1218,16 +1540,228 @@ public sealed class Transition
// 1. Collision detection returned OK
// 2. A valid contact plane was found
// 3. The contact plane is walkable (Normal.Z >= walkableZ)
+ //
+ // ACE StepDown then runs a Placement insertion to confirm the sphere
+ // can actually be placed at the candidate position — it must not be
+ // inside any solid geometry (wall, BSP object, etc.).
+ // Named-retail: CTransition::step_down, acclient_2013_pseudo_c.txt:273069.
+ // ACE: Transition.cs:731-741.
if (transitState == TransitionState.OK
&& CollisionInfo.ContactPlaneValid
&& CollisionInfo.ContactPlane.Normal.Z >= walkableZ)
{
- return true;
+ // L.2.3h (2026-04-29): Placement validation is for the
+ // DoStepUp use case (prevents climbing through walls by
+ // stepping up onto ground beyond a tall wall). For the
+ // "maintain contact during normal movement" use case (called
+ // from TransitionalInsert's contact-recovery branch), the
+ // Placement check is over-strict — slight wall overlap from
+ // a prior wall-slide makes Placement reject, then the caller
+ // returns Collided (L.2.3e) and the player gets stuck near
+ // walls without ever touching them.
+ //
+ // ACE Transition.cs:731-741 runs Placement here unconditionally,
+ // but ACE's pre-step-down state is cleaner — we have residual
+ // wall-slide artifacts that make Placement misfire.
+ if (!runPlacement)
+ return true;
+
+ // Placement validation: can we actually stand here?
+ var savedInsert = sp.InsertType;
+ sp.InsertType = InsertType.Placement;
+
+ var placeState = TransitionalInsert(1, engine);
+
+ sp.InsertType = savedInsert;
+ return placeState == TransitionState.OK;
}
return false;
}
+ // -----------------------------------------------------------------------
+ // Step-up
+ // -----------------------------------------------------------------------
+
+ ///
+ /// Attempt to step over a low obstacle by probing upward then stepping down.
+ ///
+ ///
+ /// Retail flow (CTransition::step_up, named-retail ~273099):
+ /// 1. Clear ContactPlane so the step-down probe is unbiased.
+ /// 2. Set StepUp flag so DoStepDown skips the downward offset (we start
+ /// from the sphere's current position and scan down from there).
+ /// 3. Pick stepDownHeight / walkable-Z from ObjectInfo (if OnWalkable,
+ /// use StepUpHeight + FloorZ; else 0.04 + LandingZ).
+ /// 4. Save backup, run DoStepDown, then clear StepUp.
+ /// 5. Return true on success; the caller commits the new CheckPos.
+ /// On failure, RestoreCheckPos and return false.
+ ///
+ ///
+ /// ACE: Transition.StepUp (Transition.cs:746-777).
+ /// Named-retail: CTransition::step_up (~273099-273133).
+ ///
+ internal bool DoStepUp(Vector3 collisionNormal, PhysicsEngine engine)
+ {
+ var sp = SpherePath;
+ var ci = CollisionInfo;
+ var oi = ObjectInfo;
+
+ // L.2.3f (2026-04-29): diagnostic for steep-roof bug. Logs the
+ // input polygon normal that triggered step-up. The verdict tells
+ // whether THIS polygon would pass FloorZ (≈ 0.66) — but actual
+ // step-up acceptance depends on the polygon found by step_sphere_down
+ // INSIDE the recursive TransitionalInsert, which may be different.
+ // The post-step "result=" line below logs that outcome.
+ bool diag = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEPUP") == "1";
+ if (diag)
+ {
+ float floor = PhysicsGlobals.FloorZ;
+ string verdict = collisionNormal.Z >= floor ? "WALKABLE" : "STEEP";
+ Console.WriteLine(
+ $"stepup: enter normal=({collisionNormal.X:F3},{collisionNormal.Y:F3},{collisionNormal.Z:F3}) " +
+ $"|Z|={collisionNormal.Z:F3} vs FloorZ={floor:F3} → {verdict}, " +
+ $"OnWalkable={oi.State.HasFlag(ObjectInfoState.OnWalkable)}, " +
+ $"StepUpHeight={oi.StepUpHeight:F3}, " +
+ $"CurPos=({sp.CurPos.X:F2},{sp.CurPos.Y:F2},{sp.CurPos.Z:F2})");
+ }
+
+ // L.2.3c (2026-04-29): capture the existing contact plane BEFORE
+ // clearing it. On step-up failure (too-tall wall) we restore it so
+ // the mover stays grounded — without this, walking into a wall
+ // dropped OnWalkable and the animation system flickered to falling.
+ // Retail clears here too (acclient_2013_pseudo_c.txt:273099) but
+ // its outer transition state seeded the plane back via a different
+ // path (LastKnownContactPlane retention + check_contact). For
+ // acdream's per-frame Resolve we restore here directly.
+ bool savedCpValid = ci.ContactPlaneValid;
+ Plane savedCp = ci.ContactPlane;
+ uint savedCpCellId = ci.ContactPlaneCellId;
+ bool savedCpIsWater = ci.ContactPlaneIsWater;
+
+ ci.ContactPlaneValid = false;
+ ci.ContactPlaneIsWater = false;
+
+ sp.StepUp = true;
+ sp.StepUpNormal = collisionNormal;
+
+ // Default values (not on walkable): small step, LandingZ threshold.
+ float stepDownHeight = 0.04f;
+ float zLandingValue = PhysicsGlobals.LandingZ;
+
+ if (oi.State.HasFlag(ObjectInfoState.OnWalkable))
+ {
+ zLandingValue = oi.GetWalkableZ();
+ stepDownHeight = oi.StepUpHeight;
+ }
+
+ sp.WalkableAllowance = zLandingValue;
+ sp.SaveCheckPos();
+
+ bool stepDown = DoStepDown(stepDownHeight, zLandingValue, engine);
+
+ sp.StepUp = false;
+ sp.ClearWalkable();
+
+ // L.2.3f: log the result + landing plane if step-up succeeded.
+ // This is the actual surface the player ended up on, which may
+ // differ from the input collision normal (e.g. step-up scanned
+ // past a steep slope and landed on a flatter polygon higher up).
+ if (diag)
+ {
+ if (stepDown && ci.ContactPlaneValid)
+ {
+ float floor = PhysicsGlobals.FloorZ;
+ string verdict = ci.ContactPlane.Normal.Z >= floor ? "WALKABLE" : "STEEP";
+ Console.WriteLine(
+ $"stepup: SUCCESS — landed on plane normal=" +
+ $"({ci.ContactPlane.Normal.X:F3},{ci.ContactPlane.Normal.Y:F3},{ci.ContactPlane.Normal.Z:F3}) " +
+ $"|Z|={ci.ContactPlane.Normal.Z:F3} vs FloorZ={floor:F3} → {verdict}, " +
+ $"new CheckPos=({sp.CheckPos.X:F2},{sp.CheckPos.Y:F2},{sp.CheckPos.Z:F2})");
+ }
+ else
+ {
+ Console.WriteLine($"stepup: FAILED — sliding back along normal");
+ }
+ }
+
+ if (!stepDown)
+ {
+ sp.RestoreCheckPos();
+
+ // L.2.3c: restore the pre-step-up contact plane. The mover was
+ // grounded before the failed climb attempt; failing to climb
+ // a too-tall wall must not change that.
+ if (savedCpValid)
+ {
+ ci.ContactPlane = savedCp;
+ ci.ContactPlaneValid = true;
+ ci.ContactPlaneCellId = savedCpCellId;
+ ci.ContactPlaneIsWater = savedCpIsWater;
+ }
+ }
+
+ return stepDown;
+ }
+
+ // -----------------------------------------------------------------------
+ // Walkable check
+ // -----------------------------------------------------------------------
+
+ ///
+ /// Probe downward by StepDownHeight to confirm a walkable surface is within
+ /// reach of the current CheckPos — used by the Collide branch in
+ /// TransitionalInsert before re-testing as Placement.
+ ///
+ ///
+ /// Returns true if a walkable surface was found within reach (i.e. the
+ /// sphere can land here). Returns false if:
+ /// - ObjectInfo.OnWalkable is NOT set (always walkable by convention).
+ /// - CheckWalkables() already confirmed a walkable (skip the probe).
+ /// - The downward probe returned OK (meaning: no walkable was found
+ /// within reach, so we CANNOT land → transitState == OK → return false).
+ ///
+ ///
+ /// ACE: Transition.CheckWalkable (Transition.cs:206-235).
+ /// Named-retail: CTransition::check_walkable.
+ ///
+ internal bool DoCheckWalkable(float zCheck, PhysicsEngine engine)
+ {
+ var sp = SpherePath;
+ var oi = ObjectInfo;
+
+ if (!oi.State.HasFlag(ObjectInfoState.OnWalkable))
+ return true;
+
+ // If the current walkable entry is still valid, skip the probe.
+ if (sp.WalkableValid)
+ return true;
+
+ sp.SaveCheckPos();
+
+ float stepHeight = oi.StepDownHeight;
+ var globSphere = sp.GlobalSphere[0];
+
+ if (sp.NumSphere < 2 && stepHeight > globSphere.Radius * 2f)
+ stepHeight = globSphere.Radius * 0.5f;
+
+ if (stepHeight > globSphere.Radius * 2f)
+ stepHeight *= 0.5f;
+
+ sp.WalkableAllowance = zCheck;
+ sp.CheckWalkable = true;
+ sp.AddOffsetToCheckPos(new Vector3(0f, 0f, -stepHeight));
+
+ var transitState = TransitionalInsert(1, engine);
+
+ sp.CheckWalkable = false;
+ sp.RestoreCheckPos();
+
+ // ACE returns (transitState != OK) — i.e. true when we DID find a
+ // walkable (collision probe returned Adjusted/Collided).
+ return transitState != TransitionState.OK;
+ }
+
// -----------------------------------------------------------------------
// Post-step validation
// -----------------------------------------------------------------------
@@ -1282,15 +1816,43 @@ public sealed class Transition
ci.SetSlidingNormal(ci.CollisionNormal);
// Preserve contact plane for next step.
- ci.LastKnownContactPlaneValid = ci.ContactPlaneValid;
+ // L.2.3c (2026-04-29): only OVERWRITE LastKnown when current is valid.
+ // Previously: `LastKnownValid = ContactPlaneValid` cleared
+ // LastKnown whenever current was invalid — destroying the prior frame's
+ // contact memory. After StepUpSlide cleared ContactPlane mid-step
+ // (failed step-up against a too-tall wall), this propagated to
+ // LastKnown and the player went airborne for a frame, flickering the
+ // falling animation. Now LastKnown survives transient losses.
if (ci.ContactPlaneValid)
{
- ci.LastKnownContactPlane = ci.ContactPlane;
- ci.LastKnownContactPlaneCellId = ci.ContactPlaneCellId;
+ ci.LastKnownContactPlaneValid = true;
+ ci.LastKnownContactPlane = ci.ContactPlane;
+ ci.LastKnownContactPlaneCellId = ci.ContactPlaneCellId;
ci.LastKnownContactPlaneIsWater = ci.ContactPlaneIsWater;
oi.State |= ObjectInfoState.Contact;
- if (ci.ContactPlane.Normal.Z >= PhysicsGlobals.LandingZ)
+ // L.2.3i (2026-04-29): use FloorZ (~49°) NOT LandingZ (~85°)
+ // for the OnWalkable test. The previous LandingZ check was
+ // far too permissive — a 60° roof (normal.Z=0.5) was being
+ // marked OnWalkable, letting the player walk up steep slopes
+ // they shouldn't reach. Retail's PhysicsObj::is_valid_walkable
+ // uses FloorZ unconditionally (acclient_2013_pseudo_c.txt:277180-277193,
+ // ACE PhysicsObj.cs:2861).
+ if (ci.ContactPlane.Normal.Z >= PhysicsGlobals.FloorZ)
+ oi.State |= ObjectInfoState.OnWalkable;
+ else
+ oi.State &= ~ObjectInfoState.OnWalkable;
+ }
+ else if (ci.LastKnownContactPlaneValid)
+ {
+ // L.2.3c: current contact lost transiently (e.g. StepUpSlide
+ // cleared it during a failed step-up) but the prior frame's
+ // contact is still valid — keep the mover grounded via the
+ // last-known plane. Without this, every wall bump dropped the
+ // player into the falling animation for one frame.
+ oi.State |= ObjectInfoState.Contact;
+ // L.2.3i: same FloorZ correction as the live-contact branch.
+ if (ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.FloorZ)
oi.State |= ObjectInfoState.OnWalkable;
else
oi.State &= ~ObjectInfoState.OnWalkable;
diff --git a/tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs
index 7552d809..630cae6f 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs
@@ -105,6 +105,23 @@ public class AutonomousPositionTests
Assert.Equal(56, body.Length);
}
+ [Fact]
+ public void Build_UsesExplicitAirborneContactByte()
+ {
+ var body = AutonomousPosition.Build(
+ gameActionSequence: 7,
+ cellId: 0xA9B40001u,
+ position: Vector3.Zero,
+ rotation: Quaternion.Identity,
+ instanceSequence: 0,
+ serverControlSequence: 0,
+ teleportSequence: 0,
+ forcePositionSequence: 0,
+ lastContact: 0);
+
+ Assert.Equal(0, body[52]);
+ }
+
[Fact]
public void Build_ContainsIdentityRotation_AfterPosition()
{
diff --git a/tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs b/tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs
index 8070cca5..53f95d60 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs
@@ -142,6 +142,30 @@ public class MoveToStateTests
Assert.Equal(0, body.Length % 4);
}
+ [Fact]
+ public void Build_UsesExplicitAirborneContactByte()
+ {
+ var body = MoveToState.Build(
+ gameActionSequence: 7,
+ forwardCommand: null,
+ forwardSpeed: null,
+ sidestepCommand: null,
+ sidestepSpeed: null,
+ turnCommand: null,
+ turnSpeed: null,
+ holdKey: null,
+ cellId: 0xA9B40001u,
+ position: Vector3.Zero,
+ rotation: Quaternion.Identity,
+ instanceSequence: 0,
+ serverControlSequence: 0,
+ teleportSequence: 0,
+ forcePositionSequence: 0,
+ contactLongJump: 0);
+
+ Assert.Equal(0, body[56]);
+ }
+
[Fact]
public void Build_WithHoldKey_IncludesHoldKeyFlag()
{
diff --git a/tests/AcDream.Core.Tests/Physics/BSPStepUpFixtures.cs b/tests/AcDream.Core.Tests/Physics/BSPStepUpFixtures.cs
new file mode 100644
index 00000000..ac565061
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/BSPStepUpFixtures.cs
@@ -0,0 +1,369 @@
+using System.Collections.Generic;
+using System.Numerics;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+using AcDream.Core.Physics;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Synthetic BSP tree fixtures for step-up and roof-landing conformance tests.
+///
+///
+/// These fixtures construct minimal trees plus
+/// matching dictionaries that represent canonical
+/// AC collision shapes without needing real DAT content. The shapes cover every
+/// interesting branch in Path 5 and Path 6.
+///
+///
+///
+/// Coordinate convention: +Z is up, all geometry is expressed in object-local
+/// space (identity rotation, scale = 1.0) with objects at world origin so that
+/// localSphere.Origin == worldPosition.
+///
+///
+///
+/// Retail references:
+/// BSPTREE::find_collisions Path 5 — acclient_2013_pseudo_c.txt:323849 /
+/// ACE BSPTree.cs:192-196.
+/// BSPTREE::find_collisions Path 6 / set_collide —
+/// acclient_2013_pseudo_c.txt:323819 / ACE BSPTree.cs:210-219.
+/// CTransition::step_up — acclient_2013_pseudo_c.txt:273099-273133 /
+/// ACE Transition.cs:746-777.
+/// SPHEREPATH::set_collide — acclient_2013_pseudo_c.txt:321594-321607 /
+/// ACE SpherePath.cs:279-286.
+///
+///
+public static class BSPStepUpFixtures
+{
+ // -------------------------------------------------------------------------
+ // Polygon ID constants — each fixture uses a distinct range so the
+ // resolved-polygon dictionary is unambiguous when fixtures are composed.
+ // -------------------------------------------------------------------------
+ public const ushort LowStep_FloorId = 10;
+ public const ushort LowStep_WallId = 11;
+ public const ushort LowStep_UpperFloorId = 12;
+
+ public const ushort TallWall_FloorId = 20;
+ public const ushort TallWall_WallId = 21;
+
+ public const ushort FlatRoof_FloorId = 30;
+ public const ushort FlatRoof_RoofId = 31;
+
+ public const ushort SlopedUnwalkable_FloorId = 40;
+ public const ushort SlopedUnwalkable_SlopeId = 41;
+
+ // -------------------------------------------------------------------------
+ // Sphere radius used in every test.
+ // -------------------------------------------------------------------------
+ public const float SphereRadius = 0.2f;
+
+ // =========================================================================
+ // Fixture 1 — Low step (25 cm)
+ //
+ // Schema (side view, XZ plane):
+ //
+ // +X ──────────────────►
+ // Z
+ // 0.5 ┆ ┌─────── ← UpperFloor at z=0.25 (vert 8..11)
+ // 0.25├───────────┤
+ // ┆ Wall ┆ (x=0.5, z=[0,0.25])
+ // 0.0 ┆═══════════┘
+ // ← Floor at z=0 (vert 0..3)
+ //
+ // The mover starts grounded at x=-0.5, z=SphereRadius and walks toward +X.
+ // Expected: step-up succeeds when Contact is set; sphere lifts to z=0.25+eps.
+ // =========================================================================
+
+ ///
+ /// Constructs a BSP tree and resolved-polygon dict representing a 25 cm step.
+ ///
+ /// Geometry (object-local space):
+ ///
+ /// - Floor polygon at z = 0, x ∈ [-2, 0.5], y ∈ [-1, 1].
+ /// - Vertical wall polygon at x = 0.5, z ∈ [0, 0.25], y ∈ [-1, 1], facing -X.
+ /// - Upper floor polygon at z = 0.25, x ∈ [0.2, 2], y ∈ [-1, 1] — extends
+ /// left of the wall face so the vertical step-down probe finds it when the
+ /// sphere is at x ≈ 0.3–0.5 (the wall contact zone).
+ ///
+ ///
+ public static (PhysicsBSPNode Root, Dictionary Resolved)
+ LowStep()
+ {
+ var resolved = new Dictionary();
+
+ // Lower floor: z=0, x∈[-2,0.5], y∈[-1,1], normal = +Z
+ resolved[LowStep_FloorId] = MakeFloor(
+ new Vector3(-2f, -1f, 0f), new Vector3(0.5f, -1f, 0f),
+ new Vector3(0.5f, 1f, 0f), new Vector3(-2f, 1f, 0f));
+
+ // Vertical wall facing -X at x=0.5, z∈[0,0.25], normal = -X
+ // For normal=(-1,0,0), the winding that makes cross(normal,edge)·disp > 0
+ // for interior points is: (y=-1,z=0)→(y=-1,z=0.25)→(y=1,z=0.25)→(y=1,z=0).
+ resolved[LowStep_WallId] = MakeQuad(
+ new Vector3(0.5f, -1f, 0f),
+ new Vector3(0.5f, -1f, 0.25f),
+ new Vector3(0.5f, 1f, 0.25f),
+ new Vector3(0.5f, 1f, 0f),
+ expectedNormal: new Vector3(-1f, 0f, 0f));
+
+ // Upper floor at z=0.25, x∈[0.2,2], y∈[-1,1], normal = +Z.
+ // The upper floor extends slightly left of the wall face (x=0.5)
+ // so the step-down probe (vertical, from the wall-contact XY) can
+ // find it when the sphere is at x≈0.3-0.5. Retail BSPs have the
+ // same overlap because geometry is continuous across the step.
+ resolved[LowStep_UpperFloorId] = MakeFloor(
+ new Vector3(0.2f, -1f, 0.25f), new Vector3(2f, -1f, 0.25f),
+ new Vector3(2f, 1f, 0.25f), new Vector3(0.2f, 1f, 0.25f));
+
+ // Build a flat BSP tree: one internal node with all three polys in a leaf.
+ // The bounding sphere covers everything.
+ var leaf = new PhysicsBSPNode
+ {
+ Type = BSPNodeType.Leaf,
+ BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 10f },
+ };
+ leaf.Polygons.Add(LowStep_FloorId);
+ leaf.Polygons.Add(LowStep_WallId);
+ leaf.Polygons.Add(LowStep_UpperFloorId);
+
+ return (leaf, resolved);
+ }
+
+ // =========================================================================
+ // Fixture 2 — Too-tall wall (5 m)
+ //
+ // A floor at z=0 and a 5 m wall at x=0.5 with no floor on the other side.
+ // Expected: step-up fails (wall too tall), mover slides along wall.
+ // =========================================================================
+
+ ///
+ /// Constructs a BSP tree and resolved-polygon dict representing a wall that
+ /// is too tall to step over (5 m), so step-up should fail.
+ ///
+ public static (PhysicsBSPNode Root, Dictionary Resolved)
+ TallWall()
+ {
+ var resolved = new Dictionary();
+
+ // Floor at z=0
+ resolved[TallWall_FloorId] = MakeFloor(
+ new Vector3(-2f, -1f, 0f), new Vector3(0.5f, -1f, 0f),
+ new Vector3(0.5f, 1f, 0f), new Vector3(-2f, 1f, 0f));
+
+ // Tall wall at x=0.5, z∈[0,5], normal = -X
+ // Winding for normal=(-1,0,0): (y=-1,z=0)→(y=-1,z=5)→(y=1,z=5)→(y=1,z=0).
+ resolved[TallWall_WallId] = MakeQuad(
+ new Vector3(0.5f, -1f, 0f),
+ new Vector3(0.5f, -1f, 5f),
+ new Vector3(0.5f, 1f, 5f),
+ new Vector3(0.5f, 1f, 0f),
+ expectedNormal: new Vector3(-1f, 0f, 0f));
+
+ var leaf = new PhysicsBSPNode
+ {
+ Type = BSPNodeType.Leaf,
+ BoundingSphere = new Sphere { Origin = new Vector3(0f, 0f, 2.5f), Radius = 10f },
+ };
+ leaf.Polygons.Add(TallWall_FloorId);
+ leaf.Polygons.Add(TallWall_WallId);
+
+ return (leaf, resolved);
+ }
+
+ // =========================================================================
+ // Fixture 3 — Flat roof (3 m)
+ //
+ // A horizontal polygon at z=3 representing a building rooftop.
+ // The mover is airborne (no Contact flag) descending toward the roof.
+ // Expected (after L.2.2): Path 6 sets Collide flag; the Collide-flag handler
+ // re-tests as Placement; ContactPlane is set; OnWalkable is established.
+ // =========================================================================
+
+ ///
+ /// Constructs a BSP tree and resolved-polygon dict representing a 3 m flat roof.
+ ///
+ public static (PhysicsBSPNode Root, Dictionary Resolved)
+ FlatRoof()
+ {
+ var resolved = new Dictionary();
+
+ // Ground floor for reference (not involved in landing test)
+ resolved[FlatRoof_FloorId] = MakeFloor(
+ new Vector3(-2f, -1f, 0f), new Vector3(2f, -1f, 0f),
+ new Vector3(2f, 1f, 0f), new Vector3(-2f, 1f, 0f));
+
+ // Roof at z=3.0, x∈[-2,2], y∈[-1,1], normal = +Z
+ resolved[FlatRoof_RoofId] = MakeFloor(
+ new Vector3(-2f, -1f, 3f), new Vector3(2f, -1f, 3f),
+ new Vector3(2f, 1f, 3f), new Vector3(-2f, 1f, 3f));
+
+ var leaf = new PhysicsBSPNode
+ {
+ Type = BSPNodeType.Leaf,
+ BoundingSphere = new Sphere { Origin = new Vector3(0f, 0f, 1.5f), Radius = 10f },
+ };
+ leaf.Polygons.Add(FlatRoof_FloorId);
+ leaf.Polygons.Add(FlatRoof_RoofId);
+
+ return (leaf, resolved);
+ }
+
+ // =========================================================================
+ // Fixture 4 — Sloped unwalkable surface (60°)
+ //
+ // A flat reference floor plus an angled slope at ~60° from horizontal.
+ // normal.Z = cos(60°) ≈ 0.5 < PhysicsGlobals.FloorZ (0.6642).
+ // Expected: no contact plane set; mover slides off.
+ // =========================================================================
+
+ ///
+ /// Constructs a BSP tree and resolved-polygon dict representing a steep (60°)
+ /// slope whose normal.Z is below the walkable threshold.
+ ///
+ public static (PhysicsBSPNode Root, Dictionary Resolved)
+ SlopedUnwalkable()
+ {
+ var resolved = new Dictionary();
+
+ // Reference floor at z=0
+ resolved[SlopedUnwalkable_FloorId] = MakeFloor(
+ new Vector3(-2f, -1f, 0f), new Vector3(0f, -1f, 0f),
+ new Vector3(0f, 1f, 0f), new Vector3(-2f, 1f, 0f));
+
+ // Steep slope: rises 2 m over 1 m horizontal run (63.4° from horizontal).
+ // Vertices: (0,-1,0), (1,-1,2), (1,1,2), (0,1,0)
+ // Normal direction: cross((1,0,2)-(0,0,0), (0,1,0)-(0,0,0)) ∝ (-2,0,1) normalised
+ // After normalisation: (-0.894, 0, 0.447) — normal.Z ≈ 0.447 < FloorZ.
+ // We point the normal outward (-X side) so it represents a wall-like slope.
+ var v0 = new Vector3(0f, -1f, 0f);
+ var v1 = new Vector3(1f, -1f, 2f);
+ var v2 = new Vector3(1f, 1f, 2f);
+ var v3 = new Vector3(0f, 1f, 0f);
+ var raw = Vector3.Cross(v1 - v0, v3 - v0);
+ var slopeNormal = Vector3.Normalize(raw);
+ // Ensure the normal faces away from the approach side (-X direction).
+ if (slopeNormal.X > 0) slopeNormal = -slopeNormal;
+
+ var vertices = new[] { v0, v1, v2, v3 };
+ float dotSum = 0f;
+ foreach (var v in vertices) dotSum += Vector3.Dot(slopeNormal, v);
+ float d = -(dotSum / vertices.Length);
+
+ resolved[SlopedUnwalkable_SlopeId] = new ResolvedPolygon
+ {
+ Vertices = vertices,
+ Plane = new Plane(slopeNormal, d),
+ NumPoints = 4,
+ SidesType = CullMode.None,
+ };
+
+ var leaf = new PhysicsBSPNode
+ {
+ Type = BSPNodeType.Leaf,
+ BoundingSphere = new Sphere { Origin = new Vector3(0.5f, 0f, 1f), Radius = 10f },
+ };
+ leaf.Polygons.Add(SlopedUnwalkable_FloorId);
+ leaf.Polygons.Add(SlopedUnwalkable_SlopeId);
+
+ return (leaf, resolved);
+ }
+
+ // =========================================================================
+ // Transition builder helpers
+ // =========================================================================
+
+ ///
+ /// Build a for a grounded mover (Contact + OnWalkable set).
+ ///
+ ///
+ /// The mover's foot sphere starts at and is headed
+ /// toward . is
+ /// set to so the test can control which step
+ /// heights succeed.
+ ///
+ ///
+ public static Transition MakeGroundedTransition(
+ Vector3 from,
+ Vector3 to,
+ float stepUpHeight = 0.30f,
+ uint cellId = 0xA9B40001u)
+ {
+ var t = new Transition();
+ t.SpherePath.InitPath(from, to, cellId, SphereRadius);
+ t.ObjectInfo.State = ObjectInfoState.Contact | ObjectInfoState.OnWalkable;
+ t.ObjectInfo.StepUpHeight = stepUpHeight;
+ t.ObjectInfo.StepDownHeight = 0.04f;
+ t.ObjectInfo.StepDown = true;
+ // Seed LastKnownContactPlane so the mover is "on the floor".
+ t.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, 0f);
+ t.CollisionInfo.LastKnownContactPlaneValid = true;
+ return t;
+ }
+
+ ///
+ /// Build a for an airborne mover (no Contact, no OnWalkable).
+ ///
+ ///
+ /// Represents a character that has just jumped or fallen and is now moving
+ /// downward to land on a surface.
+ ///
+ ///
+ public static Transition MakeAirborneTransition(
+ Vector3 from,
+ Vector3 to,
+ uint cellId = 0xA9B40001u)
+ {
+ var t = new Transition();
+ t.SpherePath.InitPath(from, to, cellId, SphereRadius);
+ t.ObjectInfo.State = ObjectInfoState.None;
+ t.ObjectInfo.StepUpHeight = 0.04f;
+ t.ObjectInfo.StepDownHeight = 0.04f;
+ t.ObjectInfo.StepDown = false;
+ return t;
+ }
+
+ // =========================================================================
+ // Internal polygon builders
+ // =========================================================================
+
+ // Build a horizontal floor polygon (normal = +Z) from four CCW vertices
+ // (as viewed from above).
+ private static ResolvedPolygon MakeFloor(
+ Vector3 v0, Vector3 v1, Vector3 v2, Vector3 v3)
+ {
+ var verts = new[] { v0, v1, v2, v3 };
+ var normal = Vector3.UnitZ;
+ float dotSum = 0f;
+ foreach (var v in verts) dotSum += Vector3.Dot(normal, v);
+ float d = -(dotSum / verts.Length);
+ return new ResolvedPolygon
+ {
+ Vertices = verts,
+ Plane = new Plane(normal, d),
+ NumPoints = 4,
+ SidesType = CullMode.None,
+ };
+ }
+
+ // Build a quad polygon with a specified outward normal.
+ // Vertices should be ordered so that the cross-product of two edges aligns
+ // with expectedNormal; we explicitly override the computed plane so the test
+ // is deterministic regardless of winding order.
+ private static ResolvedPolygon MakeQuad(
+ Vector3 v0, Vector3 v1, Vector3 v2, Vector3 v3,
+ Vector3 expectedNormal)
+ {
+ var verts = new[] { v0, v1, v2, v3 };
+ float dotSum = 0f;
+ foreach (var v in verts) dotSum += Vector3.Dot(expectedNormal, v);
+ float d = -(dotSum / verts.Length);
+ return new ResolvedPolygon
+ {
+ Vertices = verts,
+ Plane = new Plane(expectedNormal, d),
+ NumPoints = 4,
+ SidesType = CullMode.None,
+ };
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs
new file mode 100644
index 00000000..258fd58b
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs
@@ -0,0 +1,571 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Physics;
+using DatReaderWriter.Types;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Conformance tests for BSP step-up (Path 5) and rooftop landing (Path 6) in
+/// .
+///
+///
+/// Tests are organised in three groups corresponding to the three commits:
+///
+///
+/// - Group A — Baselines: behaviours that should pass both before
+/// and after the implementation (no-hit returns OK, fixture geometry checks).
+/// - Group B — Phase L.2.1 (Path 5 step-up): tests that are RED
+/// because Path 5 wall-slides instead of stepping up. L.2.1 flips these
+/// GREEN.
+/// - Group C — Phase L.2.2 (Path 6 SetCollide): tests that are RED
+/// because Path 6 wall-slides instead of setting the Collide flag. L.2.2
+/// flips these GREEN.
+///
+///
+///
+/// Retail references:
+/// BSPTREE::find_collisions Path 5 — acclient_2013_pseudo_c.txt:323849 /
+/// ACE BSPTree.cs:192-196.
+/// CTransition::step_up — acclient_2013_pseudo_c.txt:273099-273133 /
+/// ACE Transition.cs:746-777.
+/// BSPTREE::find_collisions Path 6 / SPHEREPATH::set_collide —
+/// acclient_2013_pseudo_c.txt:323819 / ACE BSPTree.cs:210-219.
+/// SPHEREPATH::set_collide — acclient_2013_pseudo_c.txt:321594-321607 /
+/// ACE SpherePath.cs:279-286.
+/// CTransition::transitional_insert Collide branch —
+/// acclient_2013_pseudo_c.txt:273193-273239 / ACE Transition.cs:891-930.
+///
+///
+public class BSPStepUpTests
+{
+ // =========================================================================
+ // Group A — Baselines (pass before AND after the implementation)
+ // =========================================================================
+
+ ///
+ /// No BSP geometry → FindCollisions returns OK with no state changes.
+ ///
+ [Fact]
+ public void A1_NullRoot_ReturnsOK()
+ {
+ var from = new Vector3(0f, 0f, BSPStepUpFixtures.SphereRadius);
+ var to = new Vector3(0.1f, 0f, BSPStepUpFixtures.SphereRadius);
+ var t = BSPStepUpFixtures.MakeGroundedTransition(from, to);
+
+ var localSphere = new DatReaderWriter.Types.Sphere
+ {
+ Origin = to,
+ Radius = BSPStepUpFixtures.SphereRadius,
+ };
+
+ var result = BSPQuery.FindCollisions(
+ null,
+ new Dictionary(),
+ t, localSphere, null,
+ from, Vector3.UnitZ, 1.0f);
+
+ Assert.Equal(TransitionState.OK, result);
+ }
+
+ ///
+ /// Grounded mover far from the wall → no collision → OK.
+ ///
+ [Fact]
+ public void A2_GroundedMover_NoWallNear_ReturnsOK()
+ {
+ var (root, resolved) = BSPStepUpFixtures.LowStep();
+
+ // Moving in -X, away from the wall at x=0.5.
+ var from = new Vector3(-1f, 0f, BSPStepUpFixtures.SphereRadius);
+ var to = new Vector3(-1.5f, 0f, BSPStepUpFixtures.SphereRadius);
+ var t = BSPStepUpFixtures.MakeGroundedTransition(from, to);
+
+ var localSphere = new DatReaderWriter.Types.Sphere { Origin = to, Radius = BSPStepUpFixtures.SphereRadius };
+
+ var result = BSPQuery.FindCollisions(
+ root, resolved, t, localSphere, null,
+ from, Vector3.UnitZ, 1.0f);
+
+ Assert.Equal(TransitionState.OK, result);
+ }
+
+ ///
+ /// Airborne mover well above the roof → no collision → OK.
+ ///
+ [Fact]
+ public void A3_AirborneMover_AboveRoof_ReturnsOK()
+ {
+ var (root, resolved) = BSPStepUpFixtures.FlatRoof();
+
+ // Mover at z=6 (well above the roof at z=3) with tiny downward step.
+ float highZ = 6f;
+ var from = new Vector3(0f, 0f, highZ + BSPStepUpFixtures.SphereRadius);
+ var to = new Vector3(0f, 0f, highZ + BSPStepUpFixtures.SphereRadius - 0.01f);
+ var t = BSPStepUpFixtures.MakeAirborneTransition(from, to);
+
+ var localSphere = new DatReaderWriter.Types.Sphere { Origin = to, Radius = BSPStepUpFixtures.SphereRadius };
+
+ var result = BSPQuery.FindCollisions(
+ root, resolved, t, localSphere, null,
+ from, Vector3.UnitZ, 1.0f);
+
+ Assert.Equal(TransitionState.OK, result);
+ }
+
+ ///
+ /// The slope fixture's polygon must have normal.Z below FloorZ (confirms
+ /// the fixture geometry is set up correctly as a non-walkable surface).
+ ///
+ [Fact]
+ public void A4_SlopedFixture_NormalBelowFloorZ()
+ {
+ var (_, resolved) = BSPStepUpFixtures.SlopedUnwalkable();
+ var slope = resolved[BSPStepUpFixtures.SlopedUnwalkable_SlopeId];
+
+ Assert.True(slope.Plane.Normal.Z < PhysicsGlobals.FloorZ,
+ $"Slope normal.Z ({slope.Plane.Normal.Z:F4}) must be < FloorZ ({PhysicsGlobals.FloorZ:F4})");
+ Assert.True(slope.Plane.Normal.Z > 0f,
+ $"Slope normal.Z ({slope.Plane.Normal.Z:F4}) must be > 0 (upward-facing)");
+ }
+
+ ///
+ /// Low-step upper-floor polygon has normal.Z >= FloorZ (it IS walkable).
+ ///
+ [Fact]
+ public void A5_LowStepUpperFloor_NormalAboveFloorZ()
+ {
+ var (_, resolved) = BSPStepUpFixtures.LowStep();
+ var upper = resolved[BSPStepUpFixtures.LowStep_UpperFloorId];
+
+ Assert.True(upper.Plane.Normal.Z >= PhysicsGlobals.FloorZ,
+ $"Upper floor normal.Z ({upper.Plane.Normal.Z:F4}) must be >= FloorZ ({PhysicsGlobals.FloorZ:F4})");
+ }
+
+ ///
+ /// Roof polygon has normal.Z >= LandingZ (it can be landed on).
+ ///
+ [Fact]
+ public void A6_FlatRoofPolygon_NormalAboveLandingZ()
+ {
+ var (_, resolved) = BSPStepUpFixtures.FlatRoof();
+ var roof = resolved[BSPStepUpFixtures.FlatRoof_RoofId];
+
+ Assert.True(roof.Plane.Normal.Z >= PhysicsGlobals.LandingZ,
+ $"Roof normal.Z ({roof.Plane.Normal.Z:F4}) must be >= LandingZ ({PhysicsGlobals.LandingZ:F4})");
+ }
+
+ // =========================================================================
+ // Group B — Phase L.2.1 (Path 5 step-up)
+ //
+ // RED before L.2.1, GREEN after.
+ // Each test documents the CURRENT wrong behaviour and EXPECTED correct one.
+ // =========================================================================
+
+ ///
+ /// Grounded mover (Contact + OnWalkable) walking toward the low step (25 cm):
+ /// should step up onto the upper floor, not slide sideways.
+ ///
+ ///
+ /// Current (wrong): Path 5 applies wall-slide → CurPos.X stays left of wall;
+ /// Z stays at floor level.
+ ///
+ ///
+ /// Expected after L.2.1: Path 5 calls StepUp → DoStepDown finds upper floor
+ /// → sphere lifts to z ≥ 0.25 + SphereRadius and X advances past the wall.
+ ///
+ ///
+ /// Retail: BSPTREE::step_sphere_up / CTransition::step_up
+ /// acclient_2013_pseudo_c.txt:323849, 273099.
+ ///
+ [Fact]
+ public void B1_GroundedMover_LowStep_StepsUp()
+ {
+ var (root, resolved) = BSPStepUpFixtures.LowStep();
+ const float stepUpHeight = 0.30f; // larger than step (0.25), so step-up succeeds
+
+ // CurPos (foot position) starts at z=0 (on the terrain / BSP floor at z=0).
+ // The sphere center is at CurPos + (0, 0, SphereRadius) = (x, 0, 0.2).
+ // lowPoint = sphere_center - (0,0,r) = (x, 0, 0) → on terrain → contact.
+ var from = new Vector3(0.1f, 0f, 0f);
+ // to.X = 0.6 → offset = (0.5, 0, 0), 3 sub-steps of 0.1667 each.
+ // Step 2: CurPos ≈ (0.433, 0, 0), sphere center x ≈ 0.433.
+ // Wall: dist = 0.5 - 0.433 = 0.067 < rad = 0.198 → HIT Path 5 ✓
+ var to = new Vector3(0.6f, 0f, 0f); // foot stays at z=0, crosses wall at x=0.5
+
+ var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, stepUpHeight);
+ // terrainZ=0f: terrain at z=0 keeps the step-down probe grounded between
+ // steps, preserving Contact/OnWalkable across the sub-step boundary.
+ var engine = MakeTestEngine(root, resolved, terrainZ: 0f);
+
+ bool ok = t.FindTransitionalPosition(engine);
+
+ // After step-up, the character's foot (CurPos.Z) must be at or above the
+ // upper floor (z=0.25). CurPos stores the foot origin; the sphere center is
+ // CurPos.Z + SphereRadius. The lower bound is the upper-floor Z minus a
+ // small epsilon to tolerate floating-point rounding in AdjustSphereToPlane.
+ float expectedMinZ = 0.25f - PhysicsGlobals.EPSILON * 10f;
+ Assert.True(t.SpherePath.CurPos.Z >= expectedMinZ,
+ $"Expected Z >= {expectedMinZ:F4} (stepped up to upper floor at z=0.25), " +
+ $"got CurPos.Z = {t.SpherePath.CurPos.Z:F4}. " +
+ "Path 5 must call StepUp (L.2.1) instead of wall-sliding.");
+ }
+
+ ///
+ /// Grounded mover walking into the too-tall wall (5 m) should NOT step up —
+ /// the wall is taller than StepUpHeight.
+ ///
+ ///
+ /// Expected: StepUp is called, DoStepDown finds no walkable surface within
+ /// 0.04 m (no upper floor exists), StepUpSlide applies → mover stays
+ /// left of the wall.
+ ///
+ ///
+ /// Retail: SPHEREPATH::step_up_slide
+ /// ACE SpherePath.cs:309-316.
+ ///
+ [Fact]
+ public void B2_GroundedMover_TallWall_BlockedOrSlides()
+ {
+ var (root, resolved) = BSPStepUpFixtures.TallWall();
+ const float stepUpHeight = 0.04f; // default — cannot scale 5 m wall
+
+ // Foot at z=0 (on terrain). Same reasoning as B1.
+ var from = new Vector3(0.1f, 0f, 0f);
+ var to = new Vector3(0.6f, 0f, 0f);
+
+ var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, stepUpHeight);
+ // terrainZ=0f: keep grounded between steps (same as B1).
+ var engine = MakeTestEngine(root, resolved, terrainZ: 0f);
+
+ t.FindTransitionalPosition(engine);
+
+ // The mover should NOT have crossed the wall at x=0.5.
+ float wallFace = 0.5f - BSPStepUpFixtures.SphereRadius;
+ Assert.True(t.SpherePath.CurPos.X <= wallFace + PhysicsGlobals.EPSILON * 20f,
+ $"Expected mover blocked before wall (x <= {wallFace:F3}), " +
+ $"got CurPos.X = {t.SpherePath.CurPos.X:F4}");
+ }
+
+ ///
+ /// Direct Path 5 invocation: Contact mover sphere just overlapping the low
+ /// wall should NOT return Slid after L.2.1.
+ ///
+ ///
+ /// Current: returns Slid (wall-slide).
+ /// Expected after L.2.1: returns OK (step-up succeeded) with Z lifted.
+ ///
+ ///
+ [Fact]
+ public void B3_Path5_DirectCall_ContactHitsLowWall_NotSlid()
+ {
+ var (root, resolved) = BSPStepUpFixtures.LowStep();
+
+ // Sphere center overlaps the wall (x=0.5) by half-radius.
+ float r = BSPStepUpFixtures.SphereRadius;
+ var checkPos = new Vector3(0.5f - r * 0.5f, 0f, r);
+ var currPos = new Vector3(0.1f, 0f, r);
+
+ var t = new Transition();
+ t.SpherePath.InitPath(currPos, checkPos, 0xA9B40001u, r);
+ t.SpherePath.SetCheckPos(checkPos, 0xA9B40001u);
+ t.ObjectInfo.State = ObjectInfoState.Contact | ObjectInfoState.OnWalkable;
+ t.ObjectInfo.StepUpHeight = 0.30f;
+ t.ObjectInfo.StepDownHeight = 0.04f;
+ t.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, 0f);
+ t.CollisionInfo.LastKnownContactPlaneValid = true;
+
+ var localSphere = new DatReaderWriter.Types.Sphere { Origin = checkPos, Radius = r };
+
+ // Pass engine so Path 5 can call DoStepUp → DoStepDown (L.2.1).
+ // Without engine the fallback wall-slide would return Slid.
+ var engine = MakeTestEngine(root, resolved);
+
+ var result = BSPQuery.FindCollisions(
+ root, resolved, t, localSphere, null,
+ currPos, Vector3.UnitZ, 1.0f, Quaternion.Identity, engine);
+
+ // After L.2.1 this assertion flips from failing (Slid) to passing.
+ Assert.NotEqual(TransitionState.Slid, result);
+ }
+
+ // =========================================================================
+ // Group C — Phase L.2.2 (Path 6 SetCollide)
+ //
+ // RED before L.2.2, GREEN after.
+ // =========================================================================
+
+ ///
+ /// Airborne mover hitting the flat roof from above should set Collide flag
+ /// and return Adjusted (not Slid with wall-slide offset).
+ ///
+ ///
+ /// Current (wrong): Path 6 computes a wall-slide offset and returns Slid.
+ ///
+ ///
+ /// Expected after L.2.2: Path 6 calls path.SetCollide(worldNormal), sets
+ /// WalkableAllowance = LandingZ, returns Adjusted.
+ ///
+ ///
+ /// Retail: SPHEREPATH::set_collide
+ /// acclient_2013_pseudo_c.txt:321594 / ACE BSPTree.cs:210-219.
+ ///
+ [Fact]
+ public void C1_Path6_AirborneMoverHitsRoof_SetsCollideFlagAndAdjusted()
+ {
+ var (root, resolved) = BSPStepUpFixtures.FlatRoof();
+
+ // Sphere center just penetrating the roof polygon (z=3) from above.
+ float r = BSPStepUpFixtures.SphereRadius;
+ var checkPos = new Vector3(0f, 0f, 3f + r * 0.5f); // half-radius above roof
+ var currPos = new Vector3(0f, 0f, 3f + r + 0.1f); // clearly above
+
+ var t = new Transition();
+ t.SpherePath.InitPath(currPos, checkPos, 0xA9B40001u, r);
+ t.SpherePath.SetCheckPos(checkPos, 0xA9B40001u);
+ t.ObjectInfo.State = ObjectInfoState.None; // airborne — no Contact
+
+ var localSphere = new DatReaderWriter.Types.Sphere { Origin = checkPos, Radius = r };
+
+ var result = BSPQuery.FindCollisions(
+ root, resolved, t, localSphere, null,
+ currPos, Vector3.UnitZ, 1.0f);
+
+ // After L.2.2: result = Adjusted, Collide = true, WalkableAllowance = LandingZ.
+ // Currently: result = Slid (wall-slide path).
+ Assert.Equal(TransitionState.Adjusted, result);
+ Assert.True(t.SpherePath.Collide,
+ "Expected SpherePath.Collide = true after Path 6 hit (L.2.2)");
+ Assert.Equal(PhysicsGlobals.LandingZ, t.SpherePath.WalkableAllowance,
+ precision: 5);
+ }
+
+ ///
+ /// Full integration: airborne mover drops onto the 3 m flat roof.
+ ///
+ ///
+ /// After L.2.2: TransitionalInsert sees Collide flag, re-tests as Placement,
+ /// finds walkable polygon at z=3, sets ContactPlane with normal.Z ≈ 1.
+ ///
+ ///
+ /// Current: mover slides sideways off the roof (never lands).
+ /// Expected after L.2.2: ContactPlane is set with Normal.Z >= LandingZ.
+ ///
+ ///
+ [Fact]
+ public void C2_AirborneMover_LandsOnFlatRoof_ContactPlaneSet()
+ {
+ var (root, resolved) = BSPStepUpFixtures.FlatRoof();
+
+ float roofZ = 3f;
+ float r = BSPStepUpFixtures.SphereRadius;
+ // CurPos = foot position. Sphere center = CurPos + (0,0,r).
+ // from: foot at z = roofZ - r + 0.3f → sphere center at roofZ + 0.3 = 3.3 (above roof)
+ // to: foot at z = roofZ - r - 0.05f → sphere center at roofZ - 0.05 = 2.95 (into roof by 0.05)
+ // Roof polygon at z=roofZ, normal=+Z: dist = sphere_center.z - roofZ.
+ // At to: dist = -0.05; |dist| = 0.05 < rad=0.198 → roof hit ✓
+ var from = new Vector3(0f, 0f, roofZ - r + 0.3f);
+ var to = new Vector3(0f, 0f, roofZ - r - 0.05f); // sphere bottom at z ≈ 2.95 (into roof)
+
+ var t = BSPStepUpFixtures.MakeAirborneTransition(from, to);
+ // terrainZ=-50f: airborne mover — terrain must not interfere with roof landing.
+ var engine = MakeTestEngine(root, resolved, terrainZ: -50f);
+
+ t.FindTransitionalPosition(engine);
+
+ // After L.2.2: at least one of ContactPlane / LastKnownContactPlane is set.
+ bool planeSet = t.CollisionInfo.ContactPlaneValid
+ || t.CollisionInfo.LastKnownContactPlaneValid;
+
+ Assert.True(planeSet,
+ "Expected a contact plane after landing on roof (L.2.2). " +
+ "Currently Path 6 wall-slides and never sets ContactPlane.");
+
+ if (planeSet)
+ {
+ var plane = t.CollisionInfo.ContactPlaneValid
+ ? t.CollisionInfo.ContactPlane
+ : t.CollisionInfo.LastKnownContactPlane;
+
+ Assert.True(plane.Normal.Z >= PhysicsGlobals.LandingZ,
+ $"Contact plane normal.Z ({plane.Normal.Z:F4}) must be >= LandingZ ({PhysicsGlobals.LandingZ:F4})");
+ }
+ }
+
+ ///
+ /// Airborne mover descending toward a steep slope (normal.Z < FloorZ):
+ /// Path 6 should still set the Collide flag (it fires for any polygon hit,
+ /// walkable or not).
+ ///
+ /// Retail: set_collide fires unconditionally when sphere_intersects_poly
+ /// hits; the walkable check happens later in the Collide-flag handler.
+ ///
+ [Fact]
+ public void C3_Path6_AirborneMoverHitsSteepSlope_SetsCollide()
+ {
+ var (root, resolved) = BSPStepUpFixtures.SlopedUnwalkable();
+
+ float r = BSPStepUpFixtures.SphereRadius;
+ // Approach the slope mid-face from above.
+ var checkPos = new Vector3(0.5f, 0f, 1.0f + r * 0.5f);
+ var currPos = new Vector3(0.5f, 0f, 1.0f + r + 0.1f);
+
+ var t = new Transition();
+ t.SpherePath.InitPath(currPos, checkPos, 0xA9B40001u, r);
+ t.SpherePath.SetCheckPos(checkPos, 0xA9B40001u);
+ t.ObjectInfo.State = ObjectInfoState.None; // airborne
+
+ var localSphere = new DatReaderWriter.Types.Sphere { Origin = checkPos, Radius = r };
+
+ var result = BSPQuery.FindCollisions(
+ root, resolved, t, localSphere, null,
+ currPos, Vector3.UnitZ, 1.0f);
+
+ // After L.2.2: Collide flag set, Adjusted returned.
+ // Currently: Slid (wall-slide).
+ Assert.Equal(TransitionState.Adjusted, result);
+ Assert.True(t.SpherePath.Collide,
+ "Expected Collide flag set when airborne sphere hits slope (L.2.2)");
+ }
+
+ // =========================================================================
+ // Group D — Phase L.2.3 regression tests
+ //
+ // Bugs caught by live testing 2026-04-29:
+ // D1 — walking into a too-tall wall must NOT clear ContactPlane (animation
+ // flickers to "falling" when contact is lost mid-step against a wall).
+ // D2 — Path 5 step-up must NOT recurse infinitely against a tall wall
+ // (retail guards step_sphere_up with `if (sp.step_up == 0)` per
+ // acclient_2013_pseudo_c.txt:272954). Without the guard, DoStepUp
+ // invokes DoStepDown which TransitionalInsert(5)'s into FindObjCollisions
+ // which hits the same wall AGAIN → recursive DoStepUp.
+ // =========================================================================
+
+ ///
+ /// L.2.3c regression: a grounded mover walking into a too-tall wall must
+ /// retain its ground contact across the failed step-up. Before the fix,
+ /// DoStepUp cleared
+ /// unconditionally; on failure, RestoreCheckPos restored the position but
+ /// the contact plane stayed cleared, causing OnWalkable to drop and the
+ /// animation system to interpret the stuck-against-wall state as "airborne".
+ ///
+ [Fact]
+ public void D1_GroundedMover_TooTallWall_PreservesContactPlane()
+ {
+ var (root, resolved) = BSPStepUpFixtures.TallWall();
+
+ // Foot at z=0, walking into the wall.
+ var from = new Vector3(0.1f, 0f, 0f);
+ var to = new Vector3(0.6f, 0f, 0f);
+
+ // StepUpHeight 0.04m — too small to climb the 5m wall.
+ var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, stepUpHeight: 0.04f);
+ var engine = MakeTestEngine(root, resolved, terrainZ: 0f);
+
+ t.FindTransitionalPosition(engine);
+
+ // After failed step-up + slide, the mover should still be considered
+ // grounded — either via the live contact plane, the last-known one,
+ // or the OnWalkable flag preserved by terrain re-detection.
+ bool stillGrounded = t.CollisionInfo.ContactPlaneValid
+ || t.CollisionInfo.LastKnownContactPlaneValid
+ || t.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
+ Assert.True(stillGrounded,
+ "Expected mover to still be grounded after walking into a too-tall " +
+ "wall (failed step-up should preserve LastKnownContactPlane).");
+ }
+
+ ///
+ /// L.2.3b regression: Path 5 dispatch must be guarded against re-entry while
+ /// a step-up is already in progress. Test runs FindTransitionalPosition
+ /// with a tight time budget and verifies it terminates cleanly. Without the
+ /// guard the recursive DoStepUp churns the contact plane until numAttempts
+ /// runs out — finishing in an inconsistent state.
+ ///
+ [Fact]
+ public void D2_GroundedMover_TallWall_DoesNotRecurseInfinitely()
+ {
+ var (root, resolved) = BSPStepUpFixtures.TallWall();
+
+ var from = new Vector3(0.1f, 0f, 0f);
+ var to = new Vector3(0.6f, 0f, 0f);
+
+ var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, stepUpHeight: 0.04f);
+ var engine = MakeTestEngine(root, resolved, terrainZ: 0f);
+
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ t.FindTransitionalPosition(engine);
+ sw.Stop();
+
+ // Bounded execution: even with recursion, this is a 4-step movement.
+ // 100ms is generous; without the guard, recursion adds noticeable cost.
+ Assert.True(sw.ElapsedMilliseconds < 100,
+ $"Step-up against tall wall took {sw.ElapsedMilliseconds}ms — " +
+ "indicates Path 5 recursing through DoStepUp without guard.");
+ }
+
+ // =========================================================================
+ // Helpers
+ // =========================================================================
+
+ ///
+ /// Build a that serves one synthetic BSP object.
+ /// sets every terrain sample to the given height.
+ /// Use 0f for grounded tests (terrain flush with the BSP floor at z=0, so the
+ /// step-down probe finds ground and keeps Contact/OnWalkable set between steps).
+ /// Use -50f for tests where terrain must never interfere (airborne / roof landing).
+ ///
+ private static PhysicsEngine MakeTestEngine(
+ PhysicsBSPNode root,
+ Dictionary resolved,
+ Vector3? objectPosition = null,
+ float terrainZ = 0f)
+ {
+ const uint LandblockId = 0xA9B4FFFFu;
+ const uint SyntheticGfxId = 0xDEADBEEFu;
+
+ var heights = new byte[81]; // all zero → uses index 0 from heightTable
+ var heightTab = new float[256];
+ for (int i = 0; i < 256; i++) heightTab[i] = terrainZ;
+
+ var engine = new PhysicsEngine();
+ engine.AddLandblock(
+ LandblockId,
+ new TerrainSurface(heights, heightTab),
+ Array.Empty(),
+ Array.Empty(),
+ worldOffsetX: 0f, worldOffsetY: 0f);
+
+ // Register the BSP physics into the data cache.
+ var cache = new PhysicsDataCache();
+ var bspTree = new DatReaderWriter.Types.PhysicsBSPTree { Root = root };
+ var physics = new GfxObjPhysics
+ {
+ BSP = bspTree,
+ PhysicsPolygons = new Dictionary(),
+ Vertices = new DatReaderWriter.Types.VertexArray(),
+ Resolved = resolved,
+ BoundingSphere = new DatReaderWriter.Types.Sphere { Origin = Vector3.Zero, Radius = 15f },
+ };
+ cache.RegisterGfxObjForTest(SyntheticGfxId, physics);
+ engine.DataCache = cache;
+
+ // Register the object in the shadow registry so FindObjCollisions picks it up.
+ Vector3 pos = objectPosition ?? Vector3.Zero;
+ engine.ShadowObjects.Register(
+ entityId: SyntheticGfxId,
+ gfxObjId: SyntheticGfxId,
+ worldPos: pos,
+ rotation: Quaternion.Identity,
+ radius: 15f,
+ worldOffsetX: 0f,
+ worldOffsetY: 0f,
+ landblockId: LandblockId,
+ collisionType: ShadowCollisionType.BSP,
+ scale: 1.0f);
+
+ return engine;
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
index d6e6e554..79d6ac86 100644
--- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
@@ -190,6 +190,105 @@ public class PhysicsEngineTests
Assert.True(result.Position.X > 192f);
}
+ [Fact]
+ public void ResolveWithTransition_OutdoorCellBoundary_UpdatesLowCellId()
+ {
+ var engine = MakeFlatEngine(terrainZ: 50f);
+
+ var result = engine.ResolveWithTransition(
+ currentPos: new Vector3(23f, 10f, 50f),
+ targetPos: new Vector3(25f, 10f, 50f),
+ cellId: 0x0001u,
+ sphereRadius: 0.5f,
+ sphereHeight: 1.2f,
+ stepUpHeight: 0.4f,
+ stepDownHeight: 0.4f,
+ isOnGround: true);
+
+ Assert.True(result.IsOnGround);
+ Assert.InRange(result.Position.X, 24.9f, 25.1f);
+ Assert.Equal(0x0009u, result.CellId);
+ }
+
+ [Fact]
+ public void ResolveWithTransition_EdgeSlideFlag_AllowsNormalFlatMovement()
+ {
+ var engine = MakeFlatEngine(terrainZ: 50f);
+
+ var result = engine.ResolveWithTransition(
+ currentPos: new Vector3(96f, 96f, 50f),
+ targetPos: new Vector3(98f, 96f, 50f),
+ cellId: 0x0025u,
+ sphereRadius: 0.5f,
+ sphereHeight: 1.2f,
+ stepUpHeight: 0.4f,
+ stepDownHeight: 0.4f,
+ isOnGround: true,
+ moverFlags: ObjectInfoState.EdgeSlide);
+
+ Assert.True(result.IsOnGround);
+ Assert.InRange(result.Position.X, 97.9f, 98.1f);
+ Assert.Equal(0x0025u, result.CellId);
+ }
+
+ [Fact]
+ public void ResolveWithTransition_EdgeSlideStopsAtLoadedTerrainBoundary()
+ {
+ var engine = MakeFlatEngine(terrainZ: 50f);
+ var body = new PhysicsBody
+ {
+ Position = new Vector3(191.25f, 96f, 50f),
+ TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
+ ContactPlaneValid = true,
+ ContactPlane = new Plane(Vector3.UnitZ, -50f),
+ ContactPlaneCellId = 0x003Du,
+ };
+
+ var result = engine.ResolveWithTransition(
+ currentPos: new Vector3(191.25f, 96f, 50f),
+ targetPos: new Vector3(193f, 96f, 50f),
+ cellId: 0x003Du,
+ sphereRadius: 0.5f,
+ sphereHeight: 1.2f,
+ stepUpHeight: 0.4f,
+ stepDownHeight: 0.4f,
+ isOnGround: true,
+ body: body,
+ moverFlags: ObjectInfoState.EdgeSlide);
+
+ Assert.True(result.IsOnGround);
+ Assert.InRange(result.Position.X, 190.75f, 192.0001f);
+ Assert.Equal(50f, result.Position.Z, precision: 2);
+ }
+
+ [Fact]
+ public void ResolveWithTransition_LandblockBoundary_UpdatesFullOutdoorCellId()
+ {
+ var engine = new PhysicsEngine();
+
+ var terrainA = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
+ engine.AddLandblock(0xA9B4FFFFu, terrainA, Array.Empty(),
+ Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f);
+
+ var terrainB = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
+ engine.AddLandblock(0xAAB4FFFFu, terrainB, Array.Empty(),
+ Array.Empty(), worldOffsetX: 192f, worldOffsetY: 0f);
+
+ var result = engine.ResolveWithTransition(
+ currentPos: new Vector3(191f, 10f, 50f),
+ targetPos: new Vector3(193f, 10f, 50f),
+ cellId: 0xA9B40039u,
+ sphereRadius: 0.5f,
+ sphereHeight: 1.2f,
+ stepUpHeight: 0.4f,
+ stepDownHeight: 0.4f,
+ isOnGround: true);
+
+ Assert.True(result.IsOnGround);
+ Assert.InRange(result.Position.X, 192.9f, 193.1f);
+ Assert.Equal(0xAAB40001u, result.CellId);
+ }
+
[Fact]
public void Resolve_LeaveIndoorCell_TransitionsToOutdoor()
{
diff --git a/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs b/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs
index fb70cd42..7ceda8fd 100644
--- a/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs
@@ -67,6 +67,20 @@ public class TerrainSurfaceTests
Assert.Equal(42f, surface.SampleZ(300f, 300f));
}
+ [Fact]
+ public void SampleSurfacePolygon_ReturnsContainingTriangleVertices()
+ {
+ var heights = FlatHeightmap(50);
+ var surface = new TerrainSurface(heights, LinearHeightTable(), landblockX: 0, landblockY: 0);
+
+ var sample = surface.SampleSurfacePolygon(2f, 2f);
+
+ Assert.Equal(3, sample.Vertices.Length);
+ Assert.All(sample.Vertices, v => Assert.Equal(50f, v.Z));
+ Assert.Equal(1f, sample.Normal.Z, precision: 3);
+ Assert.Contains(sample.Vertices, v => v.X == 0f && v.Y == 0f);
+ }
+
[Fact]
public void ComputeOutdoorCellId_Origin_ReturnsFirst()
{