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