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