Compare commits

..

No commits in common. "1abb699c6805ab00f7ca515cdedac2912310fca2" and "7d6fe906077b77335e74e0b02fea10cc41ec21f6" have entirely different histories.

24 changed files with 472 additions and 3197 deletions

View file

@ -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

View file

@ -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 | — |

View file

@ -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** |

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -97,25 +97,11 @@ public sealed class PlayerMovementController
/// <summary>
/// Maximum Z increase per movement step before the move is rejected.
/// Retail's <c>step_up_height</c> 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 <c>Setup.StepUpHeight</c> 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.
/// </summary>
public float StepUpHeight { get; set; } = 0.4f;
/// <summary>
/// L.2.3a (2026-04-29): how far below the foot the step-down probe
/// reaches when transitioning between surfaces. Retail's
/// <c>step_down_height</c> 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.
/// </summary>
public float StepDownHeight { get; set; } = 0.4f;
public float StepUpHeight { get; set; } = 5.0f;
/// <summary>
/// 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)
{

View file

@ -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}")
: "-";
/// <summary>
/// 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<DatReaderWriter.DBObjs.Setup>(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);

View file

@ -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);
/// <summary>
/// The relevant subset of the server-sent <c>MovementData</c> /
@ -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
{

View file

@ -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);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? 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)

View file

@ -377,33 +377,30 @@ public static class BSPQuery
///
/// <para>ACE: Polygon.cs find_crossed_edge.</para>
/// </summary>
internal static bool FindCrossedEdge(
Plane polyPlane,
ReadOnlySpan<Vector3> 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<Vector3> 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<Vector3> 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.
///
/// <para>
/// Calls <see cref="Transition.DoStepUp"/> 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).
/// </para>
///
/// <para>
/// ACE: BSPTree.step_sphere_up calls transition.StepUp(globNormal);
/// on false → SpherePath.StepUpSlide(transition).
/// Named-retail: BSPTREE::step_sphere_up.
/// </para>
/// <para>ACE: BSPTree.cs step_sphere_up.</para>
/// </summary>
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;
}
}
}

View file

@ -31,14 +31,6 @@ public enum PhysicsStateFlags : uint
ReportCollisions = 0x00000010,
Gravity = 0x00000400, // bit 10 — apply downward gravity
Hidden = 0x00001000,
/// <summary>
/// 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.
/// </summary>
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
/// <summary>Ground contact-plane normal (+0x130/134/138).</summary>
public Vector3 GroundNormal { get; set; } = Vector3.UnitZ;
/// <summary>Last wall/object sliding normal (retail transient Sliding state).</summary>
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
/// <summary>Whether the contact plane is a water surface (affects step behavior).</summary>
public bool ContactPlaneIsWater { get; set; }
/// <summary>Whether the previous walkable polygon is available for edge slide.</summary>
public bool WalkablePolygonValid { get; set; }
/// <summary>Most recent walkable polygon plane (world-space).</summary>
public System.Numerics.Plane WalkablePlane { get; set; }
/// <summary>Most recent walkable polygon vertices (world-space).</summary>
public Vector3[]? WalkableVertices { get; set; }
/// <summary>Up vector used by the most recent walkable polygon probe.</summary>
public Vector3 WalkableUp { get; set; } = Vector3.UnitZ;
/// <summary>Elasticity coefficient (+0xB0).</summary>
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.
/// </summary>
public void calc_friction(float dt, float velocityMag2)
{

View file

@ -209,14 +209,6 @@ public sealed class PhysicsDataCache
public int GfxObjCount => _gfxObj.Count;
public int SetupCount => _setup.Count;
public int CellStructCount => _cellStruct.Count;
/// <summary>
/// Register a pre-built <see cref="GfxObjPhysics"/> directly.
/// Intended for unit-test fixtures that construct synthetic BSP trees
/// without needing real DAT content.
/// </summary>
public void RegisterGfxObjForTest(uint gfxObjId, GfxObjPhysics physics)
=> _gfxObj[gfxObjId] = physics;
}
/// <summary>

View file

@ -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);
/// <summary>
/// Top-level physics resolver that combines <see cref="TerrainSurface"/> and
/// <see cref="CellSurface"/> to resolve entity movement with step-height
@ -169,83 +162,6 @@ public sealed class PhysicsEngine
return null;
}
/// <summary>
/// Sample the outdoor terrain walkable triangle at the given world-space
/// XY position. This carries the same plane as <see cref="SampleTerrainPlane"/>
/// plus world-space triangle vertices for retail precipice-slide.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Resolve an entity's movement from <paramref name="currentPos"/> by
/// applying <paramref name="delta"/> (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);
}
}

View file

@ -6,25 +6,8 @@ namespace AcDream.Core.Physics;
/// Result of <see cref="PhysicsEngine.Resolve"/>: the validated
/// position after collision, the cell the entity ended up in,
/// and whether they're standing on a surface.
///
/// <para>
/// L.3a (2026-04-30): added optional collision-normal fields so the
/// caller (typically <see cref="AcDream.App.Input.PlayerMovementController"/>)
/// can apply retail's velocity-reflection bounce
/// (<c>v_new = v - (1 + elasticity) * dot(v, n) * n</c>) to the
/// PhysicsBody after the geometric resolve completes. ACE port mirror:
/// <c>references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2692-2697</c>;
/// retail equivalent: <c>CPhysicsObj::handle_all_collisions</c> at
/// <c>acclient_2013_pseudo_c.txt:282699-282715</c>.
/// </para>
/// </summary>
public readonly record struct ResolveResult(
Vector3 Position,
uint CellId,
bool IsOnGround,
/// <summary>True when a wall collision occurred during this resolve
/// and <see cref="CollisionNormal"/> is meaningful.</summary>
bool CollisionNormalValid = false,
/// <summary>Outward surface normal of the wall the sphere hit. Used
/// by the velocity-reflection step. Pointing away from the wall.</summary>
Vector3 CollisionNormal = default);
bool IsOnGround);

View file

@ -1,13 +1,7 @@
using System;
using System.Numerics;
namespace AcDream.Core.Physics;
public readonly record struct TerrainSurfacePolygon(
float Z,
Vector3 Normal,
Vector3[] Vertices);
/// <summary>
/// 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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Retail per-point water depth in meters — the amount the character's
/// feet are allowed to sink below the contact plane before the

View file

@ -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);
/// <summary>
/// Return the Z threshold for a walkable surface appropriate to the
/// current movement context.
///
/// <para>
/// 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).
/// </para>
/// </summary>
public float GetWalkableZ()
=> OnWalkable ? PhysicsGlobals.FloorZ : PhysicsGlobals.LandingZ;
/// <summary>
/// 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.
/// </summary>
public void StopVelocity() { /* velocity lives on PhysicsBody, not here */ }
}
/// <summary>
@ -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);
}
/// <summary>
/// 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).
/// </summary>
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;
}
/// <summary>
/// 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.
///
/// <para>
/// L.2.3d (2026-04-29): the previous version only set <see cref="SlidingNormal"/>
/// 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 <see cref="CheckPos"/> via <c>Sphere.SlideSphere</c>;
/// we delegate to <see cref="Transition.SlideSphereInternal"/> which does
/// the same thing.
/// </para>
///
/// ACE: <c>SpherePath.StepUpSlide</c> + <c>Sphere.SlideSphere</c>
/// (SpherePath.cs:309-317, Sphere.cs:558-604).
/// </summary>
public TransitionState StepUpSlide(Transition transition)
{
var ci = transition.CollisionInfo;
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
return transition.SlideSphereInternal(StepUpNormal, GlobalCurrCenter[0].Origin);
}
/// <summary>
/// Slide along the edge of the walkable polygon the mover just left.
/// Retail anchor: <c>SPHEREPATH::precipice_slide</c>
/// (<c>acclient_2013_pseudo_c.txt:274316</c>).
/// </summary>
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);
}
/// <summary>
/// Initialize the path for a simple point-to-point movement.
/// </summary>
@ -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);
}
/// <summary>
@ -978,19 +667,12 @@ public sealed class Transition
/// </summary>
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<ShadowEntry> _nearbyObjs = new();
private static int _debugQueryCount = 0;
/// <summary>
/// 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<ShadowEntry>();
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.
/// </summary>
/// <summary>
/// L.2.3d: exposed as <c>internal</c> so <see cref="SpherePath.StepUpSlide"/>
/// can apply the same slide computation ACE's <c>Sphere.SlideSphere</c> uses
/// for failed step-up. Mirror of ACE Sphere.cs:558-604 (Plane variant).
/// </summary>
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).
/// </summary>
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
// -----------------------------------------------------------------------
/// <summary>
/// Attempt to step over a low obstacle by probing upward then stepping down.
///
/// <para>
/// 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.
/// </para>
///
/// ACE: Transition.StepUp (Transition.cs:746-777).
/// Named-retail: CTransition::step_up (~273099-273133).
/// </summary>
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
// -----------------------------------------------------------------------
/// <summary>
/// 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.
///
/// <para>
/// 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).
/// </para>
///
/// ACE: Transition.CheckWalkable (Transition.cs:206-235).
/// Named-retail: CTransition::check_walkable.
/// </summary>
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;

View file

@ -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()
{

View file

@ -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()
{

View file

@ -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;
/// <summary>
/// Synthetic BSP tree fixtures for step-up and roof-landing conformance tests.
///
/// <para>
/// These fixtures construct minimal <see cref="PhysicsBSPNode"/> trees plus
/// matching <see cref="ResolvedPolygon"/> dictionaries that represent canonical
/// AC collision shapes without needing real DAT content. The shapes cover every
/// interesting branch in <see cref="BSPQuery.FindCollisions"/> Path 5 and Path 6.
/// </para>
///
/// <para>
/// 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
/// <c>localSphere.Origin == worldPosition</c>.
/// </para>
///
/// <para>
/// 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.
/// </para>
/// </summary>
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.
// =========================================================================
/// <summary>
/// Constructs a BSP tree and resolved-polygon dict representing a 25 cm step.
///
/// <para>Geometry (object-local space):</para>
/// <list type="bullet">
/// <item>Floor polygon at z = 0, x ∈ [-2, 0.5], y ∈ [-1, 1].</item>
/// <item>Vertical wall polygon at x = 0.5, z ∈ [0, 0.25], y ∈ [-1, 1], facing -X.</item>
/// <item>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.30.5 (the wall contact zone).</item>
/// </list>
/// </summary>
public static (PhysicsBSPNode Root, Dictionary<ushort, ResolvedPolygon> Resolved)
LowStep()
{
var resolved = new Dictionary<ushort, ResolvedPolygon>();
// 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.
// =========================================================================
/// <summary>
/// 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.
/// </summary>
public static (PhysicsBSPNode Root, Dictionary<ushort, ResolvedPolygon> Resolved)
TallWall()
{
var resolved = new Dictionary<ushort, ResolvedPolygon>();
// 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.
// =========================================================================
/// <summary>
/// Constructs a BSP tree and resolved-polygon dict representing a 3 m flat roof.
/// </summary>
public static (PhysicsBSPNode Root, Dictionary<ushort, ResolvedPolygon> Resolved)
FlatRoof()
{
var resolved = new Dictionary<ushort, ResolvedPolygon>();
// 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.
// =========================================================================
/// <summary>
/// Constructs a BSP tree and resolved-polygon dict representing a steep (60°)
/// slope whose normal.Z is below the walkable threshold.
/// </summary>
public static (PhysicsBSPNode Root, Dictionary<ushort, ResolvedPolygon> Resolved)
SlopedUnwalkable()
{
var resolved = new Dictionary<ushort, ResolvedPolygon>();
// 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
// =========================================================================
/// <summary>
/// Build a <see cref="Transition"/> for a grounded mover (Contact + OnWalkable set).
///
/// <para>
/// The mover's foot sphere starts at <paramref name="from"/> and is headed
/// toward <paramref name="to"/>. <see cref="ObjectInfo.StepUpHeight"/> is
/// set to <paramref name="stepUpHeight"/> so the test can control which step
/// heights succeed.
/// </para>
/// </summary>
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;
}
/// <summary>
/// Build a <see cref="Transition"/> for an airborne mover (no Contact, no OnWalkable).
///
/// <para>
/// Represents a character that has just jumped or fallen and is now moving
/// downward to land on a surface.
/// </para>
/// </summary>
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,
};
}
}

View file

@ -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;
/// <summary>
/// Conformance tests for BSP step-up (Path 5) and rooftop landing (Path 6) in
/// <see cref="BSPQuery.FindCollisions"/>.
///
/// <para>
/// Tests are organised in three groups corresponding to the three commits:
/// </para>
/// <list type="bullet">
/// <item><b>Group A — Baselines</b>: behaviours that should pass both before
/// and after the implementation (no-hit returns OK, fixture geometry checks).</item>
/// <item><b>Group B — Phase L.2.1 (Path 5 step-up)</b>: tests that are RED
/// because Path 5 wall-slides instead of stepping up. L.2.1 flips these
/// GREEN.</item>
/// <item><b>Group C — Phase L.2.2 (Path 6 SetCollide)</b>: tests that are RED
/// because Path 6 wall-slides instead of setting the Collide flag. L.2.2
/// flips these GREEN.</item>
/// </list>
///
/// <para>
/// 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.
/// </para>
/// </summary>
public class BSPStepUpTests
{
// =========================================================================
// Group A — Baselines (pass before AND after the implementation)
// =========================================================================
/// <summary>
/// No BSP geometry → FindCollisions returns OK with no state changes.
/// </summary>
[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<ushort, ResolvedPolygon>(),
t, localSphere, null,
from, Vector3.UnitZ, 1.0f);
Assert.Equal(TransitionState.OK, result);
}
/// <summary>
/// Grounded mover far from the wall → no collision → OK.
/// </summary>
[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);
}
/// <summary>
/// Airborne mover well above the roof → no collision → OK.
/// </summary>
[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);
}
/// <summary>
/// The slope fixture's polygon must have normal.Z below FloorZ (confirms
/// the fixture geometry is set up correctly as a non-walkable surface).
/// </summary>
[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)");
}
/// <summary>
/// Low-step upper-floor polygon has normal.Z >= FloorZ (it IS walkable).
/// </summary>
[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})");
}
/// <summary>
/// Roof polygon has normal.Z >= LandingZ (it can be landed on).
/// </summary>
[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.
// =========================================================================
/// <summary>
/// Grounded mover (Contact + OnWalkable) walking toward the low step (25 cm):
/// should step up onto the upper floor, not slide sideways.
///
/// <para>
/// Current (wrong): Path 5 applies wall-slide → CurPos.X stays left of wall;
/// Z stays at floor level.
/// </para>
/// <para>
/// 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.
/// </para>
///
/// <para>Retail: BSPTREE::step_sphere_up / CTransition::step_up
/// acclient_2013_pseudo_c.txt:323849, 273099.</para>
/// </summary>
[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.");
}
/// <summary>
/// Grounded mover walking into the too-tall wall (5 m) should NOT step up —
/// the wall is taller than StepUpHeight.
///
/// <para>
/// 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.
/// </para>
///
/// <para>Retail: SPHEREPATH::step_up_slide
/// ACE SpherePath.cs:309-316.</para>
/// </summary>
[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}");
}
/// <summary>
/// Direct Path 5 invocation: Contact mover sphere just overlapping the low
/// wall should NOT return Slid after L.2.1.
///
/// <para>
/// Current: returns Slid (wall-slide).
/// Expected after L.2.1: returns OK (step-up succeeded) with Z lifted.
/// </para>
/// </summary>
[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.
// =========================================================================
/// <summary>
/// Airborne mover hitting the flat roof from above should set Collide flag
/// and return Adjusted (not Slid with wall-slide offset).
///
/// <para>
/// Current (wrong): Path 6 computes a wall-slide offset and returns Slid.
/// </para>
/// <para>
/// Expected after L.2.2: Path 6 calls path.SetCollide(worldNormal), sets
/// WalkableAllowance = LandingZ, returns Adjusted.
/// </para>
///
/// <para>Retail: SPHEREPATH::set_collide
/// acclient_2013_pseudo_c.txt:321594 / ACE BSPTree.cs:210-219.</para>
/// </summary>
[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);
}
/// <summary>
/// Full integration: airborne mover drops onto the 3 m flat roof.
///
/// <para>
/// After L.2.2: TransitionalInsert sees Collide flag, re-tests as Placement,
/// finds walkable polygon at z=3, sets ContactPlane with normal.Z ≈ 1.
/// </para>
/// <para>
/// Current: mover slides sideways off the roof (never lands).
/// Expected after L.2.2: ContactPlane is set with Normal.Z >= LandingZ.
/// </para>
/// </summary>
[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})");
}
}
/// <summary>
/// Airborne mover descending toward a steep slope (normal.Z &lt; FloorZ):
/// Path 6 should still set the Collide flag (it fires for any polygon hit,
/// walkable or not).
///
/// <para>Retail: set_collide fires unconditionally when sphere_intersects_poly
/// hits; the walkable check happens later in the Collide-flag handler.</para>
/// </summary>
[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.
// =========================================================================
/// <summary>
/// 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,
/// <c>DoStepUp</c> cleared <see cref="CollisionInfo.ContactPlaneValid"/>
/// 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".
/// </summary>
[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).");
}
/// <summary>
/// L.2.3b regression: Path 5 dispatch must be guarded against re-entry while
/// a step-up is already in progress. Test runs <c>FindTransitionalPosition</c>
/// 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.
/// </summary>
[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
// =========================================================================
/// <summary>
/// Build a <see cref="PhysicsEngine"/> that serves one synthetic BSP object.
/// <paramref name="terrainZ"/> 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).
/// </summary>
private static PhysicsEngine MakeTestEngine(
PhysicsBSPNode root,
Dictionary<ushort, ResolvedPolygon> 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<CellSurface>(),
Array.Empty<PortalPlane>(),
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<ushort, DatReaderWriter.Types.Polygon>(),
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;
}
}

View file

@ -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<CellSurface>(),
Array.Empty<PortalPlane>(), worldOffsetX: 0f, worldOffsetY: 0f);
var terrainB = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
engine.AddLandblock(0xAAB4FFFFu, terrainB, Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(), 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()
{

View file

@ -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()
{