diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 6033efe9..9b67b514 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -177,68 +177,6 @@ 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)
@@ -421,40 +359,6 @@ 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 5f134da7..56b888d9 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -100,129 +100,67 @@ crib-sheet version.
---
-## Project Structure (current + target)
+## Project Structure (target)
```
src/
AcDream.Core/ Layer 2-4: no GL, no Silk.NET, pure logic
Physics/
- 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)
+ 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)
World/
- 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
+ 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
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 (target move from Meshing/)
+ MotionResolver.cs ← done (move here 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 -> still owns too much runtime wiring
- TerrainRenderer.cs -> done
- StaticMeshRenderer.cs -> done
- TextureCache.cs -> done
- ChaseCamera.cs -> done
- FlyCamera.cs -> done
+ GameWindow.cs ← TODO: thin down to GL calls only
+ 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 -> active movement driver
+ PlayerMovementController.cs ← done (uses ported physics)
Plugins/
- AppPluginHost.cs -> done
+ AppPluginHost.cs ← done
```
---
-## 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)
+## GameEntity: The Unified Entity (TODO — the big refactor)
Currently, entity state is scattered across:
- `WorldEntity` (position, rotation, mesh refs)
@@ -260,21 +198,21 @@ public sealed class GameEntity
{
Motion.ApplyCurrentMovement(); // set velocity from motion state
Physics.UpdateObject(dt); // integrate position
- PhysicsEngine.ResolveWithTransition(); // current L.2 collision spine
- Cell.UpdateCell(Physics.Position); // target: retail cell ownership
+ // TODO: Transition.FindValidPosition // collision resolve
+ Cell.UpdateCell(Physics.Position); // check cell transitions
Animation.Advance(dt); // advance animation frames
RebuildMeshRefs(); // compute per-part transforms
}
}
```
-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.
+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.
---
-## Per-Frame Update Order (current runtime)
+## Per-Frame Update Order (matches retail)
```
1. Network tick
@@ -286,16 +224,15 @@ The plugin API exposes them as `WorldEntitySnapshot`. GameWindow becomes thin.
create terrain + scenery GameEntities
3. Input tick (player mode only)
- └── InputDispatcher scopes → PlayerMovementController →
- MotionInterpreter/body prediction → ResolveWithTransition →
+ └── Read WASD/mouse → MotionInterpreter.DoMotion →
send MoveToState/AutonomousPosition to server
-4. Entity / animation tick
- └── Current code still has scattered world/entity state. L.1 owns
- animation parity; L.2 owns movement/collision conformance.
+4. Entity tick (ALL entities, 30Hz fixed step)
+ └── For each GameEntity: entity.Update(dt)
+ This runs: motion → physics → collision → cell → animation
5. Render tick
- └── Read current entity mesh refs, draw
+ └── For each GameEntity: read MeshRefs, draw
TerrainRenderer.Draw, StaticMeshRenderer.Draw
(frustum cull, translucency pass, etc.)
@@ -311,26 +248,89 @@ The plugin API exposes them as `WorldEntitySnapshot`. GameWindow becomes thin.
---
-## Roadmap Model
+## Execution Plan: How to Get There
-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/`.
+### Phase R1: GameEntity Refactor (the foundation)
+**Goal:** Replace the scattered entity state with unified GameEntity.
-Current movement/collision ownership:
+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`
-- **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.
+**Test:** Everything looks the same as before. No visual change.
-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.
+### 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.
---
@@ -339,12 +339,11 @@ for new work; add or update roadmap phases instead.
```
For every AC-specific behavior:
-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
+1. DECOMPILE → Find the function in docs/research/decompiled/
+2. CROSS-CHECK → Verify against ACE + ACME + holtburger
3. PSEUDOCODE → Translate to readable pseudocode
4. PORT → Faithful C# translation
-5. TEST → Conformance test against retail/decomp golden values
+5. TEST → Conformance test against decompiled golden values
6. INTEGRATE → Surgical wiring into the existing system
7. VERIFY → Visual + functional test
```
@@ -360,9 +359,9 @@ For acdream-specific code (renderer, plugin API, streaming):
| Domain | Primary Oracle | Secondary |
|--------|---------------|-----------|
-| 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 |
+| Physics/collision | Decompiled acclient.exe | ACE Physics/ |
+| Animation | Decompiled + ACE Animation/ | — |
+| Terrain | ACME ClientReference.cs | Decompiled |
| 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 a75fd3fe..c4cb6ee8 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-29 for Phase L.2 movement/collision conformance planning.
+**Status:** Living document. Updated 2026-04-11 after Phase 6, 7.1, 9.1, 9.2 landed.
**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 MVP resolver foundation — terrain contact, CellSurface prototype, streaming-populated collision inputs, and first `PhysicsEngine` resolver path. Not the complete retail collision system. | Tests ✓ |
+| 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.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 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`.
+- **✓ 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`.
- **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. **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`.
+- **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`.
**Acceptance:** walk outside at dusk, see the sky gradient + sun moving; enter a torch-lit dungeon via portal; leave back to daylight.
@@ -318,11 +318,6 @@ 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
@@ -354,55 +349,6 @@ change must keep both phase plans in sync.
---
-### 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.
@@ -485,10 +431,7 @@ 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.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** |
+| Walking around doesn't move me on the server | **Phase B.3 FIXED** ✓ |
| 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) |
@@ -501,7 +444,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** after **L.2e** cell/building ownership |
+| Dungeons / foundry interior missing | **Phase G.3** |
| 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
deleted file mode 100644
index 90de9436..00000000
--- a/docs/plans/2026-04-29-movement-collision-conformance.md
+++ /dev/null
@@ -1,215 +0,0 @@
-# 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
deleted file mode 100644
index 8ddc4c8f..00000000
--- a/docs/research/2026-04-30-precipice-slide-pseudocode.md
+++ /dev/null
@@ -1,110 +0,0 @@
-# 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 1866b886..dc1fcd1a 100644
--- a/memory/project_collision_port.md
+++ b/memory/project_collision_port.md
@@ -1,128 +1,131 @@
-# Collision System Port - Status and Plan
+# Collision System Port — Status and Plan
-## Current State (2026-04-29)
+## Current State (2026-04-14)
-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:
+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.
-- `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.
+## What Went Wrong
-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:
+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
-- 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
+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.
-## Durable Lesson
+## What Must Happen Next
-Do not guess at AC physics, movement packets, terrain/cell ownership, or
-collision constants. The previous patchwork failures came from simplified
-approximations:
+**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#:
-- 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
+### Files to port from ACE (in order):
-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.
+1. **Sphere.cs** — `IntersectsSphere` (FUN_005387c0), `SlideSphere` (both variants), `StepSphereUp`, `StepSphereDown`, `LandOnSphere`, `CollideWithPoint`, `CollidesWithSphere`
-## Active Approach
+2. **BSPTree.cs** — `find_collisions` (6-path dispatcher)
-Continue by conformance lanes rather than rewriting blindly:
+3. **BSPNode.cs** — `sphere_intersects_poly` (tree traversal with movement), `find_walkable`, `hits_walkable`, `sphere_intersects_solid`
-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.
+4. **BSPLeaf.cs** — leaf-level polygon tests
-## What To Preserve
+5. **Polygon.cs** — `pos_hits_sphere`, `adjust_sphere_to_plane`, `check_walkable`
-- `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.
+6. **Transition.cs** — `FindTransitionalPosition`, `TransitionalInsert`, `StepUp`, `StepDown`, `ValidateTransition`, `AdjustOffset`
-## Known Gaps
+7. **SpherePath.cs** — `SetCheckPos`, `AddOffsetToCheckPos`, `CacheLocalSpaceSphere`, `SetCollide`, `SetWalkable`, `SetNegPolyHit`
-- 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.
+8. **CollisionInfo.cs** — `SetContactPlane`, `SetSlidingNormal`, `SetCollisionNormal`
-## Retail Anchors
+9. **ObjectInfo.cs** — `ValidateWalkable`
-Primary:
+10. **LandCell.cs** — `FindEnvCollisions` (outdoor terrain)
-- `docs/research/named-retail/acclient_2013_pseudo_c.txt`
-- `docs/research/named-retail/acclient.h`
-- `docs/research/named-retail/symbols.json`
+11. **EnvCell.cs** — `FindEnvCollisions` (indoor BSP)
-Key names:
+12. **ObjCell.cs** — `FindObjCollisions`, `find_cell_list`
-- `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`
+### 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`
-Older fallback:
+### 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).
-- `docs/research/decompiled/chunk_00530000.c`
-- `docs/research/decompiled/chunk_00500000.c`
-- `docs/research/acclient_function_map.md`
+### Pseudocode (already written):
+- `docs/research/transition_pseudocode.md` — full system documented
-Reference aids:
+## What to Keep
-- `references/ACE/Source/ACE.Server/Physics/`
-- `references/holtburger/` for movement wire behavior
-- `references/AC2D/` for the older client-side movement packet reference
+- `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.
-## Mandatory Workflow
+## What to Replace
-For every AC-specific function:
+- `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
-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.
+## 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
diff --git a/memory/project_movement_collision_conformance.md b/memory/project_movement_collision_conformance.md
deleted file mode 100644
index 5db6326d..00000000
--- a/memory/project_movement_collision_conformance.md
+++ /dev/null
@@ -1,74 +0,0 @@
-# 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 64f9f5c8..8084231a 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -97,25 +97,11 @@ public sealed class PlayerMovementController
///
/// Maximum Z increase per movement step before the move is rejected.
- /// 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.
+ /// 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.
///
- 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;
+ public float StepUpHeight { get; set; } = 5.0f;
///
/// Current portal-space state. Set to PortalSpace when the server sends
@@ -425,112 +411,19 @@ public sealed class PlayerMovementController
sphereRadius: 0.48f, // human player radius from Setup
sphereHeight: 1.2f, // human player height from Setup
stepUpHeight: StepUpHeight,
- stepDownHeight: StepDownHeight, // L.2.3a: from Setup.StepDownHeight
+ stepDownHeight: 0.04f, // retail default
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
- | AcDream.Core.Physics.ObjectInfoState.EdgeSlide);
+ moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer);
// 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 771c37d3..ba3c9781 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -409,8 +409,6 @@ 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
@@ -466,19 +464,6 @@ 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
@@ -3091,7 +3076,6 @@ 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
@@ -4869,7 +4853,6 @@ 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)
{
@@ -4902,10 +4885,7 @@ public sealed class GameWindow : IDisposable
instanceSequence: _liveSession.InstanceSequence,
serverControlSequence: _liveSession.ServerControlSequence,
teleportSequence: _liveSession.TeleportSequence,
- forcePositionSequence: _liveSession.ForcePositionSequence,
- contactLongJump: contactByte);
- DumpMovementTruthOutbound(
- "MTS", seq, result, wirePos, wireCellId, contactByte);
+ forcePositionSequence: _liveSession.ForcePositionSequence);
_liveSession.SendGameAction(body);
}
@@ -4920,10 +4900,7 @@ public sealed class GameWindow : IDisposable
instanceSequence: _liveSession.InstanceSequence,
serverControlSequence: _liveSession.ServerControlSequence,
teleportSequence: _liveSession.TeleportSequence,
- forcePositionSequence: _liveSession.ForcePositionSequence,
- lastContact: contactByte);
- DumpMovementTruthOutbound(
- "AP", seq, result, wirePos, wireCellId, contactByte);
+ forcePositionSequence: _liveSession.ForcePositionSequence);
_liveSession.SendGameAction(body);
}
@@ -4947,76 +4924,6 @@ 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.
@@ -5848,19 +5755,15 @@ public sealed class GameWindow : IDisposable
preIntegratePos, postIntegratePos, rm.CellId,
sphereRadius: 0.48f,
sphereHeight: 1.2f,
- stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f
- stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f
+ stepUpHeight: 2.0f, // retail default for unknown remotes
+ stepDownHeight: 0.04f, // PhysicsGlobals.DefaultStepHeight
// 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
- // 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);
+ body: rm.Body); // persist ContactPlane across frames for slope tracking
rm.Body.Position = resolveResult.Position;
if (resolveResult.CellId != 0)
@@ -7113,13 +7016,7 @@ public sealed class GameWindow : IDisposable
_playerController.SetCharacterSkills(_lastSeenRunSkill, _lastSeenJumpSkill);
Console.WriteLine($"live: {loggingTag} — applied server skills run={_lastSeenRunSkill} jump={_lastSeenJumpSkill}");
}
- // 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.
+ // Read the real step height from the player's Setup dat.
if (_dats is not null && (playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
{
var playerSetup = _dats.Get(playerEntity.SourceGfxObjOrSetupId);
@@ -7127,26 +7024,11 @@ public sealed class GameWindow : IDisposable
_physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, playerSetup);
_playerController.StepUpHeight = (playerSetup is not null && playerSetup.StepUpHeight > 0f)
? playerSetup.StepUpHeight
- : 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})");
+ : 2f;
}
else
{
- _playerController.StepUpHeight = 0.4f;
- _playerController.StepDownHeight = 0.4f;
- Console.WriteLine($"physics: player step heights — defaulting to 0.4 m (no setup dat)");
+ _playerController.StepUpHeight = 2f;
}
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 4b68d428..d574887b 100644
--- a/src/AcDream.Core.Net/Messages/CreateObject.cs
+++ b/src/AcDream.Core.Net/Messages/CreateObject.cs
@@ -120,13 +120,7 @@ public static class CreateObject
ushort ServerControlSequence = 0,
ushort ForcePositionSequence = 0,
uint? PhysicsState = 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);
+ uint? ObjectDescriptionFlags = null);
///
/// The relevant subset of the server-sent MovementData /
@@ -292,13 +286,6 @@ 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
{
@@ -466,25 +453,8 @@ public static class CreateObject
objScale = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
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.Friction) != 0) pos += 4;
+ if ((physicsFlags & PhysicsDescriptionFlag.Elasticity) != 0) pos += 4;
if ((physicsFlags & PhysicsDescriptionFlag.Translucency) != 0) pos += 4;
if ((physicsFlags & PhysicsDescriptionFlag.Velocity) != 0) pos += 12; // vec3
if ((physicsFlags & PhysicsDescriptionFlag.Acceleration) != 0) pos += 12;
@@ -540,18 +510,14 @@ 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,
- friction, elasticity);
+ physicsState, objectDescriptionFlags);
// 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,
- Friction: friction,
- Elasticity: elasticity);
+ PhysicsState: physicsState, ObjectDescriptionFlags: objectDescriptionFlags);
}
catch
{
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 47ef10bc..030573b8 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -63,13 +63,7 @@ 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,
- // 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);
+ uint? ObjectDescriptionFlags = null);
/// Fires when the session finishes parsing a CreateObject.
public event Action? EntitySpawned;
@@ -663,9 +657,7 @@ public sealed class WorldSession : IDisposable
parsed.Value.MotionState,
parsed.Value.MotionTableId,
parsed.Value.PhysicsState,
- parsed.Value.ObjectDescriptionFlags,
- parsed.Value.Friction,
- parsed.Value.Elasticity));
+ parsed.Value.ObjectDescriptionFlags));
}
}
else if (op == DeleteObject.Opcode)
diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs
index b86ccc4f..dfef08bd 100644
--- a/src/AcDream.Core/Physics/BSPQuery.cs
+++ b/src/AcDream.Core/Physics/BSPQuery.cs
@@ -377,33 +377,30 @@ public static class BSPQuery
///
/// ACE: Polygon.cs find_crossed_edge.
///
- internal static bool FindCrossedEdge(
- Plane polyPlane,
- ReadOnlySpan verts,
- Vector3 sphereCenter,
- Vector3 up,
- out Vector3 normal)
+ private static bool FindCrossedEdge(
+ ResolvedPolygon poly,
+ CollisionSphere sphere,
+ Vector3 up,
+ ref Vector3 normal)
{
- normal = Vector3.Zero;
-
- float angleUp = Vector3.Dot(polyPlane.Normal, up);
+ float angleUp = Vector3.Dot(poly.Plane.Normal, up);
if (MathF.Abs(angleUp) < PhysicsGlobals.EPSILON) return false;
- float angle = (Vector3.Dot(polyPlane.Normal, sphereCenter) + polyPlane.D) / angleUp;
- var center = sphereCenter - up * angle;
+ float angle = (Vector3.Dot(poly.Plane.Normal, sphere.Center) + poly.Plane.D) / angleUp;
+ var center = sphere.Center - up * angle;
- int n = verts.Length;
+ int n = poly.Vertices.Length;
int prevIdx = n - 1;
for (int i = 0; i < n; i++)
{
- var v = verts[i];
- var lv = verts[prevIdx];
+ var v = poly.Vertices[i];
+ var lv = poly.Vertices[prevIdx];
prevIdx = i;
var edge = v - lv;
var disp = center - lv;
- var cross = Vector3.Cross(polyPlane.Normal, edge);
+ var cross = Vector3.Cross(poly.Plane.Normal, edge);
if (Vector3.Dot(disp, cross) < 0f)
{
@@ -415,47 +412,6 @@ 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
@@ -1081,8 +1037,7 @@ public static class BSPQuery
CollisionSphere checkPos,
Vector3 up,
float scale,
- Quaternion localToWorld = default,
- Vector3 worldOrigin = default)
+ Quaternion localToWorld = default)
{
if (localToWorld == default) localToWorld = Quaternion.Identity;
@@ -1106,12 +1061,14 @@ public static class BSPQuery
var offset = Vector3.Transform(adjusted, localToWorld) * scale;
path.AddOffsetToCheckPos(offset);
- 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);
+ var worldNormal = Vector3.Transform(polyHit.Plane.Normal, localToWorld);
+ collisions.SetContactPlane(
+ new Plane(worldNormal, polyHit.Plane.D * scale),
+ path.CheckCellId, false);
- path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ);
+ path.WalkableValid = true;
+ path.WalkablePlane = new Plane(worldNormal, polyHit.Plane.D * scale);
+ path.WalkableAllowance = PhysicsGlobals.FloorZ;
return TransitionState.Adjusted;
}
@@ -1128,28 +1085,34 @@ public static class BSPQuery
/// BSPTree.step_sphere_up — attempt to step over a low obstacle.
///
///
- /// 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.
+ /// 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).
///
///
- ///
- /// ACE: BSPTree.step_sphere_up calls transition.StepUp(globNormal);
- /// on false → SpherePath.StepUpSlide(transition).
- /// Named-retail: BSPTREE::step_sphere_up.
- ///
+ /// ACE: BSPTree.cs step_sphere_up.
///
private static TransitionState StepSphereUp(
- Transition transition,
- Vector3 collisionNormal,
- PhysicsEngine engine)
+ Transition transition,
+ Vector3 collisionNormal)
{
- if (transition.DoStepUp(collisionNormal, engine!))
- return TransitionState.OK;
+ var path = transition.SpherePath;
+ var ci = transition.CollisionInfo;
- return transition.SpherePath.StepUpSlide(transition);
+ // 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;
+ return TransitionState.OK;
+ }
+
+ // StepUpSlide: can't step up, set collision normal and report adjusted.
+ ci.SetCollisionNormal(collisionNormal);
+ return TransitionState.Adjusted;
}
// -------------------------------------------------------------------------
@@ -1401,9 +1364,7 @@ public static class BSPQuery
Vector3 localCurrCenter,
Vector3 localSpaceZ,
float scale,
- Quaternion localToWorld = default,
- PhysicsEngine? engine = null,
- Vector3 worldOrigin = default)
+ Quaternion localToWorld = default)
{
if (root is null) return TransitionState.OK;
// Default quaternion (0,0,0,0) → treat as identity
@@ -1454,7 +1415,7 @@ public static class BSPQuery
// ----------------------------------------------------------------
if (path.StepDown)
{
- return StepSphereDown(root, resolved, transition, sphere0, localSpaceZ, scale, localToWorld, worldOrigin);
+ return StepSphereDown(root, resolved, transition, sphere0, localSpaceZ, scale, localToWorld);
}
// ----------------------------------------------------------------
@@ -1477,12 +1438,14 @@ public static class BSPQuery
var worldOffset = L2W(localOffset) * scale;
path.AddOffsetToCheckPos(worldOffset);
- 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);
+ var worldNormal = L2W(hitPoly.Plane.Normal);
+ collisions.SetContactPlane(
+ new Plane(worldNormal, hitPoly.Plane.D * scale),
+ path.CheckCellId, false);
- path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ);
+ path.WalkableValid = true;
+ path.WalkablePlane = new Plane(worldNormal, hitPoly.Plane.D * scale);
+ path.WalkableAllowance = PhysicsGlobals.FloorZ;
return TransitionState.Adjusted;
}
@@ -1490,15 +1453,12 @@ public static class BSPQuery
}
// ----------------------------------------------------------------
- // 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.
+ // 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.
// ----------------------------------------------------------------
if (obj.State.HasFlag(ObjectInfoState.Contact))
{
@@ -1510,19 +1470,26 @@ public static class BSPQuery
if (hit0 || hitPoly0 is not null)
{
- 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);
+ // Wall-slide response (same as Path 6 below).
+ var localNormal = hitPoly0!.Plane.Normal;
+ var localMovement = sphere0.Center - localCurrCenter;
- // No engine OR step-up/step-down already in progress — fall
- // back to wall-slide so the inner sphere doesn't recurse.
+ 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);
collisions.SetCollisionNormal(worldNormal);
collisions.SetSlidingNormal(worldNormal);
return TransitionState.Slid;
@@ -1538,11 +1505,25 @@ public static class BSPQuery
if (hit1 || hitPoly1 is not null)
{
- 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);
+ 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;
@@ -1572,19 +1553,50 @@ public static class BSPQuery
hitPoly0!, contact0, scale, localToWorld);
}
- // ─── 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.
+ // ─── 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.
//
- // 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);
+ // 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);
path.WalkableAllowance = PhysicsGlobals.LandingZ;
- return TransitionState.Adjusted;
+ collisions.SetCollisionNormal(worldNormal);
+ collisions.SetSlidingNormal(worldNormal);
+ return TransitionState.Slid;
}
if (sphere1 is not null)
@@ -1597,11 +1609,29 @@ public static class BSPQuery
if (hit1 || hitPoly1 is not null)
{
- // Head sphere hit: same SetCollide response.
- var worldNormal1 = L2W(hitPoly1!.Plane.Normal);
- path.SetCollide(worldNormal1);
- path.WalkableAllowance = PhysicsGlobals.LandingZ;
- return TransitionState.Adjusted;
+ // 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;
}
}
}
diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs
index 753db366..9c939150 100644
--- a/src/AcDream.Core/Physics/PhysicsBody.cs
+++ b/src/AcDream.Core/Physics/PhysicsBody.cs
@@ -31,14 +31,6 @@ 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)
}
@@ -52,7 +44,6 @@ 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
}
@@ -96,9 +87,6 @@ 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.
@@ -125,18 +113,6 @@ 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;
@@ -280,16 +256,6 @@ 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 771f208d..225bf3f6 100644
--- a/src/AcDream.Core/Physics/PhysicsDataCache.cs
+++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs
@@ -209,14 +209,6 @@ 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 2fee39be..4a934031 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -4,13 +4,6 @@ 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
@@ -169,83 +162,6 @@ 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
@@ -518,29 +434,8 @@ 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;
@@ -569,54 +464,14 @@ 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,
- ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId),
- onGround,
- collisionNormalValid,
- collisionNormal);
+ return new ResolveResult(sp.CheckPos, sp.CheckCellId, onGround);
}
// Transition failed (e.g., stuck in corner, too many steps).
@@ -628,12 +483,6 @@ public sealed class PhysicsEngine
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable)
|| isOnGround;
- uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId;
- return new ResolveResult(
- sp.CheckPos,
- ResolveOutdoorCellId(sp.CheckPos, partialCellId),
- partialOnGround,
- collisionNormalValid,
- collisionNormal);
+ return new ResolveResult(sp.CheckPos, sp.CheckCellId != 0 ? sp.CheckCellId : cellId, partialOnGround);
}
}
diff --git a/src/AcDream.Core/Physics/ResolveResult.cs b/src/AcDream.Core/Physics/ResolveResult.cs
index 63d18452..cc7fef83 100644
--- a/src/AcDream.Core/Physics/ResolveResult.cs
+++ b/src/AcDream.Core/Physics/ResolveResult.cs
@@ -6,25 +6,8 @@ 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,
- /// 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);
+ bool IsOnGround);
diff --git a/src/AcDream.Core/Physics/TerrainSurface.cs b/src/AcDream.Core/Physics/TerrainSurface.cs
index caa54935..6a375069 100644
--- a/src/AcDream.Core/Physics/TerrainSurface.cs
+++ b/src/AcDream.Core/Physics/TerrainSurface.cs
@@ -1,13 +1,7 @@
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
@@ -256,72 +250,6 @@ 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 d48a0a01..57d6e6d2 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -64,27 +64,6 @@ 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 */ }
}
///
@@ -188,10 +167,7 @@ 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;
@@ -234,97 +210,6 @@ 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.
///
@@ -397,9 +282,6 @@ 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
// -----------------------------------------------------------------------
@@ -609,57 +491,11 @@ 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: Transition.TransitionalInsert Collide branch (Transition.cs:891-930).
- // Named-retail: CTransition::transitional_insert Collide branch.
+ // ACE: if Collide is set, re-test as Placement to confirm position.
+ // Simplified: just clear it and accept.
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).
@@ -678,62 +514,35 @@ public sealed class Transition
if (!ci.ContactPlaneValid && oi.Contact && !sp.StepDown
&& sp.CheckCellId != 0 && oi.StepDown)
{
- // 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 zVal = PhysicsGlobals.LandingZ;
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, runPlacement: false))
+ if (DoStepDown(stepDownHeight, zVal, engine))
{
- sp.ClearWalkable();
+ sp.WalkableValid = false;
return TransitionState.OK;
}
}
else
{
stepDownHeight *= 0.5f;
- if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false)
- || DoStepDown(stepDownHeight, zVal, engine, runPlacement: false))
+ if (DoStepDown(stepDownHeight, zVal, engine)
+ || DoStepDown(stepDownHeight, zVal, engine))
{
- sp.ClearWalkable();
+ sp.WalkableValid = false;
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);
+ // Step-down failed: stay at current position.
+ sp.RestoreCheckPos();
+ return TransitionState.OK;
}
return TransitionState.OK;
@@ -744,121 +553,6 @@ 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
// -----------------------------------------------------------------------
@@ -875,10 +569,6 @@ 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;
@@ -924,9 +614,7 @@ public sealed class Transition
localSphere1,
localCurrCenter,
Vector3.UnitZ, // local space Z is up
- 1.0f, // scale = 1.0 for cell geometry
- Quaternion.Identity,
- engine); // engine needed for Path 5 step-up
+ 1.0f); // scale = 1.0 for cell geometry
if (cellState != TransitionState.OK)
{
@@ -950,10 +638,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. SampleTerrainWalkable returns
- // that plane plus the triangle vertices needed by precipice slide.
- var terrainWalkable = engine.SampleTerrainWalkable(footCenter.X, footCenter.Y);
- if (terrainWalkable is null)
+ // 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)
return TransitionState.OK; // no terrain loaded here — allow pass-through
// Per-point water depth: 0.9 on fully water cells, 0.45 on partial-
@@ -963,11 +651,12 @@ 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.
- return ValidateWalkable(footCenter, sphereRadius, terrainWalkable.Value.Plane,
- terrainWalkable.Value.IsWater,
- terrainWalkable.Value.WaterDepth,
- cellId: terrainWalkable.Value.CellId,
- walkableVertices: terrainWalkable.Value.Vertices);
+ float waterDepth = engine.SampleWaterDepth(footCenter.X, footCenter.Y);
+ bool isWater = waterDepth >= 0.45f;
+
+ return ValidateWalkable(footCenter, sphereRadius, planeOpt.Value,
+ isWater, waterDepth,
+ cellId: sp.CheckCellId);
}
///
@@ -978,19 +667,12 @@ public sealed class Transition
///
private TransitionState ValidateWalkable(Vector3 sphereCenter, float sphereRadius,
System.Numerics.Plane contactPlane,
- bool isWater, float waterDepth, uint cellId,
- Vector3[]? walkableVertices = null)
+ bool isWater, float waterDepth, uint cellId)
{
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);
@@ -1013,10 +695,7 @@ 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)
{
@@ -1038,7 +717,6 @@ public sealed class Transition
if (sp.StepDown || !oi.OnWalkable || walkable)
{
ci.SetContactPlane(contactPlane, cellId, isWater);
- CacheWalkableContext();
if (sp.StepDown)
{
@@ -1066,6 +744,11 @@ 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
@@ -1095,17 +778,23 @@ 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);
- foreach (var obj in 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)
{
// Broad-phase: can the moving sphere reach this object?
Vector3 deltaToCurr = currPos - obj.Position;
@@ -1179,9 +868,7 @@ public sealed class Transition
localCurrCenter,
localSpaceZ,
obj.Scale, // scale for local→world offsets
- obj.Rotation, // local→world rotation
- engine,
- worldOrigin: obj.Position);
+ obj.Rotation); // local→world rotation
}
else
{
@@ -1308,14 +995,6 @@ 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;
@@ -1515,8 +1194,7 @@ 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,
- bool runPlacement = true)
+ private bool DoStepDown(float stepDownHeight, float walkableZ, PhysicsEngine engine)
{
var sp = SpherePath;
@@ -1540,228 +1218,16 @@ 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)
{
- // 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 true;
}
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
// -----------------------------------------------------------------------
@@ -1816,43 +1282,15 @@ public sealed class Transition
ci.SetSlidingNormal(ci.CollisionNormal);
// Preserve contact plane for next step.
- // 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.
+ ci.LastKnownContactPlaneValid = ci.ContactPlaneValid;
if (ci.ContactPlaneValid)
{
- ci.LastKnownContactPlaneValid = true;
- ci.LastKnownContactPlane = ci.ContactPlane;
- ci.LastKnownContactPlaneCellId = ci.ContactPlaneCellId;
+ ci.LastKnownContactPlane = ci.ContactPlane;
+ ci.LastKnownContactPlaneCellId = ci.ContactPlaneCellId;
ci.LastKnownContactPlaneIsWater = ci.ContactPlaneIsWater;
oi.State |= ObjectInfoState.Contact;
- // 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)
+ if (ci.ContactPlane.Normal.Z >= PhysicsGlobals.LandingZ)
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 630cae6f..7552d809 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs
@@ -105,23 +105,6 @@ 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 53f95d60..8070cca5 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs
@@ -142,30 +142,6 @@ 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
deleted file mode 100644
index ac565061..00000000
--- a/tests/AcDream.Core.Tests/Physics/BSPStepUpFixtures.cs
+++ /dev/null
@@ -1,369 +0,0 @@
-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
deleted file mode 100644
index 258fd58b..00000000
--- a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs
+++ /dev/null
@@ -1,571 +0,0 @@
-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 79d6ac86..d6e6e554 100644
--- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
@@ -190,105 +190,6 @@ 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 7ceda8fd..fb70cd42 100644
--- a/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs
@@ -67,20 +67,6 @@ 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()
{