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