From 16d182c2f025f3ff3d03c8d876927c8111f72646 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Jul 2026 14:37:02 +0200 Subject: [PATCH] perf(physics): reuse reset-complete transition scratch Mirror retail's ten-deep LIFO transition lifetime, retain all query scratch with complete reset contracts, and remove Tier-0 enum boxing without changing collision decisions. Fresh and retained engines are bit-identical across the expanded oracle, while measured transition profiles now allocate 0 bytes per resolve. --- AGENTS.md | 10 +- CLAUDE.md | 10 +- docs/ISSUES.md | 23 +- docs/plans/2026-04-11-roadmap.md | 12 +- docs/plans/2026-05-12-milestones.md | 10 +- .../2026-07-24-modern-runtime-architecture.md | 18 +- .../2026-07-25-modern-runtime-slice-i.md | 11 +- .../2026-07-25-slice-i1-transition-scratch.md | 108 ++ src/AcDream.Core/Physics/BSPQuery.cs | 712 +++++++------ src/AcDream.Core/Physics/CellTransit.cs | 18 +- src/AcDream.Core/Physics/PhysicsBody.cs | 116 ++- src/AcDream.Core/Physics/PhysicsEngine.cs | 905 ++++++++-------- src/AcDream.Core/Physics/TerrainSurface.cs | 127 ++- .../Physics/TransitionScratchArena.cs | 68 ++ src/AcDream.Core/Physics/TransitionTypes.cs | 981 +++++++++++------- .../Physics/TerrainSurfaceTests.cs | 8 +- .../TransitionAllocationBaselineTests.cs | 65 +- .../TransitionScratchDifferentialTests.cs | 646 ++++++++++++ .../Physics/TransitionScratchResetTests.cs | 417 ++++++++ 19 files changed, 3037 insertions(+), 1228 deletions(-) create mode 100644 docs/research/2026-07-25-slice-i1-transition-scratch.md create mode 100644 src/AcDream.Core/Physics/TransitionScratchArena.cs create mode 100644 tests/AcDream.Core.Tests/Physics/TransitionScratchDifferentialTests.cs create mode 100644 tests/AcDream.Core.Tests/Physics/TransitionScratchResetTests.cs diff --git a/AGENTS.md b/AGENTS.md index af2a9267..44b065cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,15 +156,19 @@ complete. G4's 46,599-comparison automated gate has zero mismatches, the user accepted the physical-display visual matrix, and G5 removed the production `InteriorEntityPartition`. The ordinary production profile sustains 519.7 FPS with CPU/GPU p50 1.869/1.096 ms and 652.1/928.3 MiB working/private memory. -The measured 22.34 KiB/frame remainder is assigned to Slice I1. +The G5 profile's 22.34 KiB/frame remainder led into Slice I1. That slice is +now complete: player, remote, projectile, camera, and grounded +walkable-publication profiles each measure 0 B/resolve, with bit-identical +fresh-vs-reused results and complete reset/reentrancy gates. Slice H's retained UI/frame, bounded-light, and pooled/borrowed/direct network work is complete; the exact seven-checkpoint connected lifecycle/reconnect gate -passes. Slice I is active at I1 from +passes. Slice I is active at I2 from `docs/plans/2026-07-25-modern-runtime-slice-i.md`. The exact G4 visual rollback is `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`; do not revert G3 or the portal-warmup corrections. Evidence: `docs/research/2026-07-25-slice-h-closeout.md` and -`docs/research/2026-07-25-slice-g5-production-profile.md`. +`docs/research/2026-07-25-slice-g5-production-profile.md` and +`docs/research/2026-07-25-slice-i1-transition-scratch.md`. **Structural prerequisite before new M4 subsystem work:** all eight behavior-preserving `GameWindow` decomposition slices and the automated diff --git a/CLAUDE.md b/CLAUDE.md index cc7e8030..aafe37b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,15 +154,19 @@ complete. G4's 46,599-comparison automated gate has zero mismatches, the user accepted the physical-display visual matrix, and G5 removed the production `InteriorEntityPartition`. The ordinary production profile sustains 519.7 FPS with CPU/GPU p50 1.869/1.096 ms and 652.1/928.3 MiB working/private memory. -The measured 22.34 KiB/frame remainder is assigned to Slice I1. +The G5 profile's 22.34 KiB/frame remainder led into Slice I1. That slice is +now complete: player, remote, projectile, camera, and grounded +walkable-publication profiles each measure 0 B/resolve, with bit-identical +fresh-vs-reused results and complete reset/reentrancy gates. Slice H's retained UI/frame, bounded-light, and pooled/borrowed/direct network work is complete; the exact seven-checkpoint connected lifecycle/reconnect gate -passes. Slice I is active at I1 from +passes. Slice I is active at I2 from `docs/plans/2026-07-25-modern-runtime-slice-i.md`. The exact G4 visual rollback is `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`; do not revert G3 or the portal-warmup corrections. Evidence: `docs/research/2026-07-25-slice-h-closeout.md` and -`docs/research/2026-07-25-slice-g5-production-profile.md`. +`docs/research/2026-07-25-slice-g5-production-profile.md` and +`docs/research/2026-07-25-slice-i1-transition-scratch.md`. **Structural prerequisite before new M4 subsystem work:** all eight behavior-preserving `GameWindow` decomposition slices and the automated diff --git a/docs/ISSUES.md b/docs/ISSUES.md index a8777f46..3a994e17 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -327,7 +327,7 @@ covered by a zero-managed-allocation test. Evidence: ## #237 — PhysicsEngine allocates a fresh Transition graph per resolve call -**Status:** OPEN +**Status:** DONE — 2026-07-25, Modern Runtime Slice I1 **Severity:** MEDIUM **Filed:** 2026-07-24 **Component:** physics @@ -339,14 +339,21 @@ NPC/remote, projectiles, and the camera probe — per-tick GC pressure that scales with active-mover count (combat/crowds), a scenario the audit's dwell-heavy route under-measured. -**Root cause / status:** 2026-07-24 audit review finding. Pooling is only -allowed under plan Slice I's identity/lifetime-test condition (the adjacent -`LiveEntityAnimationScheduler` scratch-reuse pattern is the precedent) — -Transition state must be provably reset-complete between movers or a pooled -graph corrupts collision state, which is worse than the allocation. +**Resolution:** Each `PhysicsEngine` now owns retail-shaped ten-deep, +same-thread, LIFO transition scratch. Every stored member is reset and +reflection-poisoned in tests; exact-length walkable, candidate-cell, collision +GUID, and reentrant neighbor-order storage retains identity without exposing +stale state. Fresh-vs-reused engines are bit-identical across hostile +player/remote/projectile/camera/placement/failure/step/slide cases. Release +allocation fell from 2,512–6,848 B/resolve to 0 B/resolve for all measured +mover profiles. Evidence: +`docs/research/2026-07-25-slice-i1-transition-scratch.md`. -**Files:** `src/AcDream.Core/Physics/PhysicsEngine.cs:995`; -`src/AcDream.Core/Physics/TransitionTypes.cs:360-367,694-698`. +**Files:** `src/AcDream.Core/Physics/TransitionScratchArena.cs`; +`src/AcDream.Core/Physics/PhysicsEngine.cs`; +`src/AcDream.Core/Physics/TransitionTypes.cs`; +`tests/AcDream.Core.Tests/Physics/TransitionScratchResetTests.cs`; +`tests/AcDream.Core.Tests/Physics/TransitionScratchDifferentialTests.cs`. --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index d20effe5..c05eff4f 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1647,14 +1647,16 @@ port in any phase — no separate listing here. > come from the retained frame product. The exact lifecycle/reconnect matrix, > capped/uncapped/dense correctness routes, and ordinary production profile > pass. The final stable ordinary-production minute sustains 519.7 FPS with -> CPU/GPU p50 1.869/1.096 ms; the measured 22.34 KiB/frame remainder is the -> active Slice-I transition/query-scratch owner. +> CPU/GPU p50 1.869/1.096 ms. Slice I1 now removes the isolated +> transition/query owner: player, remote, projectile, camera, and grounded +> walkable-publication profiles each measure 0 B/resolve, and retained +> transitions remain bit-identical to the fresh-graph oracle. > The historical exact cutover > rollback remains > `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`. Slice H is complete: > retained UI/frame work, exact bounded light selection, and > pooled/borrowed/direct network I/O pass 8,292 Release tests / 5 skips and the -> connected lifecycle/reconnect gate. Slice I is active at I1 from +> connected lifecycle/reconnect gate. Slice I is active at I2 from > [its detailed plan](2026-07-25-modern-runtime-slice-i.md). **Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the @@ -1679,11 +1681,11 @@ hitch and follows. | MP1a | `AcDream.Content` extraction (GL-free MeshExtractor + boundary records out of App) | ✅ SHIPPED 2026-07-05 — user-gated (renders identical, zero tripwires, perf-neutral); 8 commits `651d041e`..`b0758d77` | | MP1b | Pak format + `acdream-bake` CLI + mmap `PakReader` + equivalence/full-scale gate | ✅ **SHIPPED 2026-07-24** — 729,888 EnvCell keys → 17,117 unique geometries + 712,771 aliases (42.6×), 751,141 total keys / 38,370 physical blobs, zero failures, 28,192.4 MiB, 81.4 s validated atomic publish; full-DAT gate also corrected a real collision in WB's legacy geometry hash. [Report](../research/2026-07-24-slice-b-full-bake-report.md) | | **MP-Alloc (safe batch)** | Reuse per-frame buffers: anim pose, particle draw-list, interior partition, animatedIds/drawableCells sets | ✅ SHIPPED + USER-GATED 2026-07-05 — dense-town frame-time spikes 20–87ms → 6–10ms, alloc spikes (30–75MB single-frame) eliminated, gen2 GC 5–11/window → ~0; user confirms FPS steady. 4 commits `b8c05e2b`..`91afea24` | -| MP-Alloc (hard sites) | EnvCell settled-camera rebuild gate + physics `Transition` pooling — the residual steady ~1.6MB/frame | ⚪ OPTIONAL follow-up — would lower the median too, but the wild-swing complaint is already resolved; each needs its own careful gate (batch correctness / physics faithfulness) | +| MP-Alloc (hard sites) | EnvCell settled-camera rebuild gate + physics `Transition` reuse | 🟡 Physics half SHIPPED 2026-07-25 — retail-shaped reset-complete transition/query scratch is bit-identical and 0 B/resolve across five profiles; flat collision storage continues in Slice I2. | | MP1c | Streaming cutover to pak + hitch gate | ✅ **SHIPPED 2026-07-24** — typed package-only production source, explicit Setup probes, non-allocating Portal-first lookup, exact translucency metadata, and ordered teardown; capped/uncapped/dense physical routes + user visual gate pass. Uncapped CPU p99 7.825→6.496 ms, GPU p99 3.406→2.706 ms, portal frame max 202.9→39.9 MiB, process allocation −41.8%, zero invalid Setup probes. [Report](../research/2026-07-24-slice-c-prepared-asset-cutover-report.md) | | MP2 | Retail particle distance/cell-view degradation: DAT-authored range, exact infinite/finite off-view update branches, emitter-first render scan, user-requested 2× default range | ✅ SHIPPED 2026-07-17 — Aerlinthe hotspot `0x32000223` resolves to retail 64m and defaults to 128m; landscape/entity streaming distance unchanged | | MP3 | Arch ECS render world + delta submission (the 300-FPS lever) | ⚪ — note: does NOT fix the steady-state GC churn (that's MP-Alloc); MP3 is the draw-submission throughput lever | -| MP4 | Zero-alloc frame loop + flat physics data (residual, post-MP-Alloc) | ⚪ hard-queued behind M1.5 #137 | +| MP4 | Zero-alloc frame loop + flat physics data (residual, post-MP-Alloc) | 🟡 ACTIVE — Slice I1 zero-allocation transition scratch shipped; I2 immutable flat collision assets in progress. | | MP5 | Job-system parallelism | ⚪ stretch, evidence-gated | --- diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index f2d80fca..2231258b 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -116,10 +116,12 @@ the retained frame product without building the old correctness routes, exact seven-checkpoint lifecycle/reconnect gate, and ordinary production profile pass. The final stable production minute sustains 519.7 FPS with CPU/GPU p50 1.869/1.096 ms and 22.34 KiB/frame allocation. -The complete Release gate is 3,826 App tests / 3 skips and 8,335 solution -tests / 5 skips. Slice H is complete: retained UI/frame work, exact bounded -light selection, and pooled/borrowed/direct network I/O pass. Slice I is active -at I1 under +Slice I1 subsequently removes the isolated per-resolve owner: player, remote, +projectile, camera, and grounded walkable-publication profiles are all +0 B/resolve with bit-identical fresh-vs-reused outcomes. The complete I1 +Release gate is 3,826 App tests / 3 skips and 8,342 solution tests / 5 skips. +Slice H is complete: retained UI/frame work, exact bounded light selection, +and pooled/borrowed/direct network I/O pass. Slice I is active at I2 under [`2026-07-25-modern-runtime-slice-i.md`](2026-07-25-modern-runtime-slice-i.md). The historical exact G4 visual rollback remains `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`. diff --git a/docs/plans/2026-07-24-modern-runtime-architecture.md b/docs/plans/2026-07-24-modern-runtime-architecture.md index 4336329a..8c18fc6b 100644 --- a/docs/plans/2026-07-24-modern-runtime-architecture.md +++ b/docs/plans/2026-07-24-modern-runtime-architecture.md @@ -2,8 +2,8 @@ **Date:** 2026-07-24 -**Status:** Slices A–H and I0 are complete. Slices F–L were explicitly -approved 2026-07-24; Slice I1 is active. +**Status:** Slices A–H and I0–I1 are complete. Slices F–L were explicitly +approved 2026-07-24; Slice I2 is active. **Scope:** Reconcile and sequence the existing Modern Pipeline (`MP`) and Linux/headless (`LH`) tracks using the 2026-07-24 connected performance audit. @@ -1193,12 +1193,16 @@ seven-checkpoint lifecycle/reconnect gate pass with graceful shutdown. The final ordinary-production minute on exact `14fbe92b` sustained 519.7 FPS: CPU p50/p95/p99 1.869/2.306/2.484 ms and GPU 1.096/1.108/1.136 ms. Working/private memory was 652.1/928.3 MiB. -Frame-thread allocation is now 22.34 KiB median; allocation-tick evidence and -the four-mover baseline assign that remainder primarily to reset-complete -`Transition` collision/query scratch. I0 has pinned the retail oracle and -fixtures, so I1 is the current execution point. Evidence: +Frame-thread allocation at the G5 checkpoint was 22.34 KiB median. I1 has now +replaced fresh per-resolve transition graphs and query temporaries with +reset-complete retained scratch: player, remote, projectile, camera, and +grounded walkable-publication profiles each measure 0 B/resolve, while +fresh-vs-reused output and body state remain bit-identical. I2 immutable flat +collision assets are the current execution point. Evidence: [`../research/2026-07-25-slice-g5-production-profile.md`](../research/2026-07-25-slice-g5-production-profile.md) and +[`../research/2026-07-25-slice-i1-transition-scratch.md`](../research/2026-07-25-slice-i1-transition-scratch.md) +and [`2026-07-25-modern-runtime-slice-i.md`](2026-07-25-modern-runtime-slice-i.md). The intended order is therefore: @@ -1212,7 +1216,7 @@ honest metrics + committed baselines (A — exit criteria block C) -> incremental render scene (F) -> delta GPU submission (G) -> residual frame cleanup (H-a, H-b, H-c) - -> flat collision assets (I — CURRENT at I1) + -> flat collision assets (I — CURRENT at I2) -> presentation-independent runtime (J — §0.3 authorization) -> Linux/headless/multi-session (K) -> evidence-gated GPU jobs (L) diff --git a/docs/plans/2026-07-25-modern-runtime-slice-i.md b/docs/plans/2026-07-25-modern-runtime-slice-i.md index 94326489..3d11ed54 100644 --- a/docs/plans/2026-07-25-modern-runtime-slice-i.md +++ b/docs/plans/2026-07-25-modern-runtime-slice-i.md @@ -1,8 +1,7 @@ # Modern runtime Slice I — flat collision assets and zero-allocation physics -**Status:** I0 COMPLETE — retail oracle, representative graph fixtures, and -four-mover allocation baselines fixed; G5 production closeout complete; I1 -active +**Status:** I0–I1 COMPLETE — retail oracle, graph fixtures, and +reset-complete zero-allocation transition/query scratch fixed; I2 active **Parent:** `2026-07-24-modern-runtime-architecture.md`, Slice I **Purpose:** remove parsed DAT object graphs and steady collision allocations without changing one retail collision decision, float, comparison, or traversal @@ -123,7 +122,11 @@ graph path, and no behavior code changed. Gate: bit-identical `ResolveResult` and body side effects, no state leakage when alternating hostile fixtures between different mover IDs, and zero -steady transition/query-scratch allocation. Close issue #237 only here. +steady transition/query-scratch allocation. **PASSED 2026-07-25:** the expanded +118-test graph oracle, structural poison/reset suite, hostile fresh-vs-reused +differential, Core/solution Release gates, and all five measured profiles pass. +Issue #237 is closed. Evidence: +[`../research/2026-07-25-slice-i1-transition-scratch.md`](../research/2026-07-25-slice-i1-transition-scratch.md). ### I2 — immutable flat collision schema and flattener diff --git a/docs/research/2026-07-25-slice-i1-transition-scratch.md b/docs/research/2026-07-25-slice-i1-transition-scratch.md new file mode 100644 index 00000000..2c235b30 --- /dev/null +++ b/docs/research/2026-07-25-slice-i1-transition-scratch.md @@ -0,0 +1,108 @@ +# Slice I1 — reset-complete transition and query scratch + +**Status:** COMPLETE +**Parent:** `docs/plans/2026-07-25-modern-runtime-slice-i.md` +**Scope:** storage lifetime and allocation only; retail collision math, +comparisons, branch order, traversal order, and returned behavior are +unchanged. + +## Retail lifetime carried forward + +Named retail establishes a retained, stack-disciplined transition lifetime: + +- `CTransition::makeTransition` `0x0050B150` +- `CTransition::cleanupTransition` `0x00509DC0` +- `CTransition::init` `0x00509DD0` +- `COLLISIONINFO::init` `0x00509D60` +- `SPHEREPATH::init` `0x0050C330` + +Retail owns ten pre-existing `CTransition` records, selects one by nesting +depth, resets it before use, and releases it in LIFO order. The acdream port +now gives each `PhysicsEngine` the same ten-deep, same-thread lease contract. +`ResolveWithTransition` and `ResolvePlacement` always return their lease in a +`finally` block. + +## Storage changes + +- `ObjectInfo`, `CollisionInfo`, `SpherePath`, and `Transition` have complete + `ResetForReuse` contracts. +- Fixed sphere objects, collision GUID storage, exact-length walkable buffers, + candidate-cell storage, and reentrant ordered-cell lists retain identity. + Every logical value and retained buffer content is reset before reuse. +- Neighboring-cell scans use a retained LIFO list arena. This is required + because a cell collision may recursively enter step-up/step-down and another + `CheckOtherCells` before the outer scan resumes. +- `BSPQuery.CollisionSphere` is value/ref scratch instead of a short-lived + reference object. +- Terrain's always-three-vertex surface sample is stored inline. Production + BSP entry points take center/radius values directly instead of constructing + temporary DAT `Sphere` objects. +- `CellTransit` reuses the transition-owned ordered/deduplicated candidate + collection. +- Walkable publication to `PhysicsBody` reuses only an exact-length backing + array; a real polygon-size change replaces it. +- Hot enum predicates use equivalent bit masks. This removes Tier-0 + `Enum.HasFlag` boxing while preserving the exact branch condition. + +The wider collision oracle exposed the reentrant-cell-list hazard before +landing: three Facility Hub corridor seam replays initially threw +`Collection was modified`. The final LIFO query arena keeps every nested +scan independent; all three exact seam replays pass. + +## Differential and structural evidence + +`TransitionScratchResetTests`: + +- reflection-poisons every stored member; +- resets and compares the complete logical graph with a fresh transition; +- proves sphere, child, list, candidate, and walkable-buffer identities are + retained while their values are cleared; +- proves exact-length walkable reuse and replacement on a true size change; +- proves ten distinct nested leases, capacity rejection, LIFO enforcement, + same-thread enforcement, and clean reuse. + +`TransitionScratchDifferentialTests` runs a fresh-transition engine beside the +production retained engine and compares every `ResolveResult` float by bits +plus every public `PhysicsBody` side effect. The hostile alternating sequence +covers: + +- successful and failed transitions; +- grounded and airborne movement; +- wall collision and carried sliding state; +- step-up and step-down inputs; +- two-sphere characters; +- one-sphere projectiles; +- viewer/camera sweeps; +- placement overlap search; +- different mover and designated-target identities. + +The fixed graph oracle, including corridor seams, cellar, doors, projectiles, +camera, stairs, cylinder/sphere families, and the new I1 gates, is +**118 passed / 0 skipped / 0 failed**. + +## Allocation result + +Release, one thread, 256 warmups and 4,096 measured resolves per profile: + +| Profile | I0 graph baseline | I1 retained scratch | +|---|---:|---:| +| player, two spheres | 4,448 B/resolve | **0 B/resolve** | +| remote, two spheres | 4,448 B/resolve | **0 B/resolve** | +| projectile, one 5 cm sphere | 6,848 B/resolve | **0 B/resolve** | +| camera/viewer, one 30 cm sphere | 2,512 B/resolve | **0 B/resolve** | +| grounded walkable publication | not isolated | **0 B/resolve** | + +This is an isolated transition/query-scratch gate. Optional diagnostics and +capture remain intentionally allocating, and the ordinary connected +frame-allocation profile is repeated at Slice I7 after flat-asset cutover. + +## Release gates + +- focused collision oracle: **118 passed** +- complete Core tests: **3,250 passed / 2 skipped** +- `dotnet build AcDream.slnx -c Release --no-restore`: **passed** +- complete solution tests: **8,342 passed / 5 skipped** + +Issue #237 is closed. Slice I2 begins with immutable Core-only flat collision +records and deterministic source-graph flattening; the current graph traversal +remains the executable behavior oracle. diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 2f609ee5..b8287038 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -32,8 +32,8 @@ public static class BSPQuery // Internal mutable sphere helper // // ACE's Sphere class is mutable (Center + Radius). We mirror that with a - // small internal class so adjust_sphere_to_plane can mutate validPos in place, - // exactly as ACE does when passing Sphere by reference through find_walkable. + // Small internal value so query scratch lives on the stack. Mutation sites + // pass it explicitly by ref, exactly matching retail's Sphere* boundaries. // ========================================================================= /// @@ -42,10 +42,10 @@ public static class BSPQuery /// Mirrors ACE's Sphere(Center, Radius) which is passed by reference through /// the BSP recursive calls. /// - internal sealed class CollisionSphere + internal struct CollisionSphere { public Vector3 Center; - public float Radius; + public float Radius; public CollisionSphere(Vector3 center, float radius) { @@ -53,11 +53,6 @@ public static class BSPQuery Radius = radius; } - public CollisionSphere(CollisionSphere other) - { - Center = other.Center; - Radius = other.Radius; - } } // ========================================================================= @@ -74,7 +69,7 @@ public static class BSPQuery private static bool NodeIntersects(PhysicsBSPNode node, CollisionSphere sphere) { var bs = node.BoundingSphere; - var d = sphere.Center - bs.Origin; + var d = sphere.Center - bs.Origin; float r = sphere.Radius + bs.Radius; return d.LengthSquared() < r * r; } @@ -102,32 +97,32 @@ public static class BSPQuery /// ACE: Polygon.cs polygon_hits_sphere_precise. /// private static bool PolygonHitsSpherePrecise( - in Plane polyPlane, + in Plane polyPlane, ReadOnlySpan verts, - Vector3 sphereCenter, - float sphereRadius, - ref Vector3 contactPoint) + Vector3 sphereCenter, + float sphereRadius, + ref Vector3 contactPoint) { int n = verts.Length; if (n == 0) return true; float dist = Vector3.Dot(polyPlane.Normal, sphereCenter) + polyPlane.D; - float rad = sphereRadius - PhysicsGlobals.EPSILON; + float rad = sphereRadius - PhysicsGlobals.EPSILON; if (MathF.Abs(dist) > rad) return false; - float diff = rad * rad - dist * dist; + float diff = rad * rad - dist * dist; contactPoint = sphereCenter - polyPlane.Normal * dist; int prevIdx = n - 1; for (int i = 0; i < n; i++) { - var v = verts[i]; - var lv = verts[prevIdx]; + var v = verts[i]; + var lv = verts[prevIdx]; prevIdx = i; - var edge = v - lv; - var disp = contactPoint - lv; + var edge = v - lv; + var disp = contactPoint - lv; var cross = Vector3.Cross(polyPlane.Normal, edge); if (Vector3.Dot(disp, cross) >= 0f) continue; @@ -136,12 +131,12 @@ public static class BSPQuery prevIdx = n - 1; for (int j = 0; j < n; j++) { - v = verts[j]; - lv = verts[prevIdx]; + v = verts[j]; + lv = verts[prevIdx]; prevIdx = j; - edge = v - lv; - disp = contactPoint - lv; + edge = v - lv; + disp = contactPoint - lv; cross = Vector3.Cross(polyPlane.Normal, edge); float dispDot = Vector3.Dot(disp, cross); @@ -195,10 +190,10 @@ public static class BSPQuery /// ACE: Polygon.cs pos_hits_sphere. /// private static bool PosHitsSphere( - ResolvedPolygon poly, - CollisionSphere sphere, - Vector3 movement, - ref Vector3 contactPoint, + ResolvedPolygon poly, + CollisionSphere sphere, + Vector3 movement, + ref Vector3 contactPoint, ref ResolvedPolygon? hitPoly) { bool hit = PolygonHitsSpherePrecise( @@ -253,9 +248,9 @@ public static class BSPQuery /// private static bool WalkableHitsSphere( ResolvedPolygon poly, - SpherePath path, + SpherePath path, CollisionSphere sphere, - Vector3 up) + Vector3 up) { float dp = Vector3.Dot(up, poly.Plane.Normal); if (dp <= path.WalkableAllowance) return false; @@ -287,29 +282,29 @@ public static class BSPQuery private static bool CheckWalkable( ResolvedPolygon poly, CollisionSphere sphere, - Vector3 up, - bool small) + Vector3 up, + bool small) { float angleUp = Vector3.Dot(poly.Plane.Normal, up); if (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(poly.Plane.Normal, sphere.Center) + poly.Plane.D) / angleUp; + var center = sphere.Center - up * angle; float radsum = sphere.Radius * sphere.Radius; if (small) radsum *= 0.25f; - int n = poly.Vertices.Length; + int n = poly.Vertices.Length; int prevIdx = n - 1; for (int i = 0; i < n; i++) { - var v = poly.Vertices[i]; - var lv = poly.Vertices[prevIdx]; + var v = poly.Vertices[i]; + var lv = poly.Vertices[prevIdx]; prevIdx = i; - var edge = v - lv; - var disp = center - lv; + var edge = v - lv; + var disp = center - lv; var cross = Vector3.Cross(poly.Plane.Normal, edge); float diff = Vector3.Dot(disp, cross); @@ -350,9 +345,9 @@ public static class BSPQuery /// private static bool AdjustSphereToPlane( ResolvedPolygon poly, - SpherePath path, - CollisionSphere validPos, - Vector3 movement) + SpherePath path, + ref CollisionSphere validPos, + Vector3 movement) { // A6.P1: snapshot inputs for the [push-back] probe (cheap copy of // Vector3 + 2 floats). Only the LogPushBackAdjust call below pays @@ -360,7 +355,7 @@ public static class BSPQuery var inputCenter = validPos.Center; float walkInterpBefore = path.WalkInterp; - float dpPos = Vector3.Dot(validPos.Center, poly.Plane.Normal) + poly.Plane.D; + float dpPos = Vector3.Dot(validPos.Center, poly.Plane.Normal) + poly.Plane.D; float dpMove = Vector3.Dot(movement, poly.Plane.Normal); float dist; @@ -385,7 +380,7 @@ public static class BSPQuery dist = -validPos.Radius - dpPos; } - float iDist = dist / dpMove; + float iDist = dist / dpMove; float interp = (1f - iDist) * path.WalkInterp; if (interp >= path.WalkInterp || interp < -0.5f) @@ -402,7 +397,7 @@ public static class BSPQuery } validPos.Center -= movement * iDist; - path.WalkInterp = interp; + path.WalkInterp = interp; if (PhysicsDiagnostics.ProbePushBackEnabled) { @@ -441,31 +436,31 @@ public static class BSPQuery /// ACE: Polygon.cs find_crossed_edge. /// internal static bool FindCrossedEdge( - Plane polyPlane, + Plane polyPlane, ReadOnlySpan verts, - Vector3 sphereCenter, - Vector3 up, - out Vector3 normal) + Vector3 sphereCenter, + Vector3 up, + out Vector3 normal) { normal = Vector3.Zero; float angleUp = Vector3.Dot(polyPlane.Normal, up); if (MathF.Abs(angleUp) < PhysicsGlobals.EPSILON) return false; - float angle = (Vector3.Dot(polyPlane.Normal, sphereCenter) + polyPlane.D) / angleUp; - var center = sphereCenter - up * angle; + float angle = (Vector3.Dot(polyPlane.Normal, sphereCenter) + polyPlane.D) / angleUp; + var center = sphereCenter - up * angle; - int n = verts.Length; + int n = verts.Length; int prevIdx = n - 1; for (int i = 0; i < n; i++) { - var v = verts[i]; - var lv = verts[prevIdx]; + var v = verts[i]; + var lv = verts[prevIdx]; prevIdx = i; - var edge = v - lv; - var disp = center - lv; + var edge = v - lv; + var disp = center - lv; var cross = Vector3.Cross(polyPlane.Normal, edge); if (Vector3.Dot(disp, cross) < 0f) @@ -481,8 +476,8 @@ public static class BSPQuery private static bool FindCrossedEdge( ResolvedPolygon poly, CollisionSphere sphere, - Vector3 up, - ref Vector3 normal) + Vector3 up, + ref Vector3 normal) { if (!FindCrossedEdge(poly.Plane, poly.Vertices, sphere.Center, up, out var crossedNormal)) return false; @@ -499,22 +494,17 @@ public static class BSPQuery : Vector3.UnitZ; } - private static Vector3[] TransformVertices( - ReadOnlySpan vertices, - Quaternion localToWorld, - float scale, - Vector3 worldOrigin) + private static Plane BuildWorldPlane( + Vector3 worldNormal, + ReadOnlySpan localVertices, + Quaternion localToWorld, + float scale, + Vector3 worldOrigin) { - var result = new Vector3[vertices.Length]; - for (int i = 0; i < vertices.Length; i++) - result[i] = Vector3.Transform(vertices[i] * scale, localToWorld) + worldOrigin; - return result; - } - - private static Plane BuildWorldPlane(Vector3 worldNormal, ReadOnlySpan worldVertices) - { - float d = worldVertices.Length > 0 - ? -Vector3.Dot(worldNormal, worldVertices[0]) + float d = localVertices.Length > 0 + ? -Vector3.Dot( + worldNormal, + Vector3.Transform(localVertices[0] * scale, localToWorld) + worldOrigin) : 0f; return new Plane(worldNormal, d); } @@ -536,12 +526,13 @@ public static class BSPQuery /// ACE: Polygon.cs adjust_to_placement_poly. /// private static void AdjustToPlacementPoly( - ResolvedPolygon poly, - CollisionSphere validPos, - CollisionSphere? validPos2, - float radius, - bool centerSolid, - bool clearCell) + ResolvedPolygon poly, + ref CollisionSphere validPos, + ref CollisionSphere validPos2, + bool hasValidPos2, + float radius, + bool centerSolid, + bool clearCell) { Vector3 moveDir = Vector3.Zero; @@ -556,14 +547,14 @@ public static class BSPQuery moveDir = poly.Plane.Normal; } - float dist = Vector3.Dot(validPos.Center, poly.Plane.Normal) + poly.Plane.D; + float dist = Vector3.Dot(validPos.Center, poly.Plane.Normal) + poly.Plane.D; float pushAmt = radius - dist; if (pushAmt <= 0f) pushAmt = PhysicsGlobals.EPSILON; var offset = moveDir * pushAmt; validPos.Center += offset; - if (validPos2 is not null) + if (hasValidPos2) validPos2.Center += offset; } @@ -596,8 +587,8 @@ public static class BSPQuery private static float AdjustSphereToPoly( ResolvedPolygon poly, CollisionSphere checkPos, - Vector3 curPos, - Vector3 movement) + Vector3 curPos, + Vector3 movement) { float dpPos = Vector3.Dot(curPos, poly.Plane.Normal) + poly.Plane.D; if (MathF.Abs(dpPos) < checkPos.Radius) return 1f; @@ -637,12 +628,12 @@ public static class BSPQuery /// /// private static bool SphereIntersectsPolyInternal( - PhysicsBSPNode? node, + PhysicsBSPNode? node, Dictionary resolved, - CollisionSphere sphere, - Vector3 movement, - ref ResolvedPolygon? hitPoly, - ref Vector3 contactPoint) + CollisionSphere sphere, + Vector3 movement, + ref ResolvedPolygon? hitPoly, + ref Vector3 contactPoint) { if (node is null) return false; if (!NodeIntersects(node, sphere)) return false; @@ -663,7 +654,7 @@ public static class BSPQuery } // Internal: classify against splitting plane. - float dist = Vector3.Dot(node.SplittingPlane.Normal, sphere.Center) + float dist = Vector3.Dot(node.SplittingPlane.Normal, sphere.Center) + node.SplittingPlane.D; float reach = sphere.Radius - PhysicsGlobals.EPSILON; @@ -704,8 +695,8 @@ public static class BSPQuery /// /// /// - /// Note: validPos is a reference type so mutations propagate back to the caller - /// automatically, mirroring ACE's by-ref Sphere passing. + /// Note: validPos is passed by reference so mutations propagate back to the + /// caller, mirroring retail's by-ref Sphere passing. /// /// /// @@ -713,15 +704,15 @@ public static class BSPQuery /// /// private static void FindWalkableInternal( - PhysicsBSPNode? node, + PhysicsBSPNode? node, Dictionary resolved, - SpherePath path, - CollisionSphere validPos, - Vector3 movement, - Vector3 up, - ref ResolvedPolygon? hitPoly, - ref ushort hitPolyId, - ref bool changed) + SpherePath path, + ref CollisionSphere validPos, + Vector3 movement, + Vector3 up, + ref ResolvedPolygon? hitPoly, + ref ushort hitPolyId, + ref bool changed) { if (node is null) return; if (!NodeIntersects(node, validPos)) return; @@ -736,7 +727,8 @@ public static class BSPQuery if (!resolved.TryGetValue(polyId, out var poly)) continue; bool walkable = WalkableHitsSphere(poly, path, validPos, up); - bool adjusted = walkable && AdjustSphereToPlane(poly, path, validPos, movement); + bool adjusted = walkable + && AdjustSphereToPlane(poly, path, ref validPos, movement); if (walkable && adjusted) { @@ -749,28 +741,28 @@ public static class BSPQuery } // Internal: classify against splitting plane. - float dist = Vector3.Dot(node.SplittingPlane.Normal, validPos.Center) + float dist = Vector3.Dot(node.SplittingPlane.Normal, validPos.Center) + node.SplittingPlane.D; float reach = validPos.Radius - PhysicsGlobals.EPSILON; if (dist >= reach) { - FindWalkableInternal(node.PosNode, resolved, path, validPos, movement, up, + FindWalkableInternal(node.PosNode, resolved, path, ref validPos, movement, up, ref hitPoly, ref hitPolyId, ref changed); return; } if (dist <= -reach) { - FindWalkableInternal(node.NegNode, resolved, path, validPos, movement, up, + FindWalkableInternal(node.NegNode, resolved, path, ref validPos, movement, up, ref hitPoly, ref hitPolyId, ref changed); return; } // Straddles. - FindWalkableInternal(node.PosNode, resolved, path, validPos, movement, up, + FindWalkableInternal(node.PosNode, resolved, path, ref validPos, movement, up, ref hitPoly, ref hitPolyId, ref changed); - FindWalkableInternal(node.NegNode, resolved, path, validPos, movement, up, + FindWalkableInternal(node.NegNode, resolved, path, ref validPos, movement, up, ref hitPoly, ref hitPolyId, ref changed); } @@ -792,11 +784,11 @@ public static class BSPQuery /// /// private static bool HitsWalkableInternal( - PhysicsBSPNode? node, + PhysicsBSPNode? node, Dictionary resolved, - SpherePath path, - CollisionSphere sphere, - Vector3 up) + SpherePath path, + CollisionSphere sphere, + Vector3 up) { if (node is null) return false; if (!NodeIntersects(node, sphere)) return false; @@ -818,7 +810,7 @@ public static class BSPQuery } // Internal. - float dist = Vector3.Dot(node.SplittingPlane.Normal, sphere.Center) + float dist = Vector3.Dot(node.SplittingPlane.Normal, sphere.Center) + node.SplittingPlane.D; float reach = sphere.Radius - PhysicsGlobals.EPSILON; @@ -856,10 +848,10 @@ public static class BSPQuery /// /// private static bool SphereIntersectsSolidInternal( - PhysicsBSPNode? node, + PhysicsBSPNode? node, Dictionary resolved, - CollisionSphere sphere, - bool centerCheck) + CollisionSphere sphere, + bool centerCheck) { if (node is null) return false; @@ -889,9 +881,9 @@ public static class BSPQuery if (PhysicsDiagnostics.ProbePlacementFailEnabled) { - PhysicsDiagnostics.LastPlacementFailPolyId = poly.Id; + PhysicsDiagnostics.LastPlacementFailPolyId = poly.Id; PhysicsDiagnostics.LastPlacementFailPolyNormal = poly.Plane.Normal; - PhysicsDiagnostics.LastPlacementFailPolyD = poly.Plane.D; + PhysicsDiagnostics.LastPlacementFailPolyD = poly.Plane.D; } return true; } @@ -902,7 +894,7 @@ public static class BSPQuery if (!NodeIntersects(node, sphere)) return false; // Internal. - float dist = Vector3.Dot(node.SplittingPlane.Normal, sphere.Center) + float dist = Vector3.Dot(node.SplittingPlane.Normal, sphere.Center) + node.SplittingPlane.D; float reach = sphere.Radius - PhysicsGlobals.EPSILON; @@ -946,13 +938,13 @@ public static class BSPQuery /// /// private static bool SphereIntersectsSolidPolyInternal( - PhysicsBSPNode? node, + PhysicsBSPNode? node, Dictionary resolved, - CollisionSphere sphere, - float radius, - ref bool centerSolid, - ref ResolvedPolygon? hitPoly, - bool centerCheck) + CollisionSphere sphere, + float radius, + ref bool centerSolid, + ref ResolvedPolygon? hitPoly, + bool centerCheck) { if (node is null) return centerSolid; @@ -982,7 +974,7 @@ public static class BSPQuery if (!NodeIntersects(node, sphere)) return centerSolid; // Internal. - float dist = Vector3.Dot(node.SplittingPlane.Normal, sphere.Center) + float dist = Vector3.Dot(node.SplittingPlane.Normal, sphere.Center) + node.SplittingPlane.D; float reach = radius - PhysicsGlobals.EPSILON; @@ -1085,7 +1077,7 @@ public static class BSPQuery if (node.Type == BSPNodeType.Leaf) return true; float dist = Vector3.Dot(node.SplittingPlane.Normal, center) + node.SplittingPlane.D; - float rad = radius + 0.01f; // retail's epsilon at :325551 + float rad = radius + 0.01f; // retail's epsilon at :325551 // Behind splitting plane by more than radius → sphere fully outside. if (dist < -rad) return false; @@ -1137,18 +1129,18 @@ public static class BSPQuery /// /// private static bool AdjustToPlane( - PhysicsBSPNode root, + PhysicsBSPNode root, Dictionary resolved, - CollisionSphere checkPos, - Vector3 curPos, - ResolvedPolygon hitPoly, - Vector3 contactPoint) + ref CollisionSphere checkPos, + Vector3 curPos, + ResolvedPolygon hitPoly, + Vector3 contactPoint) { var movement = checkPos.Center - curPos; double clearTime = 0.0; // retail var_50 — known-clear - double hitTime = 1.0; // retail var_48 — known-hit - int i = 0; + double hitTime = 1.0; // retail var_48 — known-hit + int i = 0; const int MaxIter = 15; @@ -1164,7 +1156,7 @@ public static class BSPQuery checkPos.Center = curPos + movement * touchTime; ResolvedPolygon? hp2 = null; - Vector3 cp2 = Vector3.Zero; + Vector3 cp2 = Vector3.Zero; if (!SphereIntersectsPolyInternal(root, resolved, checkPos, movement, ref hp2, ref cp2)) { @@ -1188,7 +1180,7 @@ public static class BSPQuery checkPos.Center = curPos + movement * (float)avg; ResolvedPolygon? hp2 = null; - Vector3 cp2 = Vector3.Zero; + Vector3 cp2 = Vector3.Zero; if (!SphereIntersectsPolyInternal(root, resolved, checkPos, movement, ref hp2, ref cp2)) clearTime = avg; @@ -1216,13 +1208,13 @@ public static class BSPQuery /// ACE: BSPTree.cs check_walkable. /// private static TransitionState CheckWalkableDispatch( - PhysicsBSPNode root, + PhysicsBSPNode root, Dictionary resolved, - SpherePath path, - CollisionSphere checkPos, - Vector3 up) + SpherePath path, + CollisionSphere checkPos, + Vector3 up) { - var validPos = new CollisionSphere(checkPos); + var validPos = checkPos; return HitsWalkableInternal(root, resolved, path, validPos, up) ? TransitionState.Collided : TransitionState.OK; @@ -1245,44 +1237,54 @@ public static class BSPQuery /// ACE: BSPTree.cs step_sphere_down. /// private static TransitionState StepSphereDown( - PhysicsBSPNode root, + PhysicsBSPNode root, Dictionary resolved, - Transition transition, - CollisionSphere checkPos, - Vector3 up, - float scale, - Quaternion localToWorld = default, - Vector3 worldOrigin = default) + Transition transition, + CollisionSphere checkPos, + Vector3 up, + float scale, + Quaternion localToWorld = default, + Vector3 worldOrigin = default) { if (localToWorld == default) localToWorld = Quaternion.Identity; - var path = transition.SpherePath; + var path = transition.SpherePath; var collisions = transition.CollisionInfo; float stepDownAmount = -(path.StepDownAmt * path.WalkInterp); - var movement = up * stepDownAmount * (1f / scale); + var movement = up * stepDownAmount * (1f / scale); - var validPos = new CollisionSphere(checkPos); + var validPos = checkPos; bool changed = false; ResolvedPolygon? polyHit = null; ushort _polyId = 0; // step-down doesn't need the id, but the signature requires it - FindWalkableInternal(root, resolved, path, validPos, movement, up, + FindWalkableInternal(root, resolved, path, ref validPos, movement, up, ref polyHit, ref _polyId, ref changed); if (changed && polyHit is not null) { // ACE: path.LocalSpacePos.LocalToGlobalVec(adjusted) * scale var adjusted = validPos.Center - checkPos.Center; - var offset = Vector3.Transform(adjusted, localToWorld) * scale; + var offset = Vector3.Transform(adjusted, localToWorld) * scale; path.AddOffsetToCheckPos(offset); var worldNormal = TransformNormal(polyHit.Plane.Normal, localToWorld); - var worldVertices = TransformVertices(polyHit.Vertices, localToWorld, scale, worldOrigin); - var worldPlane = BuildWorldPlane(worldNormal, worldVertices); + var worldPlane = BuildWorldPlane( + worldNormal, + polyHit.Vertices, + localToWorld, + scale, + worldOrigin); collisions.SetContactPlane(worldPlane, path.CheckCellId, false); - path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ); + path.SetWalkableTransformed( + worldPlane, + polyHit.Vertices, + localToWorld, + scale, + worldOrigin, + Vector3.UnitZ); if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled) PhysicsDiagnostics.LastBspHitPoly = polyHit; @@ -1342,29 +1344,71 @@ public static class BSPQuery /// /// True if a walkable polygon was found; false otherwise. public static bool FindWalkableSphere( - PhysicsBSPNode? root, + PhysicsBSPNode? root, Dictionary resolved, - Transition transition, - Sphere sphere, - float probeDistance, - Vector3 up, - out ResolvedPolygon? hitPoly, - out ushort hitPolyId, - out Vector3 adjustedCenter) + Transition transition, + Sphere sphere, + float probeDistance, + Vector3 up, + out ResolvedPolygon? hitPoly, + out ushort hitPolyId, + out Vector3 adjustedCenter) + => FindWalkableSphereCore( + root, + resolved, + transition, + new CollisionSphere(sphere.Origin, sphere.Radius), + probeDistance, + up, + out hitPoly, + out hitPolyId, + out adjustedCenter); + + internal static bool FindWalkableSphere( + PhysicsBSPNode? root, + Dictionary resolved, + Transition transition, + Vector3 sphereCenter, + float sphereRadius, + float probeDistance, + Vector3 up, + out ResolvedPolygon? hitPoly, + out ushort hitPolyId, + out Vector3 adjustedCenter) + => FindWalkableSphereCore( + root, + resolved, + transition, + new CollisionSphere(sphereCenter, sphereRadius), + probeDistance, + up, + out hitPoly, + out hitPolyId, + out adjustedCenter); + + private static bool FindWalkableSphereCore( + PhysicsBSPNode? root, + Dictionary resolved, + Transition transition, + CollisionSphere validPos, + float probeDistance, + Vector3 up, + out ResolvedPolygon? hitPoly, + out ushort hitPolyId, + out Vector3 adjustedCenter) { - adjustedCenter = sphere.Origin; + adjustedCenter = validPos.Center; hitPoly = null; hitPolyId = 0; if (root is null) return false; - var validPos = new CollisionSphere(sphere.Origin, sphere.Radius); var movement = -up * probeDistance; bool changed = false; ushort polyId = 0; ResolvedPolygon? poly = null; - FindWalkableInternal(root, resolved, transition.SpherePath, validPos, + FindWalkableInternal(root, resolved, transition.SpherePath, ref validPos, movement, up, ref poly, ref polyId, ref changed); if (changed && poly is not null) @@ -1401,8 +1445,8 @@ public static class BSPQuery /// /// private static TransitionState StepSphereUp( - Transition transition, - Vector3 collisionNormal, + Transition transition, + Vector3 collisionNormal, PhysicsEngine engine) { bool stepped = transition.DoStepUp(collisionNormal, engine!); @@ -1456,7 +1500,7 @@ public static class BSPQuery /// call sites apply L2W; ACE globalizes inside slide_sphere instead). private static TransitionState SlideSphere( Transition transition, - Vector3 worldNormal) + Vector3 worldNormal) => transition.SlideSphereInternal( worldNormal, transition.SpherePath.GlobalCurrCenter[0].Origin); @@ -1477,26 +1521,26 @@ public static class BSPQuery /// ACE: BSPTree.cs collide_with_pt. /// private static TransitionState CollideWithPt( - PhysicsBSPNode root, + PhysicsBSPNode root, Dictionary resolved, - Transition transition, - CollisionSphere checkPos, - Vector3 curPos, - ResolvedPolygon hitPoly, - Vector3 contactPoint, - float scale, - Quaternion localToWorld = default) + Transition transition, + CollisionSphere checkPos, + Vector3 curPos, + ResolvedPolygon hitPoly, + Vector3 contactPoint, + float scale, + Quaternion localToWorld = default) { if (localToWorld == default) localToWorld = Quaternion.Identity; - var obj = transition.ObjectInfo; - var path = transition.SpherePath; + var obj = transition.ObjectInfo; + var path = transition.SpherePath; var collisions = transition.CollisionInfo; // ACE: path.LocalSpacePos.LocalToGlobalVec(hitPoly.Plane.Normal) var collisionNormal = Vector3.Transform(hitPoly.Plane.Normal, localToWorld); - if (!obj.State.HasFlag(ObjectInfoState.PerfectClip)) + if ((obj.State & ObjectInfoState.PerfectClip) == 0) { collisions.SetCollisionNormal(collisionNormal); // L.2d slice 1 (2026-05-13): diagnostic side-channel. @@ -1505,9 +1549,9 @@ public static class BSPQuery return TransitionState.Collided; } - var validPos = new CollisionSphere(checkPos); + var validPos = checkPos; - if (!AdjustToPlane(root, resolved, validPos, curPos, hitPoly, contactPoint)) + if (!AdjustToPlane(root, resolved, ref validPos, curPos, hitPoly, contactPoint)) { // L.2d slice 1 (2026-05-13): record the would-have-hit poly before // the early-out — collisions.SetCollisionNormal isn't called on @@ -1525,7 +1569,7 @@ public static class BSPQuery var adjusted = validPos.Center - checkPos.Center; // ACE: path.LocalSpacePos.LocalToGlobalVec(adjusted) * scale - var offset = Vector3.Transform(adjusted, localToWorld) * scale; + var offset = Vector3.Transform(adjusted, localToWorld) * scale; path.AddOffsetToCheckPos(offset); return TransitionState.Adjusted; @@ -1541,14 +1585,14 @@ public static class BSPQuery /// ACE: BSPTree.cs NegPolyHit — calls path.SetNegPolyHit(stepUp, collisionNormal). /// private static TransitionState NegPolyHitDispatch( - SpherePath path, + SpherePath path, ResolvedPolygon hitPoly, - bool stepUp, - Quaternion localToWorld = default) + bool stepUp, + Quaternion localToWorld = default) { if (localToWorld == default) localToWorld = Quaternion.Identity; - path.NegPolyHit = true; - path.NegStepUp = stepUp; + path.NegPolyHit = true; + path.NegStepUp = stepUp; // ACE: path.LocalSpacePos.LocalToGlobalVec(hitPoly.Plane.Normal) path.NegCollisionNormal = Vector3.Transform(hitPoly.Plane.Normal, localToWorld); @@ -1582,10 +1626,10 @@ public static class BSPQuery /// ACE: BSPTree.cs placement_insert. /// private static TransitionState PlacementInsert( - PhysicsBSPNode root, + PhysicsBSPNode root, Dictionary resolved, - Transition transition, - bool clearCell) + Transition transition, + bool clearCell) { var path = transition.SpherePath; @@ -1595,8 +1639,9 @@ public static class BSPQuery float rad = s0.Radius; - CollisionSphere? s1 = null; - if (path.NumSphere > 1) + bool hasS1 = path.NumSphere > 1; + CollisionSphere s1 = default; + if (hasS1) s1 = new CollisionSphere( path.LocalSphere[1].Origin, path.LocalSphere[1].Radius); @@ -1614,13 +1659,20 @@ public static class BSPQuery { if (hitPoly is not null) { - AdjustToPlacementPoly(hitPoly, s0, s1, rad, centerSolid, clearCell); + AdjustToPlacementPoly( + hitPoly, + ref s0, + ref s1, + hasS1, + rad, + centerSolid, + clearCell); continue; } } else { - if (path.NumSphere >= 2 && s1 is not null) + if (hasS1) { centerSolid = false; hitPoly = null; @@ -1630,7 +1682,14 @@ public static class BSPQuery { if (hitPoly is not null) { - AdjustToPlacementPoly(hitPoly, s1, s0, rad, centerSolid, clearCell); + AdjustToPlacementPoly( + hitPoly, + ref s1, + ref s0, + true, + rad, + centerSolid, + clearCell); continue; } } @@ -1652,8 +1711,8 @@ public static class BSPQuery private static TransitionState PlacementInsertInner( CollisionSphere s0, - SpherePath path, - int iteration) + SpherePath path, + int iteration) { if (iteration == 0) return TransitionState.OK; @@ -1703,30 +1762,95 @@ public static class BSPQuery /// rotation quaternion. /// public static TransitionState FindCollisions( - PhysicsBSPNode? root, - Dictionary resolved, - Transition transition, - DatReaderWriter.Types.Sphere localSphere, - DatReaderWriter.Types.Sphere? localSphere1, - Vector3 localCurrCenter, - Vector3 localSpaceZ, - float scale, - Quaternion localToWorld = default, - PhysicsEngine? engine = null, - Vector3 worldOrigin = default) + PhysicsBSPNode? root, + Dictionary resolved, + Transition transition, + DatReaderWriter.Types.Sphere localSphere, + DatReaderWriter.Types.Sphere? localSphere1, + Vector3 localCurrCenter, + Vector3 localSpaceZ, + float scale, + Quaternion localToWorld = default, + PhysicsEngine? engine = null, + Vector3 worldOrigin = default) + { + var sphere0 = new CollisionSphere(localSphere.Origin, localSphere.Radius); + bool hasSphere1 = localSphere1 is not null; + CollisionSphere sphere1 = hasSphere1 + ? new CollisionSphere(localSphere1!.Origin, localSphere1.Radius) + : default; + + return FindCollisionsCore( + root, + resolved, + transition, + sphere0, + hasSphere1, + sphere1, + localCurrCenter, + localSpaceZ, + scale, + localToWorld, + engine, + worldOrigin); + } + + /// + /// Allocation-free production entry point. Input spheres are copied into + /// value scratch before entering the unchanged six-path dispatcher. + /// + internal static TransitionState FindCollisions( + PhysicsBSPNode? root, + Dictionary resolved, + Transition transition, + Vector3 localSphereCenter, + float localSphereRadius, + bool hasLocalSphere1, + Vector3 localSphere1Center, + float localSphere1Radius, + Vector3 localCurrCenter, + Vector3 localSpaceZ, + float scale, + Quaternion localToWorld = default, + PhysicsEngine? engine = null, + Vector3 worldOrigin = default) + => FindCollisionsCore( + root, + resolved, + transition, + new CollisionSphere(localSphereCenter, localSphereRadius), + hasLocalSphere1, + hasLocalSphere1 + ? new CollisionSphere(localSphere1Center, localSphere1Radius) + : default, + localCurrCenter, + localSpaceZ, + scale, + localToWorld, + engine, + worldOrigin); + + private static TransitionState FindCollisionsCore( + PhysicsBSPNode? root, + Dictionary resolved, + Transition transition, + CollisionSphere sphere0, + bool hasSphere1, + CollisionSphere sphere1, + Vector3 localCurrCenter, + Vector3 localSpaceZ, + float scale, + Quaternion localToWorld, + PhysicsEngine? engine, + Vector3 worldOrigin) { if (root is null) return TransitionState.OK; // Default quaternion (0,0,0,0) → treat as identity if (localToWorld == default) localToWorld = Quaternion.Identity; - var path = transition.SpherePath; + var path = transition.SpherePath; var collisions = transition.CollisionInfo; - var obj = transition.ObjectInfo; - - var sphere0 = new CollisionSphere(localSphere.Origin, localSphere.Radius); - CollisionSphere? sphere1 = localSphere1 is not null - ? new CollisionSphere(localSphere1.Origin, localSphere1.Radius) - : null; + var obj = transition.ObjectInfo; var movement = sphere0.Center - localCurrCenter; @@ -1738,13 +1862,13 @@ public static class BSPQuery if (PhysicsDiagnostics.ProbePushBackEnabled) { PhysicsDiagnostics.LogPushBackDispatch( - sphereCenter: sphere0.Center, - movement: movement, - collide: path.Collide, - insertType: (int)path.InsertType, - objState: unchecked((int)obj.State), + sphereCenter: sphere0.Center, + movement: movement, + collide: path.Collide, + insertType: (int)path.InsertType, + objState: unchecked((int)obj.State), walkInterpEntry: path.WalkInterp, - returnState: -1); + returnState: -1); } // Helper: transform a local-space vector to world space. @@ -1782,8 +1906,8 @@ public static class BSPQuery // failure) doesn't leak into the [place-fail] log. if (PhysicsDiagnostics.ProbePlacementFailEnabled) { - PhysicsDiagnostics.LastPlacementFailPolyId = 0; - PhysicsDiagnostics.LastPlacementFailSolidLeaf = false; + PhysicsDiagnostics.LastPlacementFailPolyId = 0; + PhysicsDiagnostics.LastPlacementFailSolidLeaf = false; } if (SphereIntersectsSolidInternal(root, resolved, sphere0, clearCell)) @@ -1797,11 +1921,11 @@ public static class BSPQuery if (PhysicsDiagnostics.ProbePlacementFailEnabled) { - PhysicsDiagnostics.LastPlacementFailPolyId = 0; - PhysicsDiagnostics.LastPlacementFailSolidLeaf = false; + PhysicsDiagnostics.LastPlacementFailPolyId = 0; + PhysicsDiagnostics.LastPlacementFailSolidLeaf = false; } - if (sphere1 is not null && + if (hasSphere1 && SphereIntersectsSolidInternal(root, resolved, sphere1, clearCell)) { if (PhysicsDiagnostics.ProbePlacementFailEnabled) @@ -1836,12 +1960,12 @@ public static class BSPQuery // ---------------------------------------------------------------- if (path.Collide) { - var validPos = new CollisionSphere(sphere0); + var validPos = sphere0; ResolvedPolygon? hitPoly = null; ushort _hitPolyId = 0; // Path 4 doesn't need the id bool changed = false; - FindWalkableInternal(root, resolved, path, validPos, movement, localSpaceZ, + FindWalkableInternal(root, resolved, path, ref validPos, movement, localSpaceZ, ref hitPoly, ref _hitPolyId, ref changed); if (changed && hitPoly is not null) @@ -1873,10 +1997,20 @@ public static class BSPQuery path.AddOffsetToCheckPos(worldOffset); var worldNormal = TransformNormal(hitPoly.Plane.Normal, localToWorld); - var worldVertices = TransformVertices(hitPoly.Vertices, localToWorld, scale, worldOrigin); - var worldPlane = BuildWorldPlane(worldNormal, worldVertices); + var worldPlane = BuildWorldPlane( + worldNormal, + hitPoly.Vertices, + localToWorld, + scale, + worldOrigin); collisions.SetContactPlane(worldPlane, path.CheckCellId, false); - path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ); + path.SetWalkableTransformed( + worldPlane, + hitPoly.Vertices, + localToWorld, + scale, + worldOrigin, + Vector3.UnitZ); if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled) PhysicsDiagnostics.LastBspHitPoly = hitPoly; @@ -1897,10 +2031,10 @@ public static class BSPQuery // 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)) + if ((obj.State & ObjectInfoState.Contact) != 0) { ResolvedPolygon? hitPoly0 = null; - Vector3 contact0 = Vector3.Zero; + Vector3 contact0 = Vector3.Zero; bool hit0 = SphereIntersectsPolyInternal(root, resolved, sphere0, movement, ref hitPoly0, ref contact0); @@ -1966,10 +2100,10 @@ public static class BSPQuery // assigns neg_step_up = arg2, so the HEAD near-miss (index 0) slides // and the FOOT near-miss (index 1) steps up. The head test wins when // both spheres near-miss (sphere1 is checked first). - if (sphere1 is not null) + if (hasSphere1) { ResolvedPolygon? hitPoly1 = null; - Vector3 contact1 = Vector3.Zero; + Vector3 contact1 = Vector3.Zero; bool hit1 = SphereIntersectsPolyInternal(root, resolved, sphere1, movement, ref hitPoly1, ref contact1); @@ -2014,14 +2148,14 @@ public static class BSPQuery // ---------------------------------------------------------------- { ResolvedPolygon? hitPoly0 = null; - Vector3 contact0 = Vector3.Zero; + Vector3 contact0 = Vector3.Zero; bool hit0 = SphereIntersectsPolyInternal(root, resolved, sphere0, movement, ref hitPoly0, ref contact0); if (hit0 || hitPoly0 is not null) { - if (obj.State.HasFlag(ObjectInfoState.PathClipped)) + if ((obj.State & ObjectInfoState.PathClipped) != 0) { return CollideWithPt(root, resolved, transition, sphere0, localCurrCenter, @@ -2066,9 +2200,9 @@ public static class BSPQuery if (worldNormal0.Z < PhysicsGlobals.FloorZ) { Vector3 currWorld = path.GlobalCurrCenter[0].Origin; - Vector3 endWorld = path.GlobalSphere[0].Origin; - Vector3 gDelta = endWorld - currWorld; - float diff = Vector3.Dot(worldNormal0, gDelta); + Vector3 endWorld = path.GlobalSphere[0].Origin; + Vector3 gDelta = endWorld - currWorld; + float diff = Vector3.Dot(worldNormal0, gDelta); if (diff < 0f) path.AddOffsetToCheckPos(-worldNormal0 * diff); @@ -2090,10 +2224,10 @@ public static class BSPQuery return TransitionState.Adjusted; } - if (sphere1 is not null) + if (hasSphere1) { ResolvedPolygon? hitPoly1 = null; - Vector3 contact1 = Vector3.Zero; + Vector3 contact1 = Vector3.Zero; bool hit1 = SphereIntersectsPolyInternal(root, resolved, sphere1, movement, ref hitPoly1, ref contact1); @@ -2106,9 +2240,9 @@ public static class BSPQuery if (worldNormal1.Z < PhysicsGlobals.FloorZ) { Vector3 currWorld = path.GlobalCurrCenter[0].Origin; - Vector3 endWorld = path.GlobalSphere[0].Origin; - Vector3 gDelta = endWorld - currWorld; - float diff = Vector3.Dot(worldNormal1, gDelta); + Vector3 endWorld = path.GlobalSphere[0].Origin; + Vector3 gDelta = endWorld - currWorld; + float diff = Vector3.Dot(worldNormal1, gDelta); if (diff < 0f) path.AddOffsetToCheckPos(-worldNormal1 * diff); @@ -2157,15 +2291,15 @@ public static class BSPQuery /// with pre-resolved data when available. /// public static TransitionState FindCollisions( - PhysicsBSPNode? root, + PhysicsBSPNode? root, Dictionary polygons, - DatReaderWriter.Types.VertexArray vertices, - Transition transition, - DatReaderWriter.Types.Sphere localSphere, - DatReaderWriter.Types.Sphere? localSphere1, - Vector3 localCurrCenter, - Vector3 localSpaceZ, - float scale) + DatReaderWriter.Types.VertexArray vertices, + Transition transition, + DatReaderWriter.Types.Sphere localSphere, + DatReaderWriter.Types.Sphere? localSphere1, + Vector3 localCurrCenter, + Vector3 localSpaceZ, + float scale) { var resolved = BuildResolved(polygons, vertices); return FindCollisions(root, resolved, transition, @@ -2188,13 +2322,13 @@ public static class BSPQuery /// /// public static bool SphereIntersectsPoly( - PhysicsBSPNode? node, + PhysicsBSPNode? node, Dictionary polygons, - DatReaderWriter.Types.VertexArray vertices, - Vector3 sphereCenter, - float sphereRadius, - out ushort hitPolyId, - out Vector3 hitNormal) + DatReaderWriter.Types.VertexArray vertices, + Vector3 sphereCenter, + float sphereRadius, + out ushort hitPolyId, + out Vector3 hitNormal) { hitPolyId = 0; hitNormal = Vector3.Zero; @@ -2206,18 +2340,18 @@ public static class BSPQuery } private static bool SphereIntersectsPolyStaticRecurse( - PhysicsBSPNode? node, + PhysicsBSPNode? node, Dictionary resolved, - Vector3 center, - float radius, - ref ushort hitPolyId, - ref Vector3 hitNormal) + Vector3 center, + float radius, + ref ushort hitPolyId, + ref Vector3 hitNormal) { if (node is null) return false; // Broad phase. var bs = node.BoundingSphere; - var d = center - bs.Origin; + var d = center - bs.Origin; float r = radius + bs.Radius; if (d.LengthSquared() >= r * r) return false; @@ -2240,7 +2374,7 @@ public static class BSPQuery } float splitDist = Vector3.Dot(node.SplittingPlane.Normal, center) + node.SplittingPlane.D; - float reach = radius - PhysicsGlobals.EPSILON; + float reach = radius - PhysicsGlobals.EPSILON; if (splitDist >= reach) return SphereIntersectsPolyStaticRecurse(node.PosNode, resolved, @@ -2273,19 +2407,19 @@ public static class BSPQuery /// /// public static bool SphereIntersectsPolyWithTime( - PhysicsBSPNode? node, + PhysicsBSPNode? node, Dictionary polygons, - DatReaderWriter.Types.VertexArray vertices, - Vector3 sphereCenter, - float sphereRadius, - Vector3 movement, - out ushort hitPolyId, - out Vector3 hitNormal, - out float hitTime) + DatReaderWriter.Types.VertexArray vertices, + Vector3 sphereCenter, + float sphereRadius, + Vector3 movement, + out ushort hitPolyId, + out Vector3 hitNormal, + out float hitTime) { hitPolyId = 0; hitNormal = Vector3.Zero; - hitTime = float.MaxValue; + hitTime = float.MaxValue; if (node is null) return false; var resolved = BuildResolved(polygons, vertices); @@ -2298,20 +2432,20 @@ public static class BSPQuery } private static void SphereIntersectsPolyWithTimeRecurse( - PhysicsBSPNode? node, + PhysicsBSPNode? node, Dictionary resolved, - Vector3 center, - float radius, - Vector3 movement, - ref ushort hitPolyId, - ref Vector3 hitNormal, - ref float bestTime) + Vector3 center, + float radius, + Vector3 movement, + ref ushort hitPolyId, + ref Vector3 hitNormal, + ref float bestTime) { if (node is null) return; // Broad phase. var bs = node.BoundingSphere; - var d = center - bs.Origin; + var d = center - bs.Origin; float r = radius + bs.Radius + movement.Length() + 0.1f; if (d.LengthSquared() >= r * r) return; @@ -2330,7 +2464,7 @@ public static class BSPQuery { if (0f < bestTime) { - bestTime = 0f; + bestTime = 0f; hitPolyId = polyId; hitNormal = poly.Plane.Normal; } @@ -2343,7 +2477,7 @@ public static class BSPQuery { if (1f < bestTime) { - bestTime = 1f; + bestTime = 1f; hitPolyId = polyId; hitNormal = poly.Plane.Normal; } @@ -2353,7 +2487,7 @@ public static class BSPQuery } float splitDist = Vector3.Dot(node.SplittingPlane.Normal, center) + node.SplittingPlane.D; - float reach = radius + movement.Length(); + float reach = radius + movement.Length(); if (splitDist >= reach) { @@ -2384,7 +2518,7 @@ public static class BSPQuery private static Dictionary BuildResolved( Dictionary polygons, - DatReaderWriter.Types.VertexArray vertices) + DatReaderWriter.Types.VertexArray vertices) { var resolved = new Dictionary(polygons.Count); foreach (var (id, poly) in polygons) @@ -2419,11 +2553,11 @@ public static class BSPQuery resolved[id] = new ResolvedPolygon { - Vertices = verts, - Plane = new Plane(normal, planeD), + Vertices = verts, + Plane = new Plane(normal, planeD), NumPoints = n, SidesType = poly.SidesType, - Id = id, + Id = id, }; } return resolved; diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index a5977dd3..694d15d7 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -705,26 +705,38 @@ public static class CellTransit out IReadOnlyCollection cellSet, Vector3? carriedBlockOrigin = null) { + var candidates = new CellArray(); var containing = BuildCellSetAndPickContaining( cache, worldSpheres, numSpheres, currentCellId, - carriedBlockOrigin, out var candidates); + carriedBlockOrigin, candidates); cellSet = candidates; return containing; } + internal static uint FindCellSet( + PhysicsDataCache cache, + IReadOnlyList worldSpheres, + int numSpheres, + uint currentCellId, + CellArray candidates, + Vector3? carriedBlockOrigin = null) + => BuildCellSetAndPickContaining( + cache, worldSpheres, numSpheres, currentCellId, + carriedBlockOrigin, candidates); + private static uint BuildCellSetAndPickContaining( PhysicsDataCache cache, IReadOnlyList worldSpheres, int numSpheres, uint currentCellId, Vector3? carriedBlockOrigin, - out CellArray candidates) + CellArray candidates) { // Ordered, deduped candidate array — retail CELLARRAY (add_cell @701036). // The ORDER is load-bearing: the current cell is added at index 0 and the // pick iterates in order with interior-wins-break, so the current cell wins // a boundary straddle and the membership does not ping-pong (the R1 flap). - candidates = new CellArray(); + candidates.Clear(); int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres); if (sphereCount == 0) return currentCellId; diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs index b7cab676..00cdfb97 100644 --- a/src/AcDream.Core/Physics/PhysicsBody.cs +++ b/src/AcDream.Core/Physics/PhysicsBody.cs @@ -25,21 +25,21 @@ namespace AcDream.Core.Physics; [Flags] public enum PhysicsStateFlags : uint { - None = 0x00000000, - Static = 0x00000001, - Ethereal = 0x00000004, - ReportCollisions = 0x00000008, - IgnoreCollisions = 0x00000010, - NoDraw = 0x00000020, - Missile = 0x00000040, - Pushable = 0x00000080, - AlignPath = 0x00000100, - PathClipped = 0x00000200, - Gravity = 0x00000400, - Lighting = 0x00000800, - ParticleEmitter = 0x00001000, - Hidden = 0x00004000, - ScriptedCollision = 0x00008000, + None = 0x00000000, + Static = 0x00000001, + Ethereal = 0x00000004, + ReportCollisions = 0x00000008, + IgnoreCollisions = 0x00000010, + NoDraw = 0x00000020, + Missile = 0x00000040, + Pushable = 0x00000080, + AlignPath = 0x00000100, + PathClipped = 0x00000200, + Gravity = 0x00000400, + Lighting = 0x00000800, + ParticleEmitter = 0x00001000, + Hidden = 0x00004000, + ScriptedCollision = 0x00008000, /// /// A6.P7 (2026-05-25): retail HAS_PHYSICS_BSP_PS bit /// (acclient.h:2833). When set, the entity exposes a per-Setup @@ -52,7 +52,7 @@ public enum PhysicsStateFlags : uint /// state 0x10008 (STATIC | REPORT_COLLISIONS | HAS_PHYSICS_BSP). /// ACE name: PhysicsState.HasPhysicsBSP. /// - HasPhysicsBsp = 0x00010000, + HasPhysicsBsp = 0x00010000, /// /// L.3a (2026-04-30): retail INELASTIC_PS bit (acclient.h:2834). /// When set, wall-collisions zero the velocity instead of reflecting. @@ -60,14 +60,14 @@ public enum PhysicsStateFlags : uint /// impact rather than bounce. The player NEVER has this flag set — /// player wall-hits use the reflection path with elasticity ~0.05. /// - Inelastic = 0x00020000, - HasDefaultAnim = 0x00040000, - HasDefaultScript = 0x00080000, - Cloaked = 0x00100000, + Inelastic = 0x00020000, + HasDefaultAnim = 0x00040000, + HasDefaultScript = 0x00080000, + Cloaked = 0x00100000, ReportAsEnvironment = 0x00200000, - EdgeSlide = 0x00400000, - Sledding = 0x00800000, - Frozen = 0x01000000, + EdgeSlide = 0x00400000, + Sledding = 0x00800000, + Frozen = 0x01000000, } /// @@ -77,17 +77,17 @@ public enum PhysicsStateFlags : uint [Flags] public enum TransientStateFlags : uint { - None = 0, - Contact = 0x00000001, // bit 0 — touching any surface + 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 + Sliding = 0x00000004, // bit 2 — carry sliding normal into next transition // retail frames_stationary_fall carried across frames: transition() seeds fsf from // these bits before the sweep (pc:280940-947); handle_all_collisions re-encodes fsf // into them at the end of the frame (pc:282743/282749/282753). - StationaryFall = 0x00000010, // bit 4 — fsf == 1 - StationaryStop = 0x00000020, // bit 5 — fsf == 2 + StationaryFall = 0x00000010, // bit 4 — fsf == 1 + StationaryStop = 0x00000020, // bit 5 — fsf == 2 StationaryStuck = 0x00000040, // bit 6 — fsf == 3 - Active = 0x00000080, // bit 7 — object needs per-frame update + Active = 0x00000080, // bit 7 — object needs per-frame update } /// @@ -99,17 +99,17 @@ public sealed class PhysicsBody { // ── constants ────────────────────────────────────────────────────────── // From PhysicsGlobals.cs / confirmed by DAT_007c78a4 reference in decompiled code. - public const float MaxVelocity = 50.0f; + public const float MaxVelocity = 50.0f; public const float MaxVelocitySquared = MaxVelocity * MaxVelocity; - public const float Gravity = -9.8f; // DAT_0082223c in FUN_00511420 - public const float SmallVelocity = 0.25f; + public const float Gravity = -9.8f; // DAT_0082223c in FUN_00511420 + public const float SmallVelocity = 0.25f; public const float SmallVelocitySquared = SmallVelocity * SmallVelocity; - public const float DefaultFriction = 0.95f; - public const float MinQuantum = 1.0f / 30.0f; // ~0.0333 s + public const float DefaultFriction = 0.95f; + public const float MinQuantum = 1.0f / 30.0f; // ~0.0333 s // Matching-client disassembly resolves the named lift's stripped global to // 0.2 seconds. update_object consumes larger gaps as repeated 0.2 s quanta. - public const float MaxQuantum = 0.2f; - public const float HugeQuantum = 2.0f; // discard stale dt + public const float MaxQuantum = 0.2f; + public const float HugeQuantum = 2.0f; // discard stale dt // ── struct fields ────────────────────────────────────────────────────── // Offsets from acclient_function_map.md §PhysicsObj Struct Layout. @@ -336,6 +336,28 @@ public sealed class PhysicsBody /// Most recent walkable polygon vertices (world-space). public Vector3[]? WalkableVertices { get; set; } + // Transition publication retains one exact-length backing array. The + // public property remains assignable for snapshot restoration and tests; + // engine writeback uses this separate storage so clearing the logical + // walkable reference does not force a new array on the next grounded + // resolve. + private Vector3[]? _walkableVertexStorage; + + internal void SetWalkableVerticesExact(ReadOnlySpan source) + { + if (_walkableVertexStorage is null + || _walkableVertexStorage.Length != source.Length) + { + _walkableVertexStorage = new Vector3[source.Length]; + } + + source.CopyTo(_walkableVertexStorage); + WalkableVertices = _walkableVertexStorage; + } + + internal Vector3[]? RetainedWalkableVertexStorage + => _walkableVertexStorage; + /// Up vector used by the most recent walkable polygon probe. public Vector3 WalkableUp { get; set; } = Vector3.UnitZ; @@ -389,10 +411,10 @@ public sealed class PhysicsBody // ── convenience helpers ──────────────────────────────────────────────── - public bool HasGravity => State.HasFlag(PhysicsStateFlags.Gravity); - public bool OnWalkable => TransientState.HasFlag(TransientStateFlags.OnWalkable); - public bool IsActive => TransientState.HasFlag(TransientStateFlags.Active); - public bool InContact => TransientState.HasFlag(TransientStateFlags.Contact); + public bool HasGravity => (State & PhysicsStateFlags.Gravity) != 0; + public bool OnWalkable => (TransientState & TransientStateFlags.OnWalkable) != 0; + public bool IsActive => (TransientState & TransientStateFlags.Active) != 0; + public bool InContact => (TransientState & TransientStateFlags.Contact) != 0; // ── FUN_00511420 ─────────────────────────────────────────────────────── @@ -411,16 +433,16 @@ public sealed class PhysicsBody /// public void calc_acceleration() { - if (TransientState.HasFlag(TransientStateFlags.Contact) && - TransientState.HasFlag(TransientStateFlags.OnWalkable) && - !State.HasFlag(PhysicsStateFlags.Sledding)) + if ((TransientState & TransientStateFlags.Contact) != 0 && + (TransientState & TransientStateFlags.OnWalkable) != 0 && + (State & PhysicsStateFlags.Sledding) == 0) { Acceleration = Vector3.Zero; Omega = Vector3.Zero; return; } - if (State.HasFlag(PhysicsStateFlags.Gravity)) + if ((State & PhysicsStateFlags.Gravity) != 0) Acceleration = new Vector3(0f, 0f, Gravity); else Acceleration = Vector3.Zero; @@ -540,7 +562,7 @@ public sealed class PhysicsBody /// public void calc_friction(float dt, float velocityMag2) { - if (!TransientState.HasFlag(TransientStateFlags.OnWalkable)) + if ((TransientState & TransientStateFlags.OnWalkable) == 0) return; float dot = Vector3.Dot(GroundNormal, Velocity); @@ -553,7 +575,7 @@ public sealed class PhysicsBody float friction = Friction; // Sledding modifies friction thresholds (from ACE cross-check). - if (State.HasFlag(PhysicsStateFlags.Sledding)) + if ((State & PhysicsStateFlags.Sledding) != 0) { if (velocityMag2 < 1.5625f) // 1.25² — slow sled friction = 1.0f; @@ -590,7 +612,7 @@ public sealed class PhysicsBody if (velocityMag2 <= 0f) { // No movement manager equivalent here; just clear Active if grounded. - if (TransientState.HasFlag(TransientStateFlags.OnWalkable)) + if ((TransientState & TransientStateFlags.OnWalkable) != 0) TransientState &= ~TransientStateFlags.Active; } else diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index a11d30b4..fce435a6 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -6,7 +6,7 @@ namespace AcDream.Core.Physics; internal readonly record struct TerrainWalkableSample( System.Numerics.Plane Plane, - Vector3[] Vertices, + TerrainTriangleVertices Vertices, float WaterDepth, bool IsWater, uint CellId); @@ -27,6 +27,30 @@ internal readonly record struct TerrainWalkableSample( public sealed class PhysicsEngine { private readonly Dictionary _landblocks = new(); + private readonly TransitionScratchArena? _transitionScratch; + + public PhysicsEngine() + : this(reuseTransitionScratch: true) + { + } + + /// + /// Test seam for fresh-versus-reused transition differential evidence. + /// Production always uses the public constructor and owns one retail-shaped + /// scratch arena. + /// + internal PhysicsEngine(bool reuseTransitionScratch) + { + _transitionScratch = reuseTransitionScratch + ? new TransitionScratchArena() + : null; + } + + private Transition RentTransition() + => _transitionScratch?.Rent() ?? new Transition(); + + private void ReturnTransition(Transition transition) + => _transitionScratch?.Return(transition); /// Number of registered landblocks (diagnostic). public int LandblockCount => _landblocks.Count; @@ -63,13 +87,13 @@ public sealed class PhysicsEngine int cx = (int)((cellOrLandblockId >> 24) & 0xFFu); int cy = (int)((cellOrLandblockId >> 16) & 0xFFu); for (int dx = -radius; dx <= radius; dx++) - for (int dy = -radius; dy <= radius; dy++) - { - int nx = cx + dx, ny = cy + dy; - if (nx < 0 || nx > 254 || ny < 0 || ny > 254) continue; // off-map: skip - uint prefix = ((uint)nx << 24) | ((uint)ny << 16); - if (!resident.Contains(prefix)) return false; - } + for (int dy = -radius; dy <= radius; dy++) + { + int nx = cx + dx, ny = cy + dy; + if (nx < 0 || nx > 254 || ny < 0 || ny > 254) continue; // off-map: skip + uint prefix = ((uint)nx << 24) | ((uint)ny << 16); + if (!resident.Contains(prefix)) return false; + } return true; } @@ -195,13 +219,13 @@ public sealed class PhysicsEngine float localY = worldY - lb.WorldOffsetY; if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f) { - landblockId = kvp.Key; + landblockId = kvp.Key; worldOffsetX = lb.WorldOffsetX; worldOffsetY = lb.WorldOffsetY; return true; } } - landblockId = 0; + landblockId = 0; worldOffsetX = 0f; worldOffsetY = 0f; return false; @@ -307,15 +331,10 @@ public sealed class PhysicsEngine 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 vertices = new TerrainTriangleVertices( + OffsetTerrainVertex(sample.Vertices.V0, lb), + OffsetTerrainVertex(sample.Vertices.V1, lb), + OffsetTerrainVertex(sample.Vertices.V2, lb)); var normal = sample.Normal; float d = -Vector3.Dot(normal, vertices[0]); @@ -337,6 +356,12 @@ public sealed class PhysicsEngine return null; } + private static Vector3 OffsetTerrainVertex(Vector3 vertex, LandblockPhysics landblock) + => new( + vertex.X + landblock.WorldOffsetX, + vertex.Y + landblock.WorldOffsetY, + vertex.Z); + /// /// Indoor walking Phase 2 (2026-05-19). Resolves the cell id for a /// given world position via retail's portal-graph traversal for indoor @@ -986,413 +1011,420 @@ public sealed class PhysicsEngine // the body BEFORE the engine mutates it so the replay test can seed its // PhysicsBody with the exact pre-call state. See PhysicsResolveCapture.cs. bool captureEnabled = PhysicsResolveCapture.IsEnabled - && moverFlags.HasFlag(ObjectInfoState.IsPlayer); + && (moverFlags & ObjectInfoState.IsPlayer) != 0; PhysicsBodySnapshot? bodyBeforeSnap = captureEnabled && body is not null ? PhysicsResolveCapture.Snapshot(body) : null; - var transition = new Transition(); - transition.ObjectInfo.StepUpHeight = stepUpHeight; - transition.ObjectInfo.StepDownHeight = stepDownHeight; - transition.ObjectInfo.StepDown = true; - // Fix #42 (2026-05-05): the moving entity's ShadowEntry must be - // skipped in FindObjCollisions or the sweep collides with self. - // Default 0 keeps tests / one-shot callers (no registered entity) - // working. Plumbed through ObjectInfo because retail stores the - // self pointer on OBJECTINFO::object (named-retail - // acclient_2013_pseudo_c.txt:274435 OBJECTINFO::init → - // this->object = arg2). The skip itself is at - // CObjCell::find_obj_collisions line 308931. - transition.ObjectInfo.SelfEntityId = movingEntityId; - transition.ObjectInfo.MoverPhysicsState = body?.State ?? PhysicsStateFlags.None; - transition.ObjectInfo.TargetId = designatedTargetId; - - // Commit C 2026-04-29 — caller-supplied mover flags drive the - // retail PvP exemption block in FindObjCollisions. The local - // player passes IsPlayer (and PK/PKLite/Impenetrable when known - // from PlayerDescription); remote dead-reckoning passes None - // (matches non-player movement, all targets collide). - transition.ObjectInfo.State |= moverFlags; - - // CPhysicsObj::get_object_info 0x00511CC0: Missile contributes - // PathClipped only. PerfectClip is deliberately not inferred. - if (transition.ObjectInfo.MoverPhysicsState.HasFlag(PhysicsStateFlags.Missile)) - transition.ObjectInfo.State |= ObjectInfoState.PathClipped; - - // frames_stationary_fall gate input: retail reads the mover's GRAVITY state bit - // (object_info.object->state & 0x400, pc:272625). Seed it from the body so the ladder - // in ValidateTransition runs for gravity movers (the player) and not floating props. - transition.ObjectInfo.MoverHasGravity = body?.HasGravity ?? false; - - if (isOnGround) - transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable; - - // K-fix7 (2026-04-26): only seed the contact plane when the body - // is actually grounded. Pre-seeding while AIRBORNE caused - // AdjustOffset's "Have a contact plane / Moving away from plane" - // branch to fire on every jump step — which calls - // Plane::snap_to_plane on the offset and ZEROES the Z component, - // killing all upward jump motion. - // - // We KEEP the seeding when isOnGround for slope-walking + step-up - // continuity (the original concern that motivated the seed). - // BSP step_up needs ContactPlane on sub-step 1 to compute the - // correct lift direction; removing the seed breaks stair-walking - // at the last step (verified by A6.P3 slice 2 first attempt - // 2026-05-22, reverted in this commit). Retail's CTransition::init - // explicitly CLEARS contact_plane_valid; we deliberately diverge - // for step_up correctness. - // - // A6.P3 slice 2 (2026-05-22) — to close issue #96 (per-tick CP-write - // blowup) without breaking stair-walking, the no-op-if-unchanged - // guard inside CollisionInfo.SetContactPlane (TransitionTypes.cs:259) - // collapses redundant seeds (same plane every tick) to a true no-op. - // The seed still fires the function call but only counts as a write - // when the plane values actually change. - if (isOnGround && body is not null && body.ContactPlaneValid) + var transition = RentTransition(); + try { - transition.CollisionInfo.SetContactPlane( - body.ContactPlane, - body.ContactPlaneCellId, - body.ContactPlaneIsWater); - } + transition.ObjectInfo.StepUpHeight = stepUpHeight; + transition.ObjectInfo.StepDownHeight = stepDownHeight; + transition.ObjectInfo.StepDown = true; + // Fix #42 (2026-05-05): the moving entity's ShadowEntry must be + // skipped in FindObjCollisions or the sweep collides with self. + // Default 0 keeps tests / one-shot callers (no registered entity) + // working. Plumbed through ObjectInfo because retail stores the + // self pointer on OBJECTINFO::object (named-retail + // acclient_2013_pseudo_c.txt:274435 OBJECTINFO::init → + // this->object = arg2). The skip itself is at + // CObjCell::find_obj_collisions line 308931. + transition.ObjectInfo.SelfEntityId = movingEntityId; + transition.ObjectInfo.MoverPhysicsState = body?.State ?? PhysicsStateFlags.None; + transition.ObjectInfo.TargetId = designatedTargetId; - // 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); - } + // Commit C 2026-04-29 — caller-supplied mover flags drive the + // retail PvP exemption block in FindObjCollisions. The local + // player passes IsPlayer (and PK/PKLite/Impenetrable when known + // from PlayerDescription); remote dead-reckoning passes None + // (matches non-player movement, all targets collide). + transition.ObjectInfo.State |= moverFlags; - transition.SpherePath.InitPath( - currentPos, - targetPos, - cellId, - sphereRadius, - sphereHeight, - localSphereOrigin, - beginOrientation, - endOrientation); + // CPhysicsObj::get_object_info 0x00511CC0: Missile contributes + // PathClipped only. PerfectClip is deliberately not inferred. + if ((transition.ObjectInfo.MoverPhysicsState & PhysicsStateFlags.Missile) != 0) + transition.ObjectInfo.State |= ObjectInfoState.PathClipped; - // #145: supply the carried cell-relative frame anchor to the outdoor - // membership pick. body.Position - body.CellPosition.Frame.Origin is the TRUE - // landblock world origin, correct even for an UNSTREAMED neighbour — replacing - // the terrain-registry origin that returns (0,0) and marches the cell id one - // landblock per tick (the #145 far-town cascade). Engaged only for a SEEDED - // OUTDOOR body whose carried landblock matches the resolve cell (the controller - // passes body.CellPosition.ObjCellId for the outdoor case, so they agree); - // null otherwise → legacy TryGetTerrainOrigin for NPCs/tests/indoor. - transition.SpherePath.CarriedBlockOrigin = - body is not null - && (cellId & 0xFFFFu) is >= 1u and <= 0x40u // resolve cell is an outdoor landcell - && (body.CellPosition.ObjCellId & 0xFFFFu) is >= 1u and <= 0x40u // carried cell is outdoor (seeded) - && (cellId >> 16) == (body.CellPosition.ObjCellId >> 16) // same landblock → anchor consistent - ? body.Position - body.CellPosition.Frame.Origin - : null; + // frames_stationary_fall gate input: retail reads the mover's GRAVITY state bit + // (object_info.object->state & 0x400, pc:272625). Seed it from the body so the ladder + // in ValidateTransition runs for gravity movers (the player) and not floating props. + transition.ObjectInfo.MoverHasGravity = body?.HasGravity ?? false; - if (isOnGround && body is not null - && body.WalkablePolygonValid - && body.WalkableVertices is { Length: >= 3 }) - { - transition.SpherePath.SetWalkable( - body.WalkablePlane, - body.WalkableVertices, - body.WalkableUp); - } + if (isOnGround) + transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable; - // Seed collision_info.frames_stationary_fall from the body's carried Stationary* - // transient bits — retail transition() 0x00512dc0 seeds fsf from transient_state - // 0x40/0x20/0x10 AFTER init_path and immediately BEFORE find_valid_position - // (pc:280939-949). Placed here (post-InitPath) so InitPath's CollisionInfo reset - // doesn't wipe the seed. - if (body is not null) - { - transition.CollisionInfo.FramesStationaryFall = - body.TransientState.HasFlag(TransientStateFlags.StationaryStuck) ? 3 : - body.TransientState.HasFlag(TransientStateFlags.StationaryStop) ? 2 : - body.TransientState.HasFlag(TransientStateFlags.StationaryFall) ? 1 : 0; - } + // K-fix7 (2026-04-26): only seed the contact plane when the body + // is actually grounded. Pre-seeding while AIRBORNE caused + // AdjustOffset's "Have a contact plane / Moving away from plane" + // branch to fire on every jump step — which calls + // Plane::snap_to_plane on the offset and ZEROES the Z component, + // killing all upward jump motion. + // + // We KEEP the seeding when isOnGround for slope-walking + step-up + // continuity (the original concern that motivated the seed). + // BSP step_up needs ContactPlane on sub-step 1 to compute the + // correct lift direction; removing the seed breaks stair-walking + // at the last step (verified by A6.P3 slice 2 first attempt + // 2026-05-22, reverted in this commit). Retail's CTransition::init + // explicitly CLEARS contact_plane_valid; we deliberately diverge + // for step_up correctness. + // + // A6.P3 slice 2 (2026-05-22) — to close issue #96 (per-tick CP-write + // blowup) without breaking stair-walking, the no-op-if-unchanged + // guard inside CollisionInfo.SetContactPlane (TransitionTypes.cs:259) + // collapses redundant seeds (same plane every tick) to a true no-op. + // The seed still fires the function call but only counts as a write + // when the plane values actually change. + if (isOnGround && body is not null && body.ContactPlaneValid) + { + transition.CollisionInfo.SetContactPlane( + body.ContactPlane, + body.ContactPlaneCellId, + body.ContactPlaneIsWater); + } - bool ok = transition.FindTransitionalPosition(this); + // 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 & TransientStateFlags.Sliding) != 0 + && body.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq) + { + transition.CollisionInfo.SetSlidingNormal(body.SlidingNormal); + } - var sp = transition.SpherePath; - var ci = transition.CollisionInfo; + transition.SpherePath.InitPath( + currentPos, + targetPos, + cellId, + sphereRadius, + sphereHeight, + localSphereOrigin, + beginOrientation, + endOrientation); - // Persist the resulting contact plane state back to the body so the - // next frame's transition can seed from it. Uses LastKnownContactPlane - // when current is invalid (e.g., airborne this frame), matching retail. - if (body is not null) - { - // CPhysicsObj::transition 0x00512DC0 discards its CTransition when - // find_valid_position fails. Only SetPositionInternal 0x00515330 - // publishes contact/fsf/walkable/sliding state, and that function - // is unreachable on the UpdateObjectInternal failure branch. + // #145: supply the carried cell-relative frame anchor to the outdoor + // membership pick. body.Position - body.CellPosition.Frame.Origin is the TRUE + // landblock world origin, correct even for an UNSTREAMED neighbour — replacing + // the terrain-registry origin that returns (0,0) and marches the cell id one + // landblock per tick (the #145 far-town cascade). Engaged only for a SEEDED + // OUTDOOR body whose carried landblock matches the resolve cell (the controller + // passes body.CellPosition.ObjCellId for the outdoor case, so they agree); + // null otherwise → legacy TryGetTerrainOrigin for NPCs/tests/indoor. + transition.SpherePath.CarriedBlockOrigin = + body is not null + && (cellId & 0xFFFFu) is >= 1u and <= 0x40u // resolve cell is an outdoor landcell + && (body.CellPosition.ObjCellId & 0xFFFFu) is >= 1u and <= 0x40u // carried cell is outdoor (seeded) + && (cellId >> 16) == (body.CellPosition.ObjCellId >> 16) // same landblock → anchor consistent + ? body.Position - body.CellPosition.Frame.Origin + : null; + + if (isOnGround && body is not null + && body.WalkablePolygonValid + && body.WalkableVertices is { Length: >= 3 }) + { + transition.SpherePath.SetWalkable( + body.WalkablePlane, + body.WalkableVertices, + body.WalkableUp); + } + + // Seed collision_info.frames_stationary_fall from the body's carried Stationary* + // transient bits — retail transition() 0x00512dc0 seeds fsf from transient_state + // 0x40/0x20/0x10 AFTER init_path and immediately BEFORE find_valid_position + // (pc:280939-949). Placed here (post-InitPath) so InitPath's CollisionInfo reset + // doesn't wipe the seed. + if (body is not null) + { + transition.CollisionInfo.FramesStationaryFall = + (body.TransientState & TransientStateFlags.StationaryStuck) != 0 ? 3 : + (body.TransientState & TransientStateFlags.StationaryStop) != 0 ? 2 : + (body.TransientState & TransientStateFlags.StationaryFall) != 0 ? 1 : 0; + } + + bool ok = transition.FindTransitionalPosition(this); + + var sp = transition.SpherePath; + var ci = transition.CollisionInfo; + + // Persist the resulting contact plane state back to the body so the + // next frame's transition can seed from it. Uses LastKnownContactPlane + // when current is invalid (e.g., airborne this frame), matching retail. + if (body is not null) + { + // CPhysicsObj::transition 0x00512DC0 discards its CTransition when + // find_valid_position fails. Only SetPositionInternal 0x00515330 + // publishes contact/fsf/walkable/sliding state, and that function + // is unreachable on the UpdateObjectInternal failure branch. + if (ok) + { + if (ci.ContactPlaneValid) + { + body.ContactPlaneValid = true; + body.ContactPlane = ci.ContactPlane; + body.ContactPlaneCellId = ci.ContactPlaneCellId; + body.ContactPlaneIsWater = ci.ContactPlaneIsWater; + } + else if (ci.LastKnownContactPlaneValid) + { + body.ContactPlaneValid = true; + body.ContactPlane = ci.LastKnownContactPlane; + body.ContactPlaneCellId = ci.LastKnownContactPlaneCellId; + body.ContactPlaneIsWater = ci.LastKnownContactPlaneIsWater; + } + else + { + body.ContactPlaneValid = false; + } + + // Publish frames_stationary_fall + carry it to the next frame via the Stationary* + // transient bits. Retail encodes these bits in handle_all_collisions (pc:282737-758); + // acdream co-locates the encode with the fsf writeback here (STRUCTURAL ADAPTATION, + // register) so the round-trip (seed→ladder→writeback→seed) is self-contained in Core. + // handle_all_collisions (PhysicsObjUpdate) then only READS body.FramesStationaryFall. + body.FramesStationaryFall = ci.FramesStationaryFall; + body.TransientState &= ~(TransientStateFlags.StationaryFall + | TransientStateFlags.StationaryStop + | TransientStateFlags.StationaryStuck); + body.TransientState |= ci.FramesStationaryFall switch + { + 1 => TransientStateFlags.StationaryFall, + 2 => TransientStateFlags.StationaryStop, + 3 => TransientStateFlags.StationaryStuck, + _ => TransientStateFlags.None, + }; + + if (sp.HasLastWalkablePolygon && sp.LastWalkableVertices is not null) + { + body.WalkablePolygonValid = true; + body.WalkablePlane = sp.LastWalkablePlane; + body.SetWalkableVerticesExact(sp.LastWalkableVertices); + body.WalkableUp = sp.LastWalkableUp; + } + else if (!isOnGround && !ci.ContactPlaneValid && !ci.LastKnownContactPlaneValid) + { + body.WalkablePolygonValid = false; + body.WalkableVertices = null; + } + + // Retail persists sliding state to the body ONLY on transition + // SUCCESS: CPhysicsObj::SetPositionInternal copies the normal at + // 0x005154c2 and syncs SLIDING_TS (bit 4) from the transition's + // final sliding_normal_valid at 0x005154e1 — and SetPositionInternal + // is unreachable when find_valid_position fails (the transition is + // discarded whole; the body keeps its prior state). #137 mechanism + // 2: an unconditional writeback here could persist a normal retail + // would discard. + 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.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity. + // Phase 3's reset path sets VelocityKilled when an airborne hit + // can't find a walkable surface (steep roof, wall) AND the + // body had a last_known_contact_plane (i.e., was grounded + // recently). Retail zeros all three velocity components so + // gravity restarts cleanly next frame. + // + // Named-retail: OBJECTINFO::kill_velocity → CPhysicsObj::set_velocity({0,0,0}, 0) + // acclient_2013_pseudo_c.txt:274467-274475 + // Called from CTransition::transitional_insert reset path: + // acclient_2013_pseudo_c.txt:273237 (Phase 3) + // acclient_2013_pseudo_c.txt:272567 (validate_transition) + if (transition.ObjectInfo.VelocityKilled) + { + if (PhysicsDiagnostics.DumpSteepRoofEnabled) + Console.WriteLine($"[steep-roof] KILL-VELOCITY-APPLIED Vbefore=({body.Velocity.X:F2},{body.Velocity.Y:F2},{body.Velocity.Z:F2}) → 0,0,0"); + body.Velocity = Vector3.Zero; + } + } + + // 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; + + // #42 diagnostic (2026-05-05): trace airborne sweeps to identify the + // source of the ~1m XY drift on retail-observed stationary jumps. + // Gated on ACDREAM_AIRBORNE_DIAG=1 and !isOnGround. One line per + // resolve call. deltaXY = post - target tells us how much the sweep + // diverged from the requested target; for a clean stationary +Z + // jump we expect (0,0). cp=valid with a tilted normal would confirm + // H1 (initial-overlap depenetration → next-step AdjustOffset projects + // the +Z offset along a non-+Z normal). User repros at flat plaza / + // east hillside / north hillside; if drift direction tracks terrain + // orientation, H1 is the cause; if it tracks actor facing, H2 / H3. + if (!isOnGround + && Environment.GetEnvironmentVariable("ACDREAM_AIRBORNE_DIAG") == "1") + { + var post = sp.CheckPos; + float dx = post.X - targetPos.X; + float dy = post.Y - targetPos.Y; + string cpInfo = ci.ContactPlaneValid + ? $"valid cpN=({ci.ContactPlane.Normal.X:F3},{ci.ContactPlane.Normal.Y:F3},{ci.ContactPlane.Normal.Z:F3})" + : "none"; + Console.WriteLine( + $"[SWEEP] airborne pre=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) " + + $"target=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) " + + $"post=({post.X:F3},{post.Y:F3},{post.Z:F3}) " + + $"cell={cellId:X8}->{sp.CheckCellId:X8} ok={ok} " + + $"deltaXY=({dx:F3},{dy:F3}) cp={cpInfo}"); + } + + // L.2a slice 1 (2026-05-12): general-purpose resolver probe. + // One line per call when PhysicsDiagnostics.ProbeResolveEnabled + // is set (env var ACDREAM_PROBE_RESOLVE=1 at startup, or the + // DebugPanel checkbox flipped at runtime). Captures every + // dimension L.2 cares about: input/output position, input/output + // cell, ok-vs-partial, grounded-in vs contact-out, contact-plane + // status, wall normal if hit, walkable polygon valid. Zero cost + // when off (one static-bool read). + if (PhysicsDiagnostics.ProbeResolveEnabled) + { + var probePost = sp.CheckPos; + string probeCp = ci.ContactPlaneValid + ? "valid" + : (ci.LastKnownContactPlaneValid ? "lastKnown" : "none"); + string probeHit; + if (collisionNormalValid) + { + // L.2a slice 2 (2026-05-12): include the hit object's guid + + // environment flag so we can tell whether the wall is a building + // (CBuildingObj), a door (CC0Cxxxx range), an NPC, or terrain. + // Without this we know the wall normal but not the responsible + // entity — half the L.2d sub-direction call. + string objPart = ci.LastCollidedObjectGuid.HasValue + ? System.FormattableString.Invariant( + $" obj=0x{ci.LastCollidedObjectGuid.Value:X8}") + : ""; + string envPart = ci.CollidedWithEnvironment ? " env" : ""; + int objCount = ci.CollideObjectGuids.Count; + string objCountPart = objCount > 1 + ? System.FormattableString.Invariant($" nObj={objCount}") + : ""; + probeHit = System.FormattableString.Invariant( + $"yes n=({collisionNormal.X:F2},{collisionNormal.Y:F2},{collisionNormal.Z:F2}){objPart}{envPart}{objCountPart}"); + } + else + { + probeHit = "no"; + } + Console.WriteLine(System.FormattableString.Invariant( + $"[resolve] ent=0x{movingEntityId:X8} in=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cell=0x{cellId:X8} tgt=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) out=({probePost.X:F3},{probePost.Y:F3},{probePost.Z:F3}) cell=0x{sp.CheckCellId:X8} ok={ok} groundedIn={isOnGround} cp={probeCp} hit={probeHit} walkable={sp.HasLastWalkablePolygon}")); + } + + // Phase W Stage 0 (2026-06-02): [cell-swept] probe — swept cell vs static-derived cell. + // Emits before the ResolveResult is built so it shows what BOTH paths would return. + // No ResolveCellId call here (it has a CellGraph.CurrCell side effect). No behavior change. + if (PhysicsDiagnostics.ProbeSweptEnabled) + { + Console.WriteLine(System.FormattableString.Invariant( + $"[cell-swept] ent=0x{movingEntityId:X8} ok={ok} inCell=0x{cellId:X8} curCell=0x{sp.CurCellId:X8} checkCell=0x{sp.CheckCellId:X8} curPos=({sp.CurPos.X:F3},{sp.CurPos.Y:F3},{sp.CurPos.Z:F3}) checkPos=({sp.CheckPos.X:F3},{sp.CheckPos.Y:F3},{sp.CheckPos.Z:F3})")); + } + + ResolveResult resolveResult; if (ok) { - if (ci.ContactPlaneValid) - { - body.ContactPlaneValid = true; - body.ContactPlane = ci.ContactPlane; - body.ContactPlaneCellId = ci.ContactPlaneCellId; - body.ContactPlaneIsWater = ci.ContactPlaneIsWater; - } - else if (ci.LastKnownContactPlaneValid) - { - body.ContactPlaneValid = true; - body.ContactPlane = ci.LastKnownContactPlane; - body.ContactPlaneCellId = ci.LastKnownContactPlaneCellId; - body.ContactPlaneIsWater = ci.LastKnownContactPlaneIsWater; - } - else - { - body.ContactPlaneValid = false; - } + bool inContact = ci.ContactPlaneValid; + bool onWalkable = PhysicsObjUpdate.IsWalkableContact( + inContact, + ci.ContactPlane.Normal); + bool onGround = inContact + || (transition.ObjectInfo.State & ObjectInfoState.OnWalkable) != 0; - // Publish frames_stationary_fall + carry it to the next frame via the Stationary* - // transient bits. Retail encodes these bits in handle_all_collisions (pc:282737-758); - // acdream co-locates the encode with the fsf writeback here (STRUCTURAL ADAPTATION, - // register) so the round-trip (seed→ladder→writeback→seed) is self-contained in Core. - // handle_all_collisions (PhysicsObjUpdate) then only READS body.FramesStationaryFall. - body.FramesStationaryFall = ci.FramesStationaryFall; - body.TransientState &= ~(TransientStateFlags.StationaryFall - | TransientStateFlags.StationaryStop - | TransientStateFlags.StationaryStuck); - body.TransientState |= ci.FramesStationaryFall switch - { - 1 => TransientStateFlags.StationaryFall, - 2 => TransientStateFlags.StationaryStop, - 3 => TransientStateFlags.StationaryStuck, - _ => TransientStateFlags.None, - }; - - 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; - } - - // Retail persists sliding state to the body ONLY on transition - // SUCCESS: CPhysicsObj::SetPositionInternal copies the normal at - // 0x005154c2 and syncs SLIDING_TS (bit 4) from the transition's - // final sliding_normal_valid at 0x005154e1 — and SetPositionInternal - // is unreachable when find_valid_position fails (the transition is - // discarded whole; the body keeps its prior state). #137 mechanism - // 2: an unconditional writeback here could persist a normal retail - // would discard. - 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.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity. - // Phase 3's reset path sets VelocityKilled when an airborne hit - // can't find a walkable surface (steep roof, wall) AND the - // body had a last_known_contact_plane (i.e., was grounded - // recently). Retail zeros all three velocity components so - // gravity restarts cleanly next frame. - // - // Named-retail: OBJECTINFO::kill_velocity → CPhysicsObj::set_velocity({0,0,0}, 0) - // acclient_2013_pseudo_c.txt:274467-274475 - // Called from CTransition::transitional_insert reset path: - // acclient_2013_pseudo_c.txt:273237 (Phase 3) - // acclient_2013_pseudo_c.txt:272567 (validate_transition) - if (transition.ObjectInfo.VelocityKilled) - { - if (PhysicsDiagnostics.DumpSteepRoofEnabled) - Console.WriteLine($"[steep-roof] KILL-VELOCITY-APPLIED Vbefore=({body.Velocity.X:F2},{body.Velocity.Y:F2},{body.Velocity.Z:F2}) → 0,0,0"); - body.Velocity = Vector3.Zero; - } - } - - // 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; - - // #42 diagnostic (2026-05-05): trace airborne sweeps to identify the - // source of the ~1m XY drift on retail-observed stationary jumps. - // Gated on ACDREAM_AIRBORNE_DIAG=1 and !isOnGround. One line per - // resolve call. deltaXY = post - target tells us how much the sweep - // diverged from the requested target; for a clean stationary +Z - // jump we expect (0,0). cp=valid with a tilted normal would confirm - // H1 (initial-overlap depenetration → next-step AdjustOffset projects - // the +Z offset along a non-+Z normal). User repros at flat plaza / - // east hillside / north hillside; if drift direction tracks terrain - // orientation, H1 is the cause; if it tracks actor facing, H2 / H3. - if (!isOnGround - && Environment.GetEnvironmentVariable("ACDREAM_AIRBORNE_DIAG") == "1") - { - var post = sp.CheckPos; - float dx = post.X - targetPos.X; - float dy = post.Y - targetPos.Y; - string cpInfo = ci.ContactPlaneValid - ? $"valid cpN=({ci.ContactPlane.Normal.X:F3},{ci.ContactPlane.Normal.Y:F3},{ci.ContactPlane.Normal.Z:F3})" - : "none"; - Console.WriteLine( - $"[SWEEP] airborne pre=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) " + - $"target=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) " + - $"post=({post.X:F3},{post.Y:F3},{post.Z:F3}) " + - $"cell={cellId:X8}->{sp.CheckCellId:X8} ok={ok} " + - $"deltaXY=({dx:F3},{dy:F3}) cp={cpInfo}"); - } - - // L.2a slice 1 (2026-05-12): general-purpose resolver probe. - // One line per call when PhysicsDiagnostics.ProbeResolveEnabled - // is set (env var ACDREAM_PROBE_RESOLVE=1 at startup, or the - // DebugPanel checkbox flipped at runtime). Captures every - // dimension L.2 cares about: input/output position, input/output - // cell, ok-vs-partial, grounded-in vs contact-out, contact-plane - // status, wall normal if hit, walkable polygon valid. Zero cost - // when off (one static-bool read). - if (PhysicsDiagnostics.ProbeResolveEnabled) - { - var probePost = sp.CheckPos; - string probeCp = ci.ContactPlaneValid - ? "valid" - : (ci.LastKnownContactPlaneValid ? "lastKnown" : "none"); - string probeHit; - if (collisionNormalValid) - { - // L.2a slice 2 (2026-05-12): include the hit object's guid + - // environment flag so we can tell whether the wall is a building - // (CBuildingObj), a door (CC0Cxxxx range), an NPC, or terrain. - // Without this we know the wall normal but not the responsible - // entity — half the L.2d sub-direction call. - string objPart = ci.LastCollidedObjectGuid.HasValue - ? System.FormattableString.Invariant( - $" obj=0x{ci.LastCollidedObjectGuid.Value:X8}") - : ""; - string envPart = ci.CollidedWithEnvironment ? " env" : ""; - int objCount = ci.CollideObjectGuids.Count; - string objCountPart = objCount > 1 - ? System.FormattableString.Invariant($" nObj={objCount}") - : ""; - probeHit = System.FormattableString.Invariant( - $"yes n=({collisionNormal.X:F2},{collisionNormal.Y:F2},{collisionNormal.Z:F2}){objPart}{envPart}{objCountPart}"); + resolveResult = new ResolveResult( + sp.CheckPos, + // Phase W Stage 1: return the transition's SWEPT cell (retail SetPositionInternal + // reads sphere_path.curr_cell), not a static re-derive from the resting origin. + // ValidateTransition advances sp.CurCellId only on accepted moves / reverts on + // blocks, so push-back or standing still cannot flip it. The render root + // (CellGraph.CurrCell) is NOT written here — this runs for EVERY entity; it is set + // from this id only by the player's UpdateCellId (see UpdatePlayerCurrCell). + sp.CurCellId, + onGround, + collisionNormalValid, + collisionNormal, + Orientation: sp.CurOrientation, + InContact: inContact, + OnWalkable: onWalkable); } else { - probeHit = "no"; + // Transition failed (e.g., stuck in corner, too many steps). + // Use whatever position the transition reached (partial movement) + // instead of falling back to the no-collision Resolve. + // If CheckPos hasn't moved from CurPos, the player stays put — + // this is correct behavior when completely blocked. + bool partialOnGround = ci.ContactPlaneValid + || (transition.ObjectInfo.State & ObjectInfoState.OnWalkable) != 0 + || isOnGround; + + uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId; + resolveResult = new ResolveResult( + sp.CheckPos, + // Phase W Stage 1: prefer the swept cell; fall back to partialCellId only when + // sp.CurCellId is zero (transition never advanced — teleport or physics reset). + // (Render root set by the player's UpdateCellId, not here — see UpdatePlayerCurrCell.) + sp.CurCellId != 0 ? sp.CurCellId : partialCellId, + partialOnGround, + collisionNormalValid, + collisionNormal, + Ok: false, + Orientation: sp.CurOrientation); // Render Residual A — the sweep failed (find_valid_position == 0) } - Console.WriteLine(System.FormattableString.Invariant( - $"[resolve] ent=0x{movingEntityId:X8} in=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cell=0x{cellId:X8} tgt=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) out=({probePost.X:F3},{probePost.Y:F3},{probePost.Z:F3}) cell=0x{sp.CheckCellId:X8} ok={ok} groundedIn={isOnGround} cp={probeCp} hit={probeHit} walkable={sp.HasLastWalkablePolygon}")); - } - // Phase W Stage 0 (2026-06-02): [cell-swept] probe — swept cell vs static-derived cell. - // Emits before the ResolveResult is built so it shows what BOTH paths would return. - // No ResolveCellId call here (it has a CellGraph.CurrCell side effect). No behavior change. - if (PhysicsDiagnostics.ProbeSweptEnabled) + // A6.P3 #98 capture: emit one JSON Lines record per player call, + // with bodyBefore snapshot (taken at method entry, before any + // engine mutation) + bodyAfter snapshot (taken now, after the + // engine wrote back the contact plane / walkable / sliding state + // to the body). Loaded by CellarUpTrajectoryReplayTests.cs. + if (captureEnabled) + { + PhysicsResolveCapture.LogCall( + new ResolveCallInputs( + CurrentPos: currentPos, + TargetPos: targetPos, + CellId: cellId, + SphereRadius: sphereRadius, + SphereHeight: sphereHeight, + StepUpHeight: stepUpHeight, + StepDownHeight: stepDownHeight, + IsOnGround: isOnGround, + MoverFlags: (uint)moverFlags, + MovingEntityId: movingEntityId), + bodyBeforeSnap, + new ResolveCallResult( + Position: resolveResult.Position, + CellId: resolveResult.CellId, + IsOnGround: resolveResult.IsOnGround, + CollisionNormalValid: resolveResult.CollisionNormalValid, + CollisionNormal: resolveResult.CollisionNormal), + body is not null ? PhysicsResolveCapture.Snapshot(body) : null); + } + + return resolveResult; + } + finally { - Console.WriteLine(System.FormattableString.Invariant( - $"[cell-swept] ent=0x{movingEntityId:X8} ok={ok} inCell=0x{cellId:X8} curCell=0x{sp.CurCellId:X8} checkCell=0x{sp.CheckCellId:X8} curPos=({sp.CurPos.X:F3},{sp.CurPos.Y:F3},{sp.CurPos.Z:F3}) checkPos=({sp.CheckPos.X:F3},{sp.CheckPos.Y:F3},{sp.CheckPos.Z:F3})")); + ReturnTransition(transition); } - - ResolveResult resolveResult; - if (ok) - { - bool inContact = ci.ContactPlaneValid; - bool onWalkable = PhysicsObjUpdate.IsWalkableContact( - inContact, - ci.ContactPlane.Normal); - bool onGround = inContact - || transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable); - - resolveResult = new ResolveResult( - sp.CheckPos, - // Phase W Stage 1: return the transition's SWEPT cell (retail SetPositionInternal - // reads sphere_path.curr_cell), not a static re-derive from the resting origin. - // ValidateTransition advances sp.CurCellId only on accepted moves / reverts on - // blocks, so push-back or standing still cannot flip it. The render root - // (CellGraph.CurrCell) is NOT written here — this runs for EVERY entity; it is set - // from this id only by the player's UpdateCellId (see UpdatePlayerCurrCell). - sp.CurCellId, - onGround, - collisionNormalValid, - collisionNormal, - Orientation: sp.CurOrientation, - InContact: inContact, - OnWalkable: onWalkable); - } - else - { - // Transition failed (e.g., stuck in corner, too many steps). - // Use whatever position the transition reached (partial movement) - // instead of falling back to the no-collision Resolve. - // If CheckPos hasn't moved from CurPos, the player stays put — - // this is correct behavior when completely blocked. - bool partialOnGround = ci.ContactPlaneValid - || transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable) - || isOnGround; - - uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId; - resolveResult = new ResolveResult( - sp.CheckPos, - // Phase W Stage 1: prefer the swept cell; fall back to partialCellId only when - // sp.CurCellId is zero (transition never advanced — teleport or physics reset). - // (Render root set by the player's UpdateCellId, not here — see UpdatePlayerCurrCell.) - sp.CurCellId != 0 ? sp.CurCellId : partialCellId, - partialOnGround, - collisionNormalValid, - collisionNormal, - Ok: false, - Orientation: sp.CurOrientation); // Render Residual A — the sweep failed (find_valid_position == 0) - } - - // A6.P3 #98 capture: emit one JSON Lines record per player call, - // with bodyBefore snapshot (taken at method entry, before any - // engine mutation) + bodyAfter snapshot (taken now, after the - // engine wrote back the contact plane / walkable / sliding state - // to the body). Loaded by CellarUpTrajectoryReplayTests.cs. - if (captureEnabled) - { - PhysicsResolveCapture.LogCall( - new ResolveCallInputs( - CurrentPos: currentPos, - TargetPos: targetPos, - CellId: cellId, - SphereRadius: sphereRadius, - SphereHeight: sphereHeight, - StepUpHeight: stepUpHeight, - StepDownHeight: stepDownHeight, - IsOnGround: isOnGround, - MoverFlags: (uint)moverFlags, - MovingEntityId: movingEntityId), - bodyBeforeSnap, - new ResolveCallResult( - Position: resolveResult.Position, - CellId: resolveResult.CellId, - IsOnGround: resolveResult.IsOnGround, - CollisionNormalValid: resolveResult.CollisionNormalValid, - CollisionNormal: resolveResult.CollisionNormal), - body is not null ? PhysicsResolveCapture.Snapshot(body) : null); - } - - return resolveResult; } /// @@ -1413,38 +1445,45 @@ public sealed class PhysicsEngine ObjectInfoState moverFlags = ObjectInfoState.None, uint movingEntityId = 0) { - var transition = new Transition(); - transition.ObjectInfo.StepUpHeight = stepUpHeight; - transition.ObjectInfo.StepDownHeight = stepDownHeight; - transition.ObjectInfo.StepDown = true; - transition.ObjectInfo.SelfEntityId = movingEntityId; - transition.ObjectInfo.State = moverFlags; - transition.SpherePath.InitPath( - position, position, cellId, sphereRadius, sphereHeight); - transition.SpherePath.InsertType = InsertType.Placement; + var transition = RentTransition(); + try + { + transition.ObjectInfo.StepUpHeight = stepUpHeight; + transition.ObjectInfo.StepDownHeight = stepDownHeight; + transition.ObjectInfo.StepDown = true; + transition.ObjectInfo.SelfEntityId = movingEntityId; + transition.ObjectInfo.State = moverFlags; + transition.SpherePath.InitPath( + position, position, cellId, sphereRadius, sphereHeight); + transition.SpherePath.InsertType = InsertType.Placement; - bool ok = transition.FindPlacementPos(this); - var sp = transition.SpherePath; - var ci = transition.CollisionInfo; - bool inContact = ci.ContactPlaneValid; - bool onWalkable = PhysicsObjUpdate.IsWalkableContact( - inContact, - ci.ContactPlane.Normal); - bool onGround = inContact - || transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable); + bool ok = transition.FindPlacementPos(this); + var sp = transition.SpherePath; + var ci = transition.CollisionInfo; + bool inContact = ci.ContactPlaneValid; + bool onWalkable = PhysicsObjUpdate.IsWalkableContact( + inContact, + ci.ContactPlane.Normal); + bool onGround = inContact + || (transition.ObjectInfo.State & ObjectInfoState.OnWalkable) != 0; - return new ResolveResult( - sp.CurPos, - sp.CurCellId != 0 ? sp.CurCellId : cellId, - onGround, - ci.CollisionNormalValid, - ci.CollisionNormal, - ok, - Orientation: sp.CurOrientation, - InContact: inContact, - OnWalkable: onWalkable, - ContactPlane: ci.ContactPlane, - ContactPlaneCellId: ci.ContactPlaneCellId, - ContactPlaneIsWater: ci.ContactPlaneIsWater); + return new ResolveResult( + sp.CurPos, + sp.CurCellId != 0 ? sp.CurCellId : cellId, + onGround, + ci.CollisionNormalValid, + ci.CollisionNormal, + ok, + Orientation: sp.CurOrientation, + InContact: inContact, + OnWalkable: onWalkable, + ContactPlane: ci.ContactPlane, + ContactPlaneCellId: ci.ContactPlaneCellId, + ContactPlaneIsWater: ci.ContactPlaneIsWater); + } + finally + { + ReturnTransition(transition); + } } } diff --git a/src/AcDream.Core/Physics/TerrainSurface.cs b/src/AcDream.Core/Physics/TerrainSurface.cs index 0aedfdde..d5f9e147 100644 --- a/src/AcDream.Core/Physics/TerrainSurface.cs +++ b/src/AcDream.Core/Physics/TerrainSurface.cs @@ -6,7 +6,28 @@ namespace AcDream.Core.Physics; public readonly record struct TerrainSurfacePolygon( float Z, Vector3 Normal, - Vector3[] Vertices); + TerrainTriangleVertices Vertices); + +/// +/// The terrain surface beneath one point is always exactly one triangle. +/// Keeping its three vertices inline avoids allocating two short arrays for +/// every transition substep while retaining the same vertex order and floats. +/// +public readonly record struct TerrainTriangleVertices( + Vector3 V0, + Vector3 V1, + Vector3 V2) +{ + public int Length => 3; + + public Vector3 this[int index] => index switch + { + 0 => V0, + 1 => V1, + 2 => V2, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; +} /// /// Outdoor terrain height resolver for a single landblock. Performs @@ -27,8 +48,8 @@ public sealed class TerrainSurface public const int CellsPerSide = 8; // 192 / 24 private readonly float[,] _z; // pre-resolved heights [x, y] - private readonly bool[,] _cornerIsWater; // per-VERTEX water flag [x, y] — SurfChar[(type >> 2) & 0x1F] - private readonly byte[,] _cellWaterType; // per-CELL 0=NotWater, 1=Partially, 2=Entirely [cx, cy] + private readonly bool[,] _cornerIsWater; // per-VERTEX water flag [x, y] — SurfChar[(type >> 2) & 0x1F] + private readonly byte[,] _cellWaterType; // per-CELL 0=NotWater, 1=Partially, 2=Entirely [cx, cy] private readonly uint _landblockX; private readonly uint _landblockY; @@ -49,8 +70,8 @@ public sealed class TerrainSurface // Pre-resolve all 81 heights so SampleZ is a pure lookup + lerp. _z = new float[HeightmapSide, HeightmapSide]; for (int x = 0; x < HeightmapSide; x++) - for (int y = 0; y < HeightmapSide; y++) - _z[x, y] = heightTable[heights[x * HeightmapSide + y]]; + for (int y = 0; y < HeightmapSide; y++) + _z[x, y] = heightTable[heights[x * HeightmapSide + y]]; // Per-vertex water flag. TerrainType lives in bits 2-6 of each // TerrainInfo byte; water is types 0x10-0x14 inclusive (per @@ -62,11 +83,11 @@ public sealed class TerrainSurface if (terrainTypes is not null && terrainTypes.Length >= 81) { for (int x = 0; x < HeightmapSide; x++) - for (int y = 0; y < HeightmapSide; y++) - { - int typeBits = (terrainTypes[x * HeightmapSide + y] >> 2) & 0x1F; - _cornerIsWater[x, y] = typeBits >= 0x10 && typeBits <= 0x14; - } + for (int y = 0; y < HeightmapSide; y++) + { + int typeBits = (terrainTypes[x * HeightmapSide + y] >> 2) & 0x1F; + _cornerIsWater[x, y] = typeBits >= 0x10 && typeBits <= 0x14; + } } // Per-cell water classification (mirrors ACE @@ -74,21 +95,21 @@ public sealed class TerrainSurface // vertex corners; count how many are water type. _cellWaterType = new byte[CellsPerSide, CellsPerSide]; for (int cx = 0; cx < CellsPerSide; cx++) - for (int cy = 0; cy < CellsPerSide; cy++) - { - int waterCorners = 0; - if (_cornerIsWater[cx, cy ]) waterCorners++; - if (_cornerIsWater[cx + 1, cy ]) waterCorners++; - if (_cornerIsWater[cx + 1, cy + 1]) waterCorners++; - if (_cornerIsWater[cx, cy + 1]) waterCorners++; - - _cellWaterType[cx, cy] = waterCorners switch + for (int cy = 0; cy < CellsPerSide; cy++) { - 0 => 0, // NotWater - 4 => 2, // EntirelyWater - _ => 1, // PartiallyWater - }; - } + int waterCorners = 0; + if (_cornerIsWater[cx, cy]) waterCorners++; + if (_cornerIsWater[cx + 1, cy]) waterCorners++; + if (_cornerIsWater[cx + 1, cy + 1]) waterCorners++; + if (_cornerIsWater[cx, cy + 1]) waterCorners++; + + _cellWaterType[cx, cy] = waterCorners switch + { + 0 => 0, // NotWater + 4 => 2, // EntirelyWater + _ => 1, // PartiallyWater + }; + } } /// @@ -134,10 +155,10 @@ public sealed class TerrainSurface float ty = fy - cy; // Four corner heights (BL=SW, BR=SE, TR=NE, TL=NW) - float hBL = _z[cx, cy ]; - float hBR = _z[cx + 1, 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]; + float hTL = _z[cx, cy + 1]; // Split direction — same formula as TerrainBlending.CalculateSplitDirection // and ACE's LandblockStruct.ConstructPolygons. @@ -189,10 +210,10 @@ public sealed class TerrainSurface // x-major heightmap indexing matches TerrainSurface's pre-resolution // (heights[x * 9 + y]) and ACE LandblockStruct. - float hBL = heightTable[heights[cx * HeightmapSide + cy ]]; - float hBR = heightTable[heights[(cx+1) * HeightmapSide + cy ]]; - float hTR = heightTable[heights[(cx+1) * HeightmapSide + (cy+1)]]; - float hTL = heightTable[heights[cx * HeightmapSide + (cy+1)]]; + float hBL = heightTable[heights[cx * HeightmapSide + cy]]; + float hBR = heightTable[heights[(cx + 1) * HeightmapSide + cy]]; + float hTR = heightTable[heights[(cx + 1) * HeightmapSide + (cy + 1)]]; + float hTL = heightTable[heights[cx * HeightmapSide + (cy + 1)]]; bool splitSWtoNE = IsSplitSWtoNE(landblockX, (uint)cx, landblockY, (uint)cy); return InterpolateZInTriangle(hBL, hBR, hTR, hTL, tx, ty, splitSWtoNE); @@ -227,10 +248,10 @@ public sealed class TerrainSurface float tx = fx - cx; float ty = fy - cy; - float hBL = heightTable[heights[cx * HeightmapSide + cy ]]; - float hBR = heightTable[heights[(cx+1) * HeightmapSide + cy ]]; - float hTR = heightTable[heights[(cx+1) * HeightmapSide + (cy+1)]]; - float hTL = heightTable[heights[cx * HeightmapSide + (cy+1)]]; + float hBL = heightTable[heights[cx * HeightmapSide + cy]]; + float hBR = heightTable[heights[(cx + 1) * HeightmapSide + cy]]; + float hTR = heightTable[heights[(cx + 1) * HeightmapSide + (cy + 1)]]; + float hTL = heightTable[heights[cx * HeightmapSide + (cy + 1)]]; bool splitSWtoNE = IsSplitSWtoNE(landblockX, (uint)cx, landblockY, (uint)cy); @@ -332,10 +353,10 @@ public sealed class TerrainSurface float tx = fx - cx; float ty = fy - cy; - float hBL = _z[cx, cy ]; - float hBR = _z[cx + 1, 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]; + float hTL = _z[cx, cy + 1]; bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy); @@ -352,14 +373,14 @@ public sealed class TerrainSurface if (tx > ty) { // {BL,BR,TR}: Z = hBL + (hBR-hBL)·tx + (hTR-hBR)·ty - z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty; + z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty; dzdx = (hBR - hBL) / CellSize; dzdy = (hTR - hBR) / CellSize; } else { // {BL,TR,TL}: Z = hBL + (hTR-hTL)·tx + (hTL-hBL)·ty - z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty; + z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty; dzdx = (hTR - hTL) / CellSize; dzdy = (hTL - hBL) / CellSize; } @@ -370,7 +391,7 @@ public sealed class TerrainSurface if (tx + ty <= 1f) { // {BL,BR,TL}: Z = hBL + (hBR-hBL)·tx + (hTL-hBL)·ty - z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty; + z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty; dzdx = (hBR - hBL) / CellSize; dzdy = (hTL - hBL) / CellSize; } @@ -378,7 +399,7 @@ public sealed class TerrainSurface { // {BR,TR,TL}: Z = hTR + (hTL-hTR)(1-tx) + (hBR-hTR)(1-ty) // Equivalent linear form: Z = [hBR+hTL-hTR] + (hTR-hTL)·tx + (hTR-hBR)·ty - z = hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); + z = hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); dzdx = (hTR - hTL) / CellSize; dzdy = (hTR - hBR) / CellSize; } @@ -405,32 +426,32 @@ public sealed class TerrainSurface float tx = fx - cx; float ty = fy - cy; - float hBL = _z[cx, cy ]; - float hBR = _z[cx + 1, 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]; + 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 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); + Vector3 tl = new(cx * CellSize, (cy + 1) * CellSize, hTL); float z; - Vector3[] vertices; + TerrainTriangleVertices vertices; if (splitSWtoNE) { if (tx > ty) { z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty; - vertices = new[] { bl, br, tr }; + vertices = new TerrainTriangleVertices(bl, br, tr); } else { z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty; - vertices = new[] { bl, tr, tl }; + vertices = new TerrainTriangleVertices(bl, tr, tl); } } else @@ -438,12 +459,12 @@ public sealed class TerrainSurface if (tx + ty <= 1f) { z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty; - vertices = new[] { bl, br, tl }; + vertices = new TerrainTriangleVertices(bl, br, tl); } else { z = hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); - vertices = new[] { br, tr, tl }; + vertices = new TerrainTriangleVertices(br, tr, tl); } } diff --git a/src/AcDream.Core/Physics/TransitionScratchArena.cs b/src/AcDream.Core/Physics/TransitionScratchArena.cs new file mode 100644 index 00000000..38776f04 --- /dev/null +++ b/src/AcDream.Core/Physics/TransitionScratchArena.cs @@ -0,0 +1,68 @@ +using System; +using System.Threading; + +namespace AcDream.Core.Physics; + +/// +/// Stack-disciplined reusable transition scratch owned by one +/// . +/// +/// Retail CTransition::makeTransition (0x0050B150) leases one of ten +/// pre-existing records by nesting depth, calls CTransition::init, and +/// releases it through CTransition::cleanupTransition (0x00509DC0). +/// This arena keeps the same ten-deep, LIFO, same-thread contract while +/// lazily constructing records that are actually used. +/// +internal sealed class TransitionScratchArena +{ + internal const int Capacity = 10; + + private readonly Transition?[] _records = new Transition?[Capacity]; + private int _depth; + private int _ownerThreadId; + + internal int ActiveDepth => _depth; + + internal Transition Rent() + { + int threadId = Environment.CurrentManagedThreadId; + if (_depth != 0 && _ownerThreadId != threadId) + { + throw new InvalidOperationException( + "Physics transition scratch cannot be shared across threads."); + } + + if (_depth >= Capacity) + { + throw new InvalidOperationException( + $"Physics transition nesting exceeds retail's {Capacity}-record scratch arena."); + } + + if (_depth == 0) + _ownerThreadId = threadId; + + Transition transition = _records[_depth] ??= new Transition(); + _depth++; + transition.ResetForReuse(); + return transition; + } + + internal void Return(Transition transition) + { + ArgumentNullException.ThrowIfNull(transition); + + int threadId = Environment.CurrentManagedThreadId; + int index = _depth - 1; + if (index < 0 + || _ownerThreadId != threadId + || !ReferenceEquals(_records[index], transition)) + { + throw new InvalidOperationException( + "Physics transition scratch must be returned in LIFO order on its owning thread."); + } + + _depth = index; + if (_depth == 0) + _ownerThreadId = 0; + } +} diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index c161d681..64e56d78 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -6,41 +6,41 @@ namespace AcDream.Core.Physics; public enum TransitionState { - Invalid = 0, - OK = 1, + Invalid = 0, + OK = 1, Collided = 2, Adjusted = 3, - Slid = 4, + Slid = 4, } public enum InsertType { - Transition = 0, - Placement = 1, + Transition = 0, + Placement = 1, InitialPlacement = 2, } [Flags] public enum ObjectInfoState : uint { - None = 0x000, - Contact = 0x001, - OnWalkable = 0x002, - IsViewer = 0x004, - PathClipped = 0x008, - FreeRotate = 0x010, - PerfectClip = 0x040, - IsImpenetrable = 0x080, - IsPlayer = 0x100, - EdgeSlide = 0x200, + None = 0x000, + Contact = 0x001, + OnWalkable = 0x002, + IsViewer = 0x004, + PathClipped = 0x008, + FreeRotate = 0x010, + PerfectClip = 0x040, + IsImpenetrable = 0x080, + IsPlayer = 0x100, + EdgeSlide = 0x200, IgnoreCreatures = 0x400, /// Bits added 2026-04-29 (Commit A live-entity collision): /// the moving object's PK status drives the player-vs-player /// exemption block in FindObjCollisions per /// acclient_2013_pseudo_c.txt:276807-276839. Values match /// retail's OBJECTINFO::state bits (acclient.h:6190-6194). - IsPK = 0x800, - IsPKLite = 0x1000, + IsPK = 0x800, + IsPKLite = 0x1000, } /// @@ -50,7 +50,7 @@ public enum ObjectInfoState : uint public sealed class ObjectInfo { public ObjectInfoState State; - public float StepUpHeight = 0.01f; // PhysicsGlobals.DefaultStepHeight + public float StepUpHeight = 0.01f; // PhysicsGlobals.DefaultStepHeight public float StepDownHeight = 0.04f; public bool Ethereal; public bool StepDown = true; @@ -89,13 +89,13 @@ public sealed class ObjectInfo public bool MoverHasGravity; // Convenience flag checks - public bool Contact => State.HasFlag(ObjectInfoState.Contact); - public bool OnWalkable => State.HasFlag(ObjectInfoState.OnWalkable); - public bool IsViewer => State.HasFlag(ObjectInfoState.IsViewer); - public bool IsPlayer => State.HasFlag(ObjectInfoState.IsPlayer); - public bool EdgeSlide => State.HasFlag(ObjectInfoState.EdgeSlide); - public bool PathClipped => State.HasFlag(ObjectInfoState.PathClipped); - public bool FreeRotate => State.HasFlag(ObjectInfoState.FreeRotate); + public bool Contact => (State & ObjectInfoState.Contact) != 0; + public bool OnWalkable => (State & ObjectInfoState.OnWalkable) != 0; + public bool IsViewer => (State & ObjectInfoState.IsViewer) != 0; + public bool IsPlayer => (State & ObjectInfoState.IsPlayer) != 0; + public bool EdgeSlide => (State & ObjectInfoState.EdgeSlide) != 0; + public bool PathClipped => (State & ObjectInfoState.PathClipped) != 0; + public bool FreeRotate => (State & ObjectInfoState.FreeRotate) != 0; /// /// Verbatim decision tree from retail OBJECTINFO::missile_ignore @@ -107,20 +107,20 @@ public sealed class ObjectInfo EntityCollisionFlags targetFlags) { var targetState = (PhysicsStateFlags)targetPhysicsState; - if (targetState.HasFlag(PhysicsStateFlags.Missile)) + if ((targetState & PhysicsStateFlags.Missile) != 0) return true; - if (!MoverPhysicsState.HasFlag(PhysicsStateFlags.Missile)) + if ((MoverPhysicsState & PhysicsStateFlags.Missile) == 0) return false; if (targetEntityId == TargetId) return false; - bool hasWeenie = targetFlags.HasFlag(EntityCollisionFlags.HasWeenie); - if (targetState.HasFlag(PhysicsStateFlags.Ethereal) && hasWeenie) + bool hasWeenie = (targetFlags & EntityCollisionFlags.HasWeenie) != 0; + if ((targetState & PhysicsStateFlags.Ethereal) != 0 && hasWeenie) return true; return TargetId != 0 && hasWeenie - && targetFlags.HasFlag(EntityCollisionFlags.IsCreature); + && (targetFlags & EntityCollisionFlags.IsCreature) != 0; } /// @@ -162,6 +162,26 @@ public sealed class ObjectInfo /// /// public void StopVelocity() { VelocityKilled = true; } + + /// + /// Restores the logical state of this reusable transition record. + /// Retail performs the equivalent reset in OBJECTINFO::init while + /// retaining the surrounding CTransition storage. + /// + public void ResetForReuse() + { + State = ObjectInfoState.None; + StepUpHeight = PhysicsGlobals.DefaultStepHeight; + StepDownHeight = 0.04f; + Ethereal = false; + StepDown = true; + Scale = 1.0f; + MoverPhysicsState = PhysicsStateFlags.None; + TargetId = 0; + SelfEntityId = 0; + MoverHasGravity = false; + VelocityKilled = false; + } } /// @@ -290,7 +310,7 @@ public sealed class CollisionInfo public int FramesStationaryFall; public Vector3 AdjustOffset; - public List CollideObjectGuids = new(); + public readonly List CollideObjectGuids = new(); public uint? LastCollidedObjectGuid; /// @@ -350,20 +370,92 @@ public sealed class CollisionInfo CollisionNormalValid = true; CollisionNormal = normal; } + + /// + /// Retail COLLISIONINFO::init reset for a retained transition + /// record. Backing fields are written directly so diagnostic setters do + /// not report initialization as gameplay state mutation. + /// + public void ResetForReuse() + { + _contactPlaneValid = false; + _contactPlane = default; + _contactPlaneCellId = 0; + _contactPlaneIsWater = false; + _lastKnownContactPlaneValid = false; + _lastKnownContactPlane = default; + _lastKnownContactPlaneCellId = 0; + _lastKnownContactPlaneIsWater = false; + + SlidingNormalValid = false; + SlidingNormal = Vector3.Zero; + CollisionNormalValid = false; + CollisionNormal = Vector3.Zero; + CollidedWithEnvironment = false; + FramesStationaryFall = 0; + AdjustOffset = Vector3.Zero; + CollideObjectGuids.Clear(); + LastCollidedObjectGuid = null; + ContactPlaneWriteCount = 0; + } } /// -/// Movement path descriptor — tracks sphere positions in multiple -/// coordinate frames during collision resolution. -/// ACE: SpherePath. +/// Retained, stack-disciplined ordering buffers for reentrant neighboring-cell +/// checks. A cell collision can enter another cell check before the outer scan +/// resumes, so each active depth needs an independent list. +/// +internal sealed class CellOrderScratchArena +{ + private readonly List> _records = new(); + private int _depth; + + internal int ActiveDepth => _depth; + internal int RetainedRecordCount => _records.Count; + internal IReadOnlyList> RetainedRecords => _records; + + internal List Rent() + { + if (_depth == _records.Count) + _records.Add(new List()); + + List record = _records[_depth++]; + record.Clear(); + return record; + } + + internal void Return(List record) + { + int index = _depth - 1; + if (index < 0 || !ReferenceEquals(_records[index], record)) + { + throw new InvalidOperationException( + "Cell-order scratch must be returned in LIFO order."); + } + + record.Clear(); + _depth = index; + } + + internal void ResetForReuse() + { + foreach (List record in _records) + record.Clear(); + _depth = 0; + } +} + +/// +/// Movement path descriptor; tracks sphere positions in multiple coordinate +/// frames during collision resolution. ACE: SpherePath. /// public sealed class SpherePath { public int NumSphere = 1; // Sphere arrays — index 0 = foot/body, index 1 = head (when NumSphere==2) - public readonly Sphere[] LocalSphere = new Sphere[2] { new(), new() }; - public readonly Sphere[] GlobalSphere = new Sphere[2] { new(), new() }; + public readonly Sphere[] LocalSphere = new Sphere[2] { new(), new() }; + public readonly Sphere[] GlobalSphere = new Sphere[2] { new(), new() }; public readonly Sphere[] GlobalCurrCenter = new Sphere[2] { new(), new() }; // Positions @@ -372,8 +464,8 @@ public sealed class SpherePath public Vector3 CurPos; public Vector3 CheckPos; public Quaternion BeginOrientation = Quaternion.Identity; - public Quaternion EndOrientation = Quaternion.Identity; - public Quaternion CurOrientation = Quaternion.Identity; + public Quaternion EndOrientation = Quaternion.Identity; + public Quaternion CurOrientation = Quaternion.Identity; public Quaternion CheckOrientation = Quaternion.Identity; // Cell tracking @@ -474,6 +566,18 @@ public sealed class SpherePath /// public bool HitsInteriorCell; + // The logical walkable references above are cleared by ResetForReuse. + // These private exact-length buffers retain only storage identity, matching + // retail's embedded transition scratch without exposing stale vertices to + // polygon math. + private Vector3[]? _walkableVertexStorage; + private Vector3[]? _lastWalkableVertexStorage; + internal readonly CellArray CellCandidates = new(); + internal readonly CellOrderScratchArena OrderedCellScratch = new(); + + internal Vector3[]? RetainedWalkableVertexStorage => _walkableVertexStorage; + internal Vector3[]? RetainedLastWalkableVertexStorage => _lastWalkableVertexStorage; + public void SetCheckPos(Vector3 pos, uint cellId) { CheckPos = pos; @@ -513,24 +617,84 @@ public sealed class SpherePath /// public void SetCollide(Vector3 collisionNormal) { - Collide = true; - BackupCheckPos = CheckPos; + Collide = true; + BackupCheckPos = CheckPos; BackupCheckCellId = CheckCellId; - StepUpNormal = collisionNormal; - WalkInterp = 1.0f; + StepUpNormal = collisionNormal; + WalkInterp = 1.0f; } public void SetWalkable(Plane plane, Vector3[] vertices, Vector3 up) { + ArgumentNullException.ThrowIfNull(vertices); + WalkableValid = true; WalkablePlane = plane; - WalkableVertices = (Vector3[])vertices.Clone(); + WalkableVertices = CopyExact(vertices, ref _walkableVertexStorage); WalkableUp = up; WalkableAllowance = PhysicsGlobals.FloorZ; LastWalkableValid = true; LastWalkablePlane = plane; - LastWalkableVertices = (Vector3[])vertices.Clone(); + LastWalkableVertices = CopyExact(vertices, ref _lastWalkableVertexStorage); + LastWalkableUp = up; + } + + internal void SetWalkable( + Plane plane, + in TerrainTriangleVertices vertices, + Vector3 up) + { + Vector3[] walkable = RentExact(3, ref _walkableVertexStorage); + Vector3[] last = RentExact(3, ref _lastWalkableVertexStorage); + walkable[0] = last[0] = vertices.V0; + walkable[1] = last[1] = vertices.V1; + walkable[2] = last[2] = vertices.V2; + + WalkableValid = true; + WalkablePlane = plane; + WalkableVertices = walkable; + WalkableUp = up; + WalkableAllowance = PhysicsGlobals.FloorZ; + LastWalkableValid = true; + LastWalkablePlane = plane; + LastWalkableVertices = last; + LastWalkableUp = up; + } + + /// + /// Copies local polygon vertices directly into the retained exact-length + /// world-space buffers. This removes the former temporary transformed + /// array while preserving the same multiplication and addition order. + /// + internal void SetWalkableTransformed( + Plane plane, + ReadOnlySpan localVertices, + Quaternion localToWorld, + float scale, + Vector3 worldOrigin, + Vector3 up) + { + Vector3[] walkable = RentExact(localVertices.Length, ref _walkableVertexStorage); + Vector3[] last = RentExact(localVertices.Length, ref _lastWalkableVertexStorage); + + for (int i = 0; i < localVertices.Length; i++) + { + Vector3 transformed = + Vector3.Transform(localVertices[i] * scale, localToWorld) + worldOrigin; + walkable[i] = transformed; + last[i] = transformed; + } + + WalkableValid = true; + WalkablePlane = plane; + WalkableVertices = walkable; + WalkableUp = up; + WalkableAllowance = PhysicsGlobals.FloorZ; + + LastWalkableValid = true; + LastWalkablePlane = plane; + LastWalkableVertices = last; LastWalkableUp = up; } @@ -547,11 +711,95 @@ public sealed class SpherePath WalkableValid = true; WalkablePlane = LastWalkablePlane; - WalkableVertices = (Vector3[])LastWalkableVertices.Clone(); + WalkableVertices = CopyExact(LastWalkableVertices, ref _walkableVertexStorage); WalkableUp = LastWalkableUp; return true; } + /// + /// Retail SPHEREPATH::init reset for a retained transition record. + /// Every logical value is restored; only the two private exact-length + /// vertex buffers and the fixed sphere object identities survive. + /// + public void ResetForReuse() + { + NumSphere = 1; + ResetSphereArray(LocalSphere); + ResetSphereArray(GlobalSphere); + ResetSphereArray(GlobalCurrCenter); + + BeginPos = Vector3.Zero; + EndPos = Vector3.Zero; + CurPos = Vector3.Zero; + CheckPos = Vector3.Zero; + BeginOrientation = Quaternion.Identity; + EndOrientation = Quaternion.Identity; + CurOrientation = Quaternion.Identity; + CheckOrientation = Quaternion.Identity; + CurCellId = 0; + CheckCellId = 0; + CarriedBlockOrigin = null; + GlobalOffset = Vector3.Zero; + StepUp = false; + StepUpNormal = Vector3.Zero; + Collide = false; + StepDown = false; + StepDownAmt = 0f; + WalkInterp = 1.0f; + WalkableValid = false; + WalkablePlane = default; + WalkableVertices = null; + WalkableUp = Vector3.UnitZ; + WalkableAllowance = PhysicsGlobals.FloorZ; + LastWalkableValid = false; + LastWalkablePlane = default; + LastWalkableVertices = null; + LastWalkableUp = Vector3.UnitZ; + BackupCheckPos = Vector3.Zero; + BackupCheckCellId = 0; + NegPolyHit = false; + NegStepUp = false; + NegCollisionNormal = Vector3.Zero; + CheckWalkable = false; + InsertType = InsertType.Transition; + PlacementAllowsSliding = true; + ObstructionEthereal = false; + BldgCheck = false; + HitsInteriorCell = false; + if (_walkableVertexStorage is not null) + System.Array.Clear(_walkableVertexStorage); + if (_lastWalkableVertexStorage is not null) + System.Array.Clear(_lastWalkableVertexStorage); + CellCandidates.Clear(); + OrderedCellScratch.ResetForReuse(); + } + + private static Vector3[] CopyExact( + ReadOnlySpan source, + ref Vector3[]? storage) + { + Vector3[] destination = RentExact(source.Length, ref storage); + source.CopyTo(destination); + return destination; + } + + private static Vector3[] RentExact(int length, ref Vector3[]? storage) + { + if (storage is null || storage.Length != length) + storage = new Vector3[length]; + + return storage; + } + + private static void ResetSphereArray(Sphere[] spheres) + { + for (int i = 0; i < spheres.Length; i++) + { + spheres[i].Origin = Vector3.Zero; + spheres[i].Radius = 0f; + } + } + /// /// Slide fallback when step-up fails. Clears the contact-plane state that /// caused the step-up attempt and runs the full sphere-slide computation @@ -574,7 +822,7 @@ public sealed class SpherePath public TransitionState StepUpSlide(Transition transition) { var ci = transition.CollisionInfo; - ci.ContactPlaneValid = false; + ci.ContactPlaneValid = false; ci.ContactPlaneIsWater = false; return transition.SlideSphereInternal(StepUpNormal, GlobalCurrCenter[0].Origin); } @@ -668,12 +916,12 @@ public sealed class SpherePath /// public static class PhysicsGlobals { - public const float EPSILON = 0.0002f; - public const float EpsilonSq = EPSILON * EPSILON; - public const float LandingZ = 0.0871557f; - public const float FloorZ = 0.6642f; + public const float EPSILON = 0.0002f; + public const float EpsilonSq = EPSILON * EPSILON; + public const float LandingZ = 0.0871557f; + public const float FloorZ = 0.6642f; public const float DefaultStepHeight = 0.01f; - public const float Gravity = -9.8f; + public const float Gravity = -9.8f; public const float MaxVelocity = 50.0f; public const float DummySphereRadius = 0.1f; } @@ -693,9 +941,22 @@ public static class PhysicsGlobals /// public sealed class Transition { - public ObjectInfo ObjectInfo = new(); - public SpherePath SpherePath = new(); - public CollisionInfo CollisionInfo = new(); + public readonly ObjectInfo ObjectInfo = new(); + public readonly SpherePath SpherePath = new(); + public readonly CollisionInfo CollisionInfo = new(); + + /// + /// Restores this retained record to the state of a freshly constructed + /// transition. Retail CTransition::makeTransition calls + /// CTransition::init on its stack-disciplined scratch record before + /// each lease. + /// + public void ResetForReuse() + { + ObjectInfo.ResetForReuse(); + SpherePath.ResetForReuse(); + CollisionInfo.ResetForReuse(); + } private static bool DumpEdgeSlideEnabled => Environment.GetEnvironmentVariable("ACDREAM_DUMP_EDGE_SLIDE") == "1"; @@ -788,15 +1049,15 @@ public sealed class Transition // - non-viewer: equal slices of ~one radius (TRANSITIONAL_PERCENT_OF_ // RADIUS = 1.0 at 0x007c6874) — unchanged, matches retail. // ------------------------------------------------------------------ - Vector3 offset = sp.EndPos - sp.BeginPos; - float dist = offset.Length(); - float radius = sp.LocalSphere[0].Radius; + Vector3 offset = sp.EndPos - sp.BeginPos; + float dist = offset.Length(); + float radius = sp.LocalSphere[0].Radius; // Guard: zero-radius sphere would cause a div-by-zero. if (radius <= PhysicsGlobals.EPSILON) return false; - int numSteps; + int numSteps; Vector3 offsetPerStep; if (ObjectInfo.IsViewer) @@ -804,11 +1065,11 @@ public sealed class Transition if (dist > PhysicsGlobals.EPSILON) { offsetPerStep = offset * (radius / dist); // radius-length steps - numSteps = (int)MathF.Floor(dist / radius) + 1; + numSteps = (int)MathF.Floor(dist / radius) + 1; } else { - numSteps = 0; + numSteps = 0; offsetPerStep = Vector3.Zero; } } @@ -818,17 +1079,17 @@ public sealed class Transition if (step > 1.0f) { - numSteps = (int)MathF.Ceiling(step); + numSteps = (int)MathF.Ceiling(step); offsetPerStep = offset * (1f / numSteps); } else if (offset != Vector3.Zero) { - numSteps = 1; + numSteps = 1; offsetPerStep = offset; } else { - numSteps = 0; + numSteps = 0; offsetPerStep = Vector3.Zero; } } @@ -920,8 +1181,8 @@ public sealed class Transition // Clear collision state AFTER AdjustOffset reads it. TransitionalInsert // will set new state that the next step's AdjustOffset will consume. - CollisionInfo.SlidingNormalValid = false; - CollisionInfo.ContactPlaneValid = false; + CollisionInfo.SlidingNormalValid = false; + CollisionInfo.ContactPlaneValid = false; CollisionInfo.ContactPlaneIsWater = false; // Apply the offset, then check collisions. @@ -1087,7 +1348,7 @@ public sealed class Transition { // COLLISIONINFO::init at retail 0x0050B052. Placement probes are // independent; a failed compass sample must not bias the next one. - CollisionInfo = new CollisionInfo(); + CollisionInfo.ResetForReuse(); } return transitionState; @@ -1128,7 +1389,7 @@ public sealed class Transition private TransitionState TransitionalInsert(int numAttempts, PhysicsEngine engine) { if (SpherePath.CheckCellId == 0) return TransitionState.OK; - if (numAttempts <= 0) return TransitionState.Invalid; + if (numAttempts <= 0) return TransitionState.Invalid; var sp = SpherePath; var ci = CollisionInfo; @@ -1150,9 +1411,9 @@ public sealed class Transition { // Env collision slid the sphere. Clear state and retry at // the new CheckPos to see if we hit anything else. - ci.ContactPlaneValid = false; + ci.ContactPlaneValid = false; ci.ContactPlaneIsWater = false; - sp.NegPolyHit = false; + sp.NegPolyHit = false; continue; } @@ -1176,9 +1437,9 @@ public sealed class Transition if (bldgState == TransitionState.Slid) { - ci.ContactPlaneValid = false; + ci.ContactPlaneValid = false; ci.ContactPlaneIsWater = false; - sp.NegPolyHit = false; + sp.NegPolyHit = false; continue; } @@ -1205,9 +1466,9 @@ public sealed class Transition // Object collision applied a push-out and set sliding normal. // Retry at the new CheckPos — we may have slid into another // object, or need to re-verify env at the new position. - ci.ContactPlaneValid = false; + ci.ContactPlaneValid = false; ci.ContactPlaneIsWater = false; - sp.NegPolyHit = false; + sp.NegPolyHit = false; continue; } @@ -1254,7 +1515,7 @@ public sealed class Transition { // CheckPos is walkable — re-test as Placement to snap/validate. var savedInsert = sp.InsertType; - sp.InsertType = InsertType.Placement; + sp.InsertType = InsertType.Placement; var placeState = TransitionalInsert(numAttempts, engine); @@ -1281,7 +1542,7 @@ public sealed class Transition if (reset) { sp.RestoreCheckPos(); - ci.ContactPlaneValid = false; + ci.ContactPlaneValid = false; ci.ContactPlaneIsWater = false; bool diagSteep = PhysicsDiagnostics.DumpSteepRoofEnabled; @@ -1377,7 +1638,7 @@ public sealed class Transition var stepUpSlideRes = sp.StepUpSlide(this); if (stepUpSlideRes == TransitionState.Slid) { - ci.ContactPlaneValid = false; + ci.ContactPlaneValid = false; ci.ContactPlaneIsWater = false; continue; } @@ -1453,9 +1714,9 @@ public sealed class Transition // 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; + float zVal = oi.GetWalkableZ(); + float stepDownHeight = oi.StepDownHeight; + sp.WalkableAllowance = zVal; sp.SaveCheckPos(); float radsum = sp.GlobalSphere[0].Radius * 2f; @@ -1850,15 +2111,6 @@ public sealed class Transition if (cellPhysics.BSP?.Root is null) return false; - // Build foot sphere in cell-local space. Caller passes localFootCenter - // already transformed into cell-local space and the resolver's - // foot-sphere radius. - var localSphere = new DatReaderWriter.Types.Sphere - { - Origin = localFootCenter, - Radius = sphereRadius, - }; - // Save/restore WalkableAllowance: CPolygon::walkable_hits_sphere reads // path.WalkableAllowance (acclient_2013_pseudo_c.txt:323010). For // "standing here, find my floor" we want the walkability slope @@ -1880,7 +2132,8 @@ public sealed class Transition cellPhysics.BSP.Root, cellPhysics.Resolved, this, - localSphere, + localFootCenter, + sphereRadius, INDOOR_WALKABLE_PROBE_DISTANCE, Vector3.UnitZ, // local Z is up for indoor cells (identity transform) out hitPoly, @@ -1957,171 +2210,186 @@ public sealed class Transition // Deterministic order for greppable probe logs. Skip the primary // cell — caller has already run its BSP. - var ordered = new System.Collections.Generic.List(cellSet); - ordered.Sort(); - - foreach (uint cellId in ordered) + List ordered = sp.OrderedCellScratch.Rent(); + try { - if (cellId == sp.CheckCellId) continue; - - // #137 seam shake (2026-07-06): a mid-loop query can MOVE the - // sphere — a Path-5 full hit dispatches step_sphere_up, and a - // successful climb lifts the foot (the 0.6 mm ramp-slab lift at - // the Facility Hub boundaries) yet returns OK, so the loop - // continues. Retail's check_other_cells reads the LIVE - // sphere_path.global_sphere for every cell (each cell's - // find_collisions receives the transition itself, pc:272717+); - // querying the remaining cells with the pre-climb center re-tests - // the boundary ~0.4 mm inside the floor slab and grazes the - // under-room's CEILING (the slab underside) — the neg-poly - // step-up-with-a-downward-normal chain that shook the player at - // every corridor seam. Same lesson as the P2 cellar-lip fix - // (RunCheckOtherCellsAndAdvance's entry refresh), one loop deeper. - footCenter = sp.GlobalSphere[0].Origin; - - if ((cellId & 0xFFFFu) < 0x0100u) + if (cellSet is CellArray cellArray) { - var terrainWalkable = engine.SampleTerrainWalkable(footCenter.X, footCenter.Y); - if (terrainWalkable is null || terrainWalkable.Value.CellId != cellId) - continue; + for (int i = 0; i < cellArray.Count; i++) + ordered.Add(cellArray.OrderedIds[i]); + } + else + { + foreach (uint id in cellSet) + ordered.Add(id); + } + ordered.Sort(); - var terrainState = ValidateWalkable( - footCenter, + foreach (uint cellId in ordered) + { + if (cellId == sp.CheckCellId) continue; + + // #137 seam shake (2026-07-06): a mid-loop query can MOVE the + // sphere — a Path-5 full hit dispatches step_sphere_up, and a + // successful climb lifts the foot (the 0.6 mm ramp-slab lift at + // the Facility Hub boundaries) yet returns OK, so the loop + // continues. Retail's check_other_cells reads the LIVE + // sphere_path.global_sphere for every cell (each cell's + // find_collisions receives the transition itself, pc:272717+); + // querying the remaining cells with the pre-climb center re-tests + // the boundary ~0.4 mm inside the floor slab and grazes the + // under-room's CEILING (the slab underside) — the neg-poly + // step-up-with-a-downward-normal chain that shook the player at + // every corridor seam. Same lesson as the P2 cellar-lip fix + // (RunCheckOtherCellsAndAdvance's entry refresh), one loop deeper. + footCenter = sp.GlobalSphere[0].Origin; + + if ((cellId & 0xFFFFu) < 0x0100u) + { + var terrainWalkable = engine.SampleTerrainWalkable(footCenter.X, footCenter.Y); + if (terrainWalkable is null || terrainWalkable.Value.CellId != cellId) + continue; + + var terrainState = ValidateWalkable( + footCenter, + sphereRadius, + terrainWalkable.Value.Plane, + terrainWalkable.Value.IsWater, + terrainWalkable.Value.WaterDepth, + cellId: terrainWalkable.Value.CellId, + walkableVertices: terrainWalkable.Value.Vertices); + + if (PhysicsDiagnostics.ProbeIndoorBspEnabled) + { + var plane = terrainWalkable.Value.Plane; + Console.WriteLine(System.FormattableString.Invariant( + $"[other-cells] primary=0x{sp.CheckCellId:X8} iter=0x{cellId:X8} terrain wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) r={sphereRadius:F3} result={terrainState} n=({plane.Normal.X:F3},{plane.Normal.Y:F3},{plane.Normal.Z:F3}) d={plane.D:F3}")); + } + + if (PhysicsDiagnostics.ProbePushBackEnabled) + { + PhysicsDiagnostics.LogPushBackCellTransit( + primaryCellId: sp.CheckCellId, + otherCellId: cellId, + bspResult: (int)terrainState, + halted: false); + } + + if (ApplyOtherCellResult(terrainState, out var terrainHalted)) + return terrainHalted; + + // BR-7 / A6.P4 (2026-06-11): retail's per-other-cell + // find_collisions on a LandCell is env → building → objects + // (CLandCell::find_collisions 0x00532d60 → + // CSortCell::find_collisions 0x005340a0 → + // CObjCell::find_obj_collisions 0x0052b750). This is how an + // indoor-primary sphere straddling an exit portal tests the + // building shell AND outdoor-registered objects (doors). + var bldgOtherState = FindBuildingCollisions(engine, cellId); + if (ApplyOtherCellResult(bldgOtherState, out var bldgHalted)) + return bldgHalted; + + var objOtherState = FindObjCollisionsInCell(engine, cellId); + if (ApplyOtherCellResult(objOtherState, out var objHalted)) + return objHalted; + + continue; + } + + var cell = engine.DataCache.GetCellStruct(cellId); + // R2 guard: stale CellPhysics loaded for render but not physics. + if (cell?.BSP?.Root is null) continue; + + // Transform sphere into THIS cell's local space. Mirrors the + // primary-cell pattern at TransitionTypes.cs (FindEnvCollisions, + // ~line 1413) AND the Bug B world-origin fix that decomposes + // WorldTransform per cell so BSP Path-3 + Path-4 land write + // world-space ContactPlanes. + var localCenter = Vector3.Transform(footCenter, cell.InverseWorldTransform); + var localCurrCenter = Vector3.Transform(sp.GlobalCurrCenter[0].Origin, cell.InverseWorldTransform); + + bool hasLocalSphere1 = sp.NumSphere > 1; + Vector3 localSphere1Center = hasLocalSphere1 + ? Vector3.Transform(sp.GlobalSphere[1].Origin, cell.InverseWorldTransform) + : Vector3.Zero; + float localSphere1Radius = hasLocalSphere1 + ? sp.GlobalSphere[1].Radius + : 0f; + + System.Numerics.Quaternion cellRotation; + Vector3 cellOrigin; + if (!System.Numerics.Matrix4x4.Decompose(cell.WorldTransform, out _, + out cellRotation, out cellOrigin)) + { + Console.WriteLine(System.FormattableString.Invariant( + $"[other-cells] WARN cell 0x{cellId:X8} WorldTransform did not decompose — falling back to identity rotation")); + cellRotation = System.Numerics.Quaternion.Identity; + cellOrigin = cell.WorldTransform.Translation; + } + + if (PhysicsDiagnostics.ProbeIndoorBspEnabled) + PhysicsDiagnostics.LastBspHitPoly = null; + + var result = BSPQuery.FindCollisions( + cell.BSP.Root, cell.Resolved, this, + localCenter, sphereRadius, - terrainWalkable.Value.Plane, - terrainWalkable.Value.IsWater, - terrainWalkable.Value.WaterDepth, - cellId: terrainWalkable.Value.CellId, - walkableVertices: terrainWalkable.Value.Vertices); + hasLocalSphere1, + localSphere1Center, + localSphere1Radius, + localCurrCenter, + Vector3.UnitZ, 1.0f, cellRotation, engine, + worldOrigin: cellOrigin); if (PhysicsDiagnostics.ProbeIndoorBspEnabled) { - var plane = terrainWalkable.Value.Plane; + var hit = PhysicsDiagnostics.LastBspHitPoly; + string polyDesc = hit is null + ? "poly=n/a" + : System.FormattableString.Invariant( + $"poly=0x{hit.Id:X4} n=({hit.Plane.Normal.X:F3},{hit.Plane.Normal.Y:F3},{hit.Plane.Normal.Z:F3}) d={hit.Plane.D:F3} sides={hit.SidesType}"); + var bs = cell.BSP.Root.BoundingSphere; + string bsDesc = System.FormattableString.Invariant( + $"bs=({bs.Origin.X:F3},{bs.Origin.Y:F3},{bs.Origin.Z:F3}) br={bs.Radius:F3}"); Console.WriteLine(System.FormattableString.Invariant( - $"[other-cells] primary=0x{sp.CheckCellId:X8} iter=0x{cellId:X8} terrain wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) r={sphereRadius:F3} result={terrainState} n=({plane.Normal.X:F3},{plane.Normal.Y:F3},{plane.Normal.Z:F3}) d={plane.D:F3}")); + $"[other-cells] primary=0x{sp.CheckCellId:X8} iter=0x{cellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) lpos=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) lprev=({localCurrCenter.X:F3},{localCurrCenter.Y:F3},{localCurrCenter.Z:F3}) r={sphereRadius:F3} {bsDesc} result={result} cn=({CollisionInfo.CollisionNormal.X:F2},{CollisionInfo.CollisionNormal.Y:F2},{CollisionInfo.CollisionNormal.Z:F2}) sn=({CollisionInfo.SlidingNormal.X:F2},{CollisionInfo.SlidingNormal.Y:F2},{CollisionInfo.SlidingNormal.Z:F2}) negHit={sp.NegPolyHit} negN=({sp.NegCollisionNormal.X:F2},{sp.NegCollisionNormal.Y:F2},{sp.NegCollisionNormal.Z:F2}) {polyDesc}")); + } + + if (PhysicsDiagnostics.ProbeStepWalkEnabled + && sp.StepDown + && result == TransitionState.OK) + { + LogNearestWalkableCandidate("other-cell", cellId, localCenter, sphereRadius, cell.Resolved); } if (PhysicsDiagnostics.ProbePushBackEnabled) { PhysicsDiagnostics.LogPushBackCellTransit( primaryCellId: sp.CheckCellId, - otherCellId: cellId, - bspResult: (int)terrainState, - halted: false); + otherCellId: cellId, + bspResult: (int)result, + halted: false); } - if (ApplyOtherCellResult(terrainState, out var terrainHalted)) - return terrainHalted; + if (ApplyOtherCellResult(result, out var halted)) + return halted; // BR-7 / A6.P4 (2026-06-11): retail's per-other-cell - // find_collisions on a LandCell is env → building → objects - // (CLandCell::find_collisions 0x00532d60 → - // CSortCell::find_collisions 0x005340a0 → - // CObjCell::find_obj_collisions 0x0052b750). This is how an - // indoor-primary sphere straddling an exit portal tests the - // building shell AND outdoor-registered objects (doors). - var bldgOtherState = FindBuildingCollisions(engine, cellId); - if (ApplyOtherCellResult(bldgOtherState, out var bldgHalted)) - return bldgHalted; - - var objOtherState = FindObjCollisionsInCell(engine, cellId); - if (ApplyOtherCellResult(objOtherState, out var objHalted)) - return objHalted; - - continue; + // find_collisions on an EnvCell is env → objects + // (CEnvCell::find_collisions 0x0052c100 → + // CObjCell::find_obj_collisions 0x0052b750) — the other cell's + // OWN shadow list, which the registration-side flood populated. + var objIndoorState = FindObjCollisionsInCell(engine, cellId); + if (ApplyOtherCellResult(objIndoorState, out var objIndoorHalted)) + return objIndoorHalted; } - var cell = engine.DataCache.GetCellStruct(cellId); - // R2 guard: stale CellPhysics loaded for render but not physics. - if (cell?.BSP?.Root is null) continue; - - // Transform sphere into THIS cell's local space. Mirrors the - // primary-cell pattern at TransitionTypes.cs (FindEnvCollisions, - // ~line 1413) AND the Bug B world-origin fix that decomposes - // WorldTransform per cell so BSP Path-3 + Path-4 land write - // world-space ContactPlanes. - var localCenter = Vector3.Transform(footCenter, cell.InverseWorldTransform); - var localCurrCenter = Vector3.Transform(sp.GlobalCurrCenter[0].Origin, cell.InverseWorldTransform); - - var localSphere = new DatReaderWriter.Types.Sphere - { - Origin = localCenter, - Radius = sphereRadius, - }; - DatReaderWriter.Types.Sphere? localSphere1 = null; - if (sp.NumSphere > 1) - { - localSphere1 = new DatReaderWriter.Types.Sphere - { - Origin = Vector3.Transform(sp.GlobalSphere[1].Origin, cell.InverseWorldTransform), - Radius = sp.GlobalSphere[1].Radius, - }; - } - - System.Numerics.Quaternion cellRotation; - Vector3 cellOrigin; - if (!System.Numerics.Matrix4x4.Decompose(cell.WorldTransform, out _, - out cellRotation, out cellOrigin)) - { - Console.WriteLine(System.FormattableString.Invariant( - $"[other-cells] WARN cell 0x{cellId:X8} WorldTransform did not decompose — falling back to identity rotation")); - cellRotation = System.Numerics.Quaternion.Identity; - cellOrigin = cell.WorldTransform.Translation; - } - - if (PhysicsDiagnostics.ProbeIndoorBspEnabled) - PhysicsDiagnostics.LastBspHitPoly = null; - - var result = BSPQuery.FindCollisions( - cell.BSP.Root, cell.Resolved, this, - localSphere, localSphere1, localCurrCenter, - Vector3.UnitZ, 1.0f, cellRotation, engine, - worldOrigin: cellOrigin); - - if (PhysicsDiagnostics.ProbeIndoorBspEnabled) - { - var hit = PhysicsDiagnostics.LastBspHitPoly; - string polyDesc = hit is null - ? "poly=n/a" - : System.FormattableString.Invariant( - $"poly=0x{hit.Id:X4} n=({hit.Plane.Normal.X:F3},{hit.Plane.Normal.Y:F3},{hit.Plane.Normal.Z:F3}) d={hit.Plane.D:F3} sides={hit.SidesType}"); - var bs = cell.BSP.Root.BoundingSphere; - string bsDesc = System.FormattableString.Invariant( - $"bs=({bs.Origin.X:F3},{bs.Origin.Y:F3},{bs.Origin.Z:F3}) br={bs.Radius:F3}"); - Console.WriteLine(System.FormattableString.Invariant( - $"[other-cells] primary=0x{sp.CheckCellId:X8} iter=0x{cellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) lpos=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) lprev=({localCurrCenter.X:F3},{localCurrCenter.Y:F3},{localCurrCenter.Z:F3}) r={sphereRadius:F3} {bsDesc} result={result} cn=({CollisionInfo.CollisionNormal.X:F2},{CollisionInfo.CollisionNormal.Y:F2},{CollisionInfo.CollisionNormal.Z:F2}) sn=({CollisionInfo.SlidingNormal.X:F2},{CollisionInfo.SlidingNormal.Y:F2},{CollisionInfo.SlidingNormal.Z:F2}) negHit={sp.NegPolyHit} negN=({sp.NegCollisionNormal.X:F2},{sp.NegCollisionNormal.Y:F2},{sp.NegCollisionNormal.Z:F2}) {polyDesc}")); - } - - if (PhysicsDiagnostics.ProbeStepWalkEnabled - && sp.StepDown - && result == TransitionState.OK) - { - LogNearestWalkableCandidate("other-cell", cellId, localCenter, sphereRadius, cell.Resolved); - } - - if (PhysicsDiagnostics.ProbePushBackEnabled) - { - PhysicsDiagnostics.LogPushBackCellTransit( - primaryCellId: sp.CheckCellId, - otherCellId: cellId, - bspResult: (int)result, - halted: false); - } - - if (ApplyOtherCellResult(result, out var halted)) - return halted; - - // BR-7 / A6.P4 (2026-06-11): retail's per-other-cell - // find_collisions on an EnvCell is env → objects - // (CEnvCell::find_collisions 0x0052c100 → - // CObjCell::find_obj_collisions 0x0052b750) — the other cell's - // OWN shadow list, which the registration-side flood populated. - var objIndoorState = FindObjCollisionsInCell(engine, cellId); - if (ApplyOtherCellResult(objIndoorState, out var objIndoorHalted)) - return objIndoorHalted; + return TransitionState.OK; + } + finally + { + sp.OrderedCellScratch.Return(ordered); } - - return TransitionState.OK; } private void LogIssue98CellSetSummary( @@ -2295,11 +2563,11 @@ public sealed class Transition { case TransitionState.Collided: case TransitionState.Adjusted: - if (!ObjectInfo.State.HasFlag(ObjectInfoState.Contact)) + if ((ObjectInfo.State & ObjectInfoState.Contact) == 0) CollisionInfo.CollidedWithEnvironment = true; return true; case TransitionState.Slid: - CollisionInfo.ContactPlaneValid = false; + CollisionInfo.ContactPlaneValid = false; CollisionInfo.ContactPlaneIsWater = false; return true; default: @@ -2320,7 +2588,7 @@ public sealed class Transition sp.ObstructionEthereal = false; Vector3 footCenter = sp.GlobalSphere[0].Origin; - float sphereRadius = sp.GlobalSphere[0].Radius; + float sphereRadius = sp.GlobalSphere[0].Radius; // Retail does NOT re-pick the cell before colliding. CEnvCell::find_env_collisions // (acclient_2013_pseudo_c.txt:309573) collides against the cell it was called ON — @@ -2358,23 +2626,16 @@ public sealed class Transition var localCenter = Vector3.Transform(footCenter, cellPhysics.InverseWorldTransform); var localCurrCenter = Vector3.Transform(sp.GlobalCurrCenter[0].Origin, cellPhysics.InverseWorldTransform); - var localSphere = new DatReaderWriter.Types.Sphere - { - Origin = localCenter, - Radius = sphereRadius, - }; - // Second sphere (head) in local space, if present. - DatReaderWriter.Types.Sphere? localSphere1 = null; - if (sp.NumSphere > 1) - { - var headCenter = sp.GlobalSphere[1].Origin; - localSphere1 = new DatReaderWriter.Types.Sphere - { - Origin = Vector3.Transform(headCenter, cellPhysics.InverseWorldTransform), - Radius = sp.GlobalSphere[1].Radius, - }; - } + bool hasLocalSphere1 = sp.NumSphere > 1; + Vector3 localSphere1Center = hasLocalSphere1 + ? Vector3.Transform( + sp.GlobalSphere[1].Origin, + cellPhysics.InverseWorldTransform) + : Vector3.Zero; + float localSphere1Radius = hasLocalSphere1 + ? sp.GlobalSphere[1].Radius + : 0f; // Indoor walking Phase 1 (2026-05-19): clear the LastBspHitPoly // side-channel before the call so a missed write (no collision) @@ -2401,7 +2662,7 @@ public sealed class Transition // TransformVertices + BuildWorldPlane chain is the equivalent // — it just needs the right rotation + origin. Quaternion cellRotation; - Vector3 cellOrigin; + Vector3 cellOrigin; if (!Matrix4x4.Decompose(cellPhysics.WorldTransform, out _, out cellRotation, out cellOrigin)) { // Matrix has shear or other non-decomposable parts. EnvCell @@ -2413,15 +2674,18 @@ public sealed class Transition Console.WriteLine(System.FormattableString.Invariant( $"[indoor-bsp] WARN cellPhysics.WorldTransform did not decompose cleanly for cell 0x{sp.CheckCellId:X8} — falling back to identity rotation")); cellRotation = Quaternion.Identity; - cellOrigin = cellPhysics.WorldTransform.Translation; + cellOrigin = cellPhysics.WorldTransform.Translation; } var cellState = BSPQuery.FindCollisions( cellPhysics.BSP.Root, cellPhysics.Resolved, this, - localSphere, - localSphere1, + localCenter, + sphereRadius, + hasLocalSphere1, + localSphere1Center, + localSphere1Radius, localCurrCenter, Vector3.UnitZ, // local space Z is up 1.0f, // scale = 1.0 for cell geometry @@ -2443,7 +2707,7 @@ public sealed class Transition if (cellState != TransitionState.OK) { - if (!ObjectInfo.State.HasFlag(ObjectInfoState.Contact)) + if ((ObjectInfo.State & ObjectInfoState.Contact) == 0) ci.CollidedWithEnvironment = true; return cellState; } @@ -2553,8 +2817,13 @@ public sealed class Transition sp.HitsInteriorCell = false; uint containingCellId = CellTransit.FindCellSet( - engine.DataCache, sp.GlobalSphere, sp.NumSphere, sp.CheckCellId, out var cellSet, + engine.DataCache, + sp.GlobalSphere, + sp.NumSphere, + sp.CheckCellId, + sp.CellCandidates, sp.CarriedBlockOrigin); + CellArray cellSet = sp.CellCandidates; LogIssue98CellSetSummary(engine, containingCellId, cellSet, footCenter, sphereRadius); if ((sp.CheckCellId & 0xFFFFu) >= 0x0100u @@ -2564,8 +2833,9 @@ public sealed class Transition } else { - foreach (uint id in cellSet) + for (int i = 0; i < cellSet.Count; i++) { + uint id = cellSet.OrderedIds[i]; if ((id & 0xFFFFu) >= 0x0100u) { sp.HitsInteriorCell = true; break; } } } @@ -2610,18 +2880,12 @@ public sealed class Transition private TransitionState ValidateWalkable(Vector3 sphereCenter, float sphereRadius, System.Numerics.Plane contactPlane, bool isWater, float waterDepth, uint cellId, - Vector3[]? walkableVertices = null) + TerrainTriangleVertices? 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); @@ -2646,7 +2910,7 @@ public sealed class Transition if (sp.StepDown || !oi.OnWalkable || walkableNormal) { ci.SetContactPlane(contactPlane, cellId, isWater); - CacheWalkableContext(); + CacheWalkableContext(sp, contactPlane, walkableVertices); } if (!oi.Contact && !sp.StepDown) @@ -2669,7 +2933,7 @@ public sealed class Transition if (sp.StepDown || !oi.OnWalkable || walkable) { ci.SetContactPlane(contactPlane, cellId, isWater); - CacheWalkableContext(); + CacheWalkableContext(sp, contactPlane, walkableVertices); if (sp.StepDown) { @@ -2693,6 +2957,18 @@ public sealed class Transition return TransitionState.Adjusted; } + private static void CacheWalkableContext( + SpherePath spherePath, + Plane contactPlane, + TerrainTriangleVertices? walkableVertices) + { + if (walkableVertices is { } vertices + && contactPlane.Normal.Z >= PhysicsGlobals.FloorZ) + { + spherePath.SetWalkable(contactPlane, in vertices, Vector3.UnitZ); + } + } + // ----------------------------------------------------------------------- // Object collision — static BSP objects // ----------------------------------------------------------------------- @@ -2740,10 +3016,10 @@ public sealed class Transition && Environment.GetEnvironmentVariable("ACDREAM_AIRBORNE_DIAG") == "1"; Vector3 sphereCheckBefore = sp.CheckPos; - Vector3 checkPos = sp.GlobalSphere[0].Origin; - Vector3 currPos = sp.GlobalCurrCenter[0].Origin; - float sphereRadius = sp.GlobalSphere[0].Radius; - Vector3 movement = checkPos - currPos; + Vector3 checkPos = sp.GlobalSphere[0].Origin; + Vector3 currPos = sp.GlobalCurrCenter[0].Origin; + float sphereRadius = sp.GlobalSphere[0].Radius; + Vector3 movement = checkPos - currPos; // Landblock offsets feed the [resolve-bldg] probe only. engine.TryGetLandblockContext(checkPos.X, checkPos.Y, @@ -2880,23 +3156,22 @@ public sealed class Transition var invRot = Quaternion.Inverse(obj.Rotation); float invScale = obj.Scale > 0 ? 1.0f / obj.Scale : 1.0f; - var localSphere0 = new DatReaderWriter.Types.Sphere - { - Origin = Vector3.Transform(sp.GlobalSphere[0].Origin - obj.Position, invRot) * invScale, - Radius = sp.GlobalSphere[0].Radius * invScale, - }; + Vector3 localSphere0Center = + Vector3.Transform(sp.GlobalSphere[0].Origin - obj.Position, invRot) + * invScale; + float localSphere0Radius = sp.GlobalSphere[0].Radius * invScale; var localCurrCenter = Vector3.Transform( sp.GlobalCurrCenter[0].Origin - obj.Position, invRot) * invScale; - DatReaderWriter.Types.Sphere? localSphere1 = null; - if (sp.NumSphere > 1) - { - localSphere1 = new DatReaderWriter.Types.Sphere - { - Origin = Vector3.Transform(sp.GlobalSphere[1].Origin - obj.Position, invRot) * invScale, - Radius = sp.GlobalSphere[1].Radius * invScale, - }; - } + bool hasLocalSphere1 = sp.NumSphere > 1; + Vector3 localSphere1Center = hasLocalSphere1 + ? Vector3.Transform( + sp.GlobalSphere[1].Origin - obj.Position, + invRot) * invScale + : Vector3.Zero; + float localSphere1Radius = hasLocalSphere1 + ? sp.GlobalSphere[1].Radius * invScale + : 0f; // Local-space Z (up direction rotated into object space). var localSpaceZ = Vector3.Transform(Vector3.UnitZ, invRot); @@ -2908,8 +3183,11 @@ public sealed class Transition physics.BSP.Root, physics.Resolved, this, - localSphere0, - localSphere1, + localSphere0Center, + localSphere0Radius, + hasLocalSphere1, + localSphere1Center, + localSphere1Radius, localCurrCenter, localSpaceZ, obj.Scale, // scale for local→world offsets @@ -3082,14 +3360,14 @@ public sealed class Transition // one BSP test on Parts[0]; doorway gap lives inside that BSP). if (attributed && PhysicsDiagnostics.ProbeBuildingEnabled) { - uint partIdx = obj.EntityId & 0xFFu; + uint partIdx = obj.EntityId & 0xFFu; uint entityIdProbe = obj.EntityId >> 8; - var cachedPhys = engine.DataCache.GetGfxObj(obj.GfxObjId); - var visBounds = engine.DataCache.GetVisualBounds(obj.GfxObjId); - float bspR = cachedPhys?.BoundingSphere?.Radius ?? 0f; - float vAabbR = visBounds?.Radius ?? 0f; - bool hasPhys = cachedPhys is not null; - var entOriginLb = obj.Position - new Vector3(worldOffsetX, worldOffsetY, 0f); + var cachedPhys = engine.DataCache.GetGfxObj(obj.GfxObjId); + var visBounds = engine.DataCache.GetVisualBounds(obj.GfxObjId); + float bspR = cachedPhys?.BoundingSphere?.Radius ?? 0f; + float vAabbR = visBounds?.Radius ?? 0f; + bool hasPhys = cachedPhys is not null; + var entOriginLb = obj.Position - new Vector3(worldOffsetX, worldOffsetY, 0f); var sb = new System.Text.StringBuilder(256); sb.Append(System.FormattableString.Invariant( @@ -3208,24 +3486,20 @@ public sealed class Transition out Quaternion bldRotation, out Vector3 bldOrigin)) { bldRotation = Quaternion.Identity; - bldOrigin = building.WorldTransform.Translation; + bldOrigin = building.WorldTransform.Translation; } var invRot = Quaternion.Inverse(bldRotation); - var localSphere0 = new DatReaderWriter.Types.Sphere - { - Origin = Vector3.Transform(sp.GlobalSphere[0].Origin - bldOrigin, invRot), - Radius = sp.GlobalSphere[0].Radius, - }; - DatReaderWriter.Types.Sphere? localSphere1 = null; - if (sp.NumSphere > 1) - { - localSphere1 = new DatReaderWriter.Types.Sphere - { - Origin = Vector3.Transform(sp.GlobalSphere[1].Origin - bldOrigin, invRot), - Radius = sp.GlobalSphere[1].Radius, - }; - } + Vector3 localSphere0Center = + Vector3.Transform(sp.GlobalSphere[0].Origin - bldOrigin, invRot); + float localSphere0Radius = sp.GlobalSphere[0].Radius; + bool hasLocalSphere1 = sp.NumSphere > 1; + Vector3 localSphere1Center = hasLocalSphere1 + ? Vector3.Transform(sp.GlobalSphere[1].Origin - bldOrigin, invRot) + : Vector3.Zero; + float localSphere1Radius = hasLocalSphere1 + ? sp.GlobalSphere[1].Radius + : 0f; var localCurrCenter = Vector3.Transform( sp.GlobalCurrCenter[0].Origin - bldOrigin, invRot); var localSpaceZ = Vector3.Transform(Vector3.UnitZ, invRot); @@ -3239,8 +3513,11 @@ public sealed class Transition physics.BSP.Root, physics.Resolved, this, - localSphere0, - localSphere1, + localSphere0Center, + localSphere0Radius, + hasLocalSphere1, + localSphere1Center, + localSphere1Radius, localCurrCenter, localSpaceZ, 1.0f, // buildings are unscaled @@ -3513,7 +3790,7 @@ public sealed class Transition Vector3 gCenter = sp.GlobalCurrCenter[sphereNum].Origin; Vector3 globalOffset = gCenter - obj.Position; - if (!ObjectInfo.State.HasFlag(ObjectInfoState.PerfectClip)) + if ((ObjectInfo.State & ObjectInfoState.PerfectClip) == 0) { if (!NormalizeCheckSmall(ref globalOffset)) CollisionInfo.SetCollisionNormal(globalOffset); @@ -3946,7 +4223,7 @@ public sealed class Transition if (NormalizeCheckSmall(ref n)) return TransitionState.Collided; - if (!ObjectInfo.State.HasFlag(ObjectInfoState.PerfectClip)) + if ((ObjectInfo.State & ObjectInfoState.PerfectClip) == 0) { CollisionInfo.SetCollisionNormal(n); return TransitionState.Collided; @@ -4198,11 +4475,11 @@ public sealed class Transition var sp = SpherePath; var ci = CollisionInfo; - Vector3 result = offset; - bool checkSlide = false; + Vector3 result = offset; + bool checkSlide = false; // A6.P3 #98 (2026-05-23): branch token for the [step-walk-adjust] // probe. Tracks which exit path the projection takes. - string branch = "init"; + string branch = "init"; // Check if we should apply sliding. float slidingAngle = Vector3.Dot(result, ci.SlidingNormal); @@ -4240,8 +4517,8 @@ public sealed class Transition } // Have a contact plane — project movement onto the contact surface. - float collisionAngle = Vector3.Dot(result, ci.ContactPlane.Normal); - Vector3 slideOffset = Vector3.Cross(ci.ContactPlane.Normal, ci.SlidingNormal); + float collisionAngle = Vector3.Dot(result, ci.ContactPlane.Normal); + Vector3 slideOffset = Vector3.Cross(ci.ContactPlane.Normal, ci.SlidingNormal); if (checkSlide) { @@ -4301,7 +4578,7 @@ public sealed class Transition if (ci.ContactPlaneCellId != 0 && !ci.ContactPlaneIsWater) { Vector3 globCenter = sp.GlobalSphere[0].Origin; - float radius = sp.GlobalSphere[0].Radius; + float radius = sp.GlobalSphere[0].Radius; // Signed distance from sphere center to contact plane. float dist = Vector3.Dot(globCenter, ci.ContactPlane.Normal) @@ -4353,10 +4630,10 @@ public sealed class Transition { var sp = SpherePath; - sp.NegPolyHit = false; - sp.StepDown = true; + sp.NegPolyHit = false; + sp.StepDown = true; sp.StepDownAmt = stepDownHeight; - sp.WalkInterp = 1.0f; + sp.WalkInterp = 1.0f; bool stepWalkProbe = PhysicsDiagnostics.ProbeStepWalkEnabled; if (stepWalkProbe) @@ -4463,10 +4740,10 @@ public sealed class Transition // // This fix is the cellar-up target (issue #98). May also help // other "step-up onto sloped surface" scenarios. - var savedInsert = sp.InsertType; + var savedInsert = sp.InsertType; float winterpBeforePlacement = sp.WalkInterp; - sp.InsertType = InsertType.Placement; - sp.WalkInterp = 1.0f; + sp.InsertType = InsertType.Placement; + sp.WalkInterp = 1.0f; var placeState = TransitionalInsert(1, engine); @@ -4564,7 +4841,7 @@ public sealed class Transition 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)}, " + + $"OnWalkable={(oi.State & ObjectInfoState.OnWalkable) != 0}, " + $"StepUpHeight={oi.StepUpHeight:F3}, " + $"CurPos=({sp.CurPos.X:F2},{sp.CurPos.Y:F2},{sp.CurPos.Z:F2})"); } @@ -4577,24 +4854,24 @@ public sealed class Transition // 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; + bool savedCpValid = ci.ContactPlaneValid; + Plane savedCp = ci.ContactPlane; + uint savedCpCellId = ci.ContactPlaneCellId; + bool savedCpIsWater = ci.ContactPlaneIsWater; - ci.ContactPlaneValid = false; + ci.ContactPlaneValid = false; ci.ContactPlaneIsWater = false; - sp.StepUp = true; + sp.StepUp = true; sp.StepUpNormal = collisionNormal; // Default values (not on walkable): small step, LandingZ threshold. float stepDownHeight = 0.04f; - float zLandingValue = PhysicsGlobals.LandingZ; + float zLandingValue = PhysicsGlobals.LandingZ; - if (oi.State.HasFlag(ObjectInfoState.OnWalkable)) + if ((oi.State & ObjectInfoState.OnWalkable) != 0) { - zLandingValue = oi.GetWalkableZ(); + zLandingValue = oi.GetWalkableZ(); stepDownHeight = oi.StepUpHeight; } @@ -4637,10 +4914,10 @@ public sealed class Transition // a too-tall wall must not change that. if (savedCpValid) { - ci.ContactPlane = savedCp; - ci.ContactPlaneValid = true; - ci.ContactPlaneCellId = savedCpCellId; - ci.ContactPlaneIsWater = savedCpIsWater; + ci.ContactPlane = savedCp; + ci.ContactPlaneValid = true; + ci.ContactPlaneCellId = savedCpCellId; + ci.ContactPlaneIsWater = savedCpIsWater; } } @@ -4673,7 +4950,7 @@ public sealed class Transition var sp = SpherePath; var oi = ObjectInfo; - if (!oi.State.HasFlag(ObjectInfoState.OnWalkable)) + if ((oi.State & ObjectInfoState.OnWalkable) == 0) return true; // If the current walkable entry is still valid, skip the probe. @@ -4683,7 +4960,7 @@ public sealed class Transition sp.SaveCheckPos(); float stepHeight = oi.StepDownHeight; - var globSphere = sp.GlobalSphere[0]; + var globSphere = sp.GlobalSphere[0]; if (sp.NumSphere < 2 && stepHeight > globSphere.Radius * 2f) stepHeight = globSphere.Radius * 0.5f; @@ -4692,7 +4969,7 @@ public sealed class Transition stepHeight *= 0.5f; sp.WalkableAllowance = zCheck; - sp.CheckWalkable = true; + sp.CheckWalkable = true; sp.AddOffsetToCheckPos(new Vector3(0f, 0f, -stepHeight)); var transitState = TransitionalInsert(1, engine); @@ -4730,8 +5007,8 @@ public sealed class Transition if (transitionState == TransitionState.OK && sp.CheckPos != sp.CurPos) { // Movement succeeded: accept the new position. - sp.CurPos = sp.CheckPos; - sp.CurCellId = sp.CheckCellId; + sp.CurPos = sp.CheckPos; + sp.CurCellId = sp.CheckCellId; sp.CurOrientation = sp.CheckOrientation; // Cache the current-center spheres at the new position. @@ -4774,9 +5051,9 @@ public sealed class Transition // falling animation. Now LastKnown survives transient losses. if (ci.ContactPlaneValid) { - ci.LastKnownContactPlaneValid = true; - 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; @@ -4821,7 +5098,7 @@ public sealed class Transition // after sub-step accumulation. Using the wrong one made the proximity // guard fire on the wrong reference point. var sphereCenter = sp.GlobalCurrCenter[0].Origin; - var radius = sp.GlobalSphere[0].Radius; + var radius = sp.GlobalSphere[0].Radius; float angle = Vector3.Dot(ci.LastKnownContactPlane.Normal, sphereCenter) + ci.LastKnownContactPlane.D; @@ -4911,9 +5188,9 @@ public sealed class Transition // acdream runs the ladder after the fused contact-marking, so apply the // manufactured grounding + LKCP save that ACE's post-fsf D block would. oi.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable; - ci.LastKnownContactPlaneValid = true; - ci.LastKnownContactPlane = ci.ContactPlane; - ci.LastKnownContactPlaneCellId = ci.ContactPlaneCellId; + ci.LastKnownContactPlaneValid = true; + ci.LastKnownContactPlane = ci.ContactPlane; + ci.LastKnownContactPlaneCellId = ci.ContactPlaneCellId; ci.LastKnownContactPlaneIsWater = ci.ContactPlaneIsWater; } else diff --git a/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs b/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs index 17a756c9..dcae8053 100644 --- a/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs +++ b/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs @@ -134,9 +134,13 @@ public class TerrainSurfaceTests var sample = surface.SampleSurfacePolygon(2f, 2f); Assert.Equal(3, sample.Vertices.Length); - Assert.All(sample.Vertices, v => Assert.Equal(50f, v.Z)); + for (int i = 0; i < sample.Vertices.Length; i++) + Assert.Equal(50f, sample.Vertices[i].Z); Assert.Equal(1f, sample.Normal.Z, precision: 3); - Assert.Contains(sample.Vertices, v => v.X == 0f && v.Y == 0f); + Assert.True( + sample.Vertices.V0.X == 0f && sample.Vertices.V0.Y == 0f + || sample.Vertices.V1.X == 0f && sample.Vertices.V1.Y == 0f + || sample.Vertices.V2.X == 0f && sample.Vertices.V2.Y == 0f); } [Fact] diff --git a/tests/AcDream.Core.Tests/Physics/TransitionAllocationBaselineTests.cs b/tests/AcDream.Core.Tests/Physics/TransitionAllocationBaselineTests.cs index 88e8c09f..b37d418e 100644 --- a/tests/AcDream.Core.Tests/Physics/TransitionAllocationBaselineTests.cs +++ b/tests/AcDream.Core.Tests/Physics/TransitionAllocationBaselineTests.cs @@ -9,11 +9,11 @@ using Xunit.Abstractions; namespace AcDream.Core.Tests.Physics; /// -/// Slice I0 evidence harness for the four production +/// Slice I allocation gate for the four production /// call shapes. This is not a -/// budget assertion: I1 deliberately changes the expected allocation from the -/// recorded graph-path baseline to zero steady transition/query-scratch bytes. -/// The harness stays in-tree so the before/after figure is reproducible. +/// broad frame-allocation budget: it isolates transition/query scratch and +/// requires zero steady bytes after all retained buffers and tiered code paths +/// have warmed. /// public sealed class TransitionAllocationBaselineTests { @@ -27,32 +27,33 @@ public sealed class TransitionAllocationBaselineTests => _output = output; [Fact] - public void GraphPath_RecordsPerResolveAllocationForEveryMoverFamily() + public void ReusedScratch_AllocatesZeroBytesPerResolveForEveryMoverFamily() { var (root, resolved) = BSPStepUpFixtures.TallWall(); var engine = BuildEngine(root, resolved); + var groundEngine = BuildGroundEngine(); long player = Measure(() => ResolvePlayer(engine)); + long groundedPublication = Measure( + () => ResolveGroundedPublication(groundEngine)); long remote = Measure(() => ResolveRemote(engine)); long projectile = Measure(() => ResolveProjectile(engine)); long camera = Measure(() => ResolveCamera(engine)); _output.WriteLine( - $"Slice I0 graph-path allocation baseline ({MeasuredIterations:N0} resolves/profile):"); + $"Slice I1 steady scratch gate ({MeasuredIterations:N0} resolves/profile):"); _output.WriteLine($" player: {player,8:N0} B/resolve"); + _output.WriteLine( + $" grounded: {groundedPublication,8:N0} B/resolve"); _output.WriteLine($" remote: {remote,8:N0} B/resolve"); _output.WriteLine($" projectile: {projectile,8:N0} B/resolve"); _output.WriteLine($" camera: {camera,8:N0} B/resolve"); - // The current graph path constructs a Transition object graph and - // CollisionSphere helpers on every resolve. I1 replaces that lifetime - // shape; this lower bound merely proves the baseline capture actually - // observed those allocations instead of an optimized-away call. - Assert.All( - new[] { player, remote, projectile, camera }, - bytes => Assert.True( - bytes > 0, - "The I0 baseline must observe the current per-resolve allocation.")); + Assert.Equal(0, player); + Assert.Equal(0, groundedPublication); + Assert.Equal(0, remote); + Assert.Equal(0, projectile); + Assert.Equal(0, camera); } private static long Measure(Func resolve) @@ -106,6 +107,24 @@ public sealed class TransitionAllocationBaselineTests movingEntityId: 0x80000001u); } + private static ResolveResult ResolveGroundedPublication(PhysicsEngine engine) + { + var body = Bodies.Grounded; + ResetBody(body, PhysicsStateFlags.Gravity); + return engine.ResolveWithTransition( + currentPos: new Vector3(8f, 8f, 0f), + targetPos: new Vector3(8.12f, 8f, 0f), + cellId: CellId, + sphereRadius: BSPStepUpFixtures.SphereRadius, + sphereHeight: 1.20f, + stepUpHeight: 0.40f, + stepDownHeight: 1.50f, + isOnGround: true, + body: body, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0x5000000Bu); + } + private static ResolveResult ResolveProjectile(PhysicsEngine engine) { var body = Bodies.Projectile; @@ -197,9 +216,25 @@ public sealed class TransitionAllocationBaselineTests return engine; } + private static PhysicsEngine BuildGroundEngine() + { + var heights = new byte[81]; + var heightTable = new float[256]; + var engine = new PhysicsEngine(); + engine.AddLandblock( + 0xA9B4FFFFu, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + 0f, + 0f); + return engine; + } + private static class Bodies { internal static readonly PhysicsBody Player = new(); + internal static readonly PhysicsBody Grounded = new(); internal static readonly PhysicsBody Remote = new(); internal static readonly PhysicsBody Projectile = new(); } diff --git a/tests/AcDream.Core.Tests/Physics/TransitionScratchDifferentialTests.cs b/tests/AcDream.Core.Tests/Physics/TransitionScratchDifferentialTests.cs new file mode 100644 index 00000000..5c099e8c --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/TransitionScratchDifferentialTests.cs @@ -0,0 +1,646 @@ +using System.Collections.Generic; +using System.Numerics; +using System.Reflection; +using AcDream.Core.Physics; +using DatReaderWriter.Types; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Differential referee for Slice I1. The fresh engine constructs a new +/// transition graph for every call; the production engine leases retained +/// scratch. Every deterministic result bit and every public body side effect +/// must remain identical while hostile mover shapes alternate. +/// +public sealed class TransitionScratchDifferentialTests +{ + private const uint Landblock = 0xA9B40000u; + private const uint Cell = Landblock | 0x0001u; + private const uint GfxObjId = 0x0100F100u; + + [Fact] + public void ReusedScratch_MatchesFreshAcrossTransitionFamilies() + { + RunSequence( + reuse => BuildBspEngine(reuse, BSPStepUpFixtures.TallWall, 0f), + new ResolveSpec( + "grounded two-sphere wall", + new Vector3(0.10f, 0f, 0f), + new Vector3(0.60f, 0f, 0f), + BSPStepUpFixtures.SphereRadius, + 1.20f, + 0.04f, + 0.40f, + true, + GroundedBody, + ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + 0x50000001u, + Coverage: CoverageKind.WallBlock), + new ResolveSpec( + "airborne wall collision", + new Vector3(0.10f, 0f, 2.00f), + new Vector3(0.60f, 0f, 1.50f), + BSPStepUpFixtures.SphereRadius, + 0f, + 0.04f, + 0.04f, + false, + AirborneBody, + ObjectInfoState.EdgeSlide, + 0x80000001u, + Coverage: CoverageKind.AirborneDescent), + new ResolveSpec( + "carried sliding normal", + new Vector3(0.10f, 0f, 2.00f), + new Vector3(0.60f, 0.12f, 1.50f), + BSPStepUpFixtures.SphereRadius, + 1.20f, + 0.04f, + 0.40f, + false, + SlidingBody, + ObjectInfoState.EdgeSlide, + 0x80000002u, + Coverage: CoverageKind.AirborneDescent), + new ResolveSpec( + "projectile single sphere", + new Vector3(0.10f, 0f, 2.00f), + new Vector3(0.60f, 0f, 2.00f), + 0.05f, + 0f, + 0f, + 0f, + false, + ProjectileBody, + ObjectInfoState.None, + 0x80000003u, + BeginOrientation: Quaternion.Identity, + EndOrientation: Quaternion.CreateFromAxisAngle( + Vector3.UnitZ, + 0.25f), + DesignatedTargetId: 0x50000099u, + Coverage: CoverageKind.Success), + new ResolveSpec( + "viewer camera", + new Vector3(0.10f, 0f, 2.00f), + new Vector3(0.60f, 0f, 2.00f), + 0.30f, + 0f, + 0f, + 0f, + false, + static () => null, + ObjectInfoState.IsViewer + | ObjectInfoState.PathClipped + | ObjectInfoState.FreeRotate + | ObjectInfoState.PerfectClip, + 0u, + Coverage: CoverageKind.Success)); + + RunSequence( + reuse => BuildBspEngine(reuse, BSPStepUpFixtures.LowStep, 0f), + new ResolveSpec( + "step up", + new Vector3(0.10f, 0f, 0f), + new Vector3(0.60f, 0f, 0f), + BSPStepUpFixtures.SphereRadius, + 1.20f, + 0.35f, + 0.60f, + true, + GroundedBody, + ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + 0x50000011u, + Coverage: CoverageKind.StepUp), + new ResolveSpec( + "step down", + new Vector3(0.80f, 0f, 0.25f), + new Vector3(0.10f, 0f, 0.25f), + BSPStepUpFixtures.SphereRadius, + 1.20f, + 0.35f, + 0.60f, + true, + UpperGroundedBody, + ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + 0x50000012u, + Coverage: CoverageKind.Success)); + + RunSequence( + reuse => BuildBspEngine(reuse, BSPStepUpFixtures.FlatRoof, -50f), + new ResolveSpec( + "airborne roof landing", + new Vector3(0f, 0f, 3.10f), + new Vector3(0f, 0f, 2.75f), + BSPStepUpFixtures.SphereRadius, + 1.20f, + 0.04f, + 0.40f, + false, + AirborneBody, + ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + 0x50000021u, + Coverage: CoverageKind.RoofLanding)); + + RunSequence( + reuse => BuildOpenEngine(reuse, includeLandblock: true), + new ResolveSpec( + "open outdoor success", + new Vector3(8f, 8f, 0f), + new Vector3(8.25f, 8f, 0f), + BSPStepUpFixtures.SphereRadius, + 1.20f, + 0.40f, + 1.50f, + true, + GroundedBody, + ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + 0x50000031u, + Coverage: CoverageKind.Success)); + + RunSequence( + reuse => BuildOpenEngine(reuse, includeLandblock: false), + new ResolveSpec( + "missing-cell failure", + new Vector3(8f, 8f, 2f), + new Vector3(8.25f, 8f, 2f), + BSPStepUpFixtures.SphereRadius, + 1.20f, + 0.40f, + 1.50f, + false, + AirborneBody, + ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + 0x50000032u, + CellId: 0u, + Coverage: CoverageKind.Failure)); + } + + [Fact] + public void ReusedScratch_MatchesFreshPlacementSearch() + { + PhysicsEngine fresh = BuildPlacementEngine(reuse: false); + PhysicsEngine reused = BuildPlacementEngine(reuse: true); + + ResolveResult expected = fresh.ResolvePlacement( + new Vector3(10f, 10f, 0f), + Cell, + 0.48f, + 1.835f, + 0.40f, + 0.40f, + ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + 0x50000101u); + ResolveResult actual = reused.ResolvePlacement( + new Vector3(10f, 10f, 0f), + Cell, + 0.48f, + 1.835f, + 0.40f, + 0.40f, + ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + 0x50000101u); + + AssertResolveBitwise(expected, actual, "placement"); + + // A second placement with a different self identity proves that the + // previous mover and collision-GUID list cannot leak through the lease. + expected = fresh.ResolvePlacement( + new Vector3(11f, 10f, 0f), + Cell, + 0.48f, + 1.835f, + 0.40f, + 0.40f, + ObjectInfoState.EdgeSlide, + 0x80000102u); + actual = reused.ResolvePlacement( + new Vector3(11f, 10f, 0f), + Cell, + 0.48f, + 1.835f, + 0.40f, + 0.40f, + ObjectInfoState.EdgeSlide, + 0x80000102u); + + AssertResolveBitwise(expected, actual, "placement after hostile identity"); + } + + private static void RunSequence( + Func buildEngine, + params ResolveSpec[] specs) + { + PhysicsEngine fresh = buildEngine(false); + PhysicsEngine reused = buildEngine(true); + + // Repeat in alternating directions. This leaves every retained field + // exposed to a materially different next mover and branch family. + for (int pass = 0; pass < 4; pass++) + { + for (int index = 0; index < specs.Length; index++) + { + int specIndex = (pass & 1) == 0 + ? index + : specs.Length - 1 - index; + ResolveSpec spec = specs[specIndex]; + PhysicsBody? expectedBody = spec.BodyFactory(); + PhysicsBody? actualBody = spec.BodyFactory(); + + ResolveResult expected = spec.Resolve(fresh, expectedBody); + ResolveResult actual = spec.Resolve(reused, actualBody); + + AssertCoverage(spec, expected); + AssertResolveBitwise(expected, actual, spec.Name); + AssertBodyBitwise(expectedBody, actualBody, spec.Name); + } + } + } + + private static PhysicsEngine BuildBspEngine( + bool reuse, + Func<(PhysicsBSPNode Root, Dictionary Resolved)> fixture, + float terrainZ) + { + var (root, resolved) = fixture(); + PhysicsEngine engine = BuildOpenEngine(reuse, includeLandblock: true, terrainZ); + var cache = new PhysicsDataCache(); + cache.RegisterGfxObjForTest(GfxObjId, new GfxObjPhysics + { + BSP = new PhysicsBSPTree { Root = root }, + PhysicsPolygons = new Dictionary(), + Vertices = new VertexArray(), + Resolved = resolved, + BoundingSphere = new Sphere + { + Origin = new Vector3(0f, 0f, 2.5f), + Radius = 15f, + }, + }); + engine.DataCache = cache; + engine.ShadowObjects.Register( + entityId: GfxObjId, + gfxObjId: GfxObjId, + worldPos: Vector3.Zero, + rotation: Quaternion.Identity, + radius: 15f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: Landblock, + collisionType: ShadowCollisionType.BSP, + scale: 1f); + return engine; + } + + private static PhysicsEngine BuildOpenEngine( + bool reuse, + bool includeLandblock, + float terrainZ = 0f) + { + var engine = new PhysicsEngine(reuse); + if (!includeLandblock) + return engine; + + var heights = new byte[81]; + var heightTable = new float[256]; + Array.Fill(heightTable, terrainZ); + engine.AddLandblock( + Landblock | 0xFFFFu, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + 0f, + 0f); + return engine; + } + + private static PhysicsEngine BuildPlacementEngine(bool reuse) + { + PhysicsEngine engine = BuildOpenEngine(reuse, includeLandblock: true); + engine.DataCache = new PhysicsDataCache(); + RegisterSphere( + engine, + 0x50000101u, + new Vector3(10f, 10f, 0.48f), + EntityCollisionFlags.IsPlayer | EntityCollisionFlags.IsCreature); + RegisterSphere( + engine, + 0x50000102u, + new Vector3(10.10f, 10f, 0.48f), + EntityCollisionFlags.IsCreature); + return engine; + } + + private static void RegisterSphere( + PhysicsEngine engine, + uint entityId, + Vector3 center, + EntityCollisionFlags flags) + { + engine.ShadowObjects.Register( + entityId, + gfxObjId: 0u, + worldPos: center, + rotation: Quaternion.Identity, + radius: 0.48f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: Landblock, + collisionType: ShadowCollisionType.Sphere, + cylHeight: 0f, + scale: 1f, + state: 0u, + flags: flags, + seedCellId: Cell, + isStatic: false); + } + + private static PhysicsBody GroundedBody() + => new() + { + State = PhysicsStateFlags.Gravity, + TransientState = TransientStateFlags.Active + | TransientStateFlags.Contact + | TransientStateFlags.OnWalkable, + ContactPlaneValid = true, + ContactPlane = new Plane(Vector3.UnitZ, 0f), + ContactPlaneCellId = Cell, + WalkablePolygonValid = true, + WalkablePlane = new Plane(Vector3.UnitZ, 0f), + WalkableVertices = + [ + new(-2f, -2f, 0f), + new(2f, -2f, 0f), + new(2f, 2f, 0f), + new(-2f, 2f, 0f), + ], + WalkableUp = Vector3.UnitZ, + Velocity = new Vector3(0.25f, 0.125f, 0f), + }; + + private static PhysicsBody UpperGroundedBody() + { + PhysicsBody body = GroundedBody(); + body.ContactPlane = new Plane(Vector3.UnitZ, -0.25f); + body.WalkablePlane = new Plane(Vector3.UnitZ, -0.25f); + body.WalkableVertices = + [ + new(0.2f, -2f, 0.25f), + new(2f, -2f, 0.25f), + new(2f, 2f, 0.25f), + new(0.2f, 2f, 0.25f), + ]; + return body; + } + + private static PhysicsBody AirborneBody() + => new() + { + State = PhysicsStateFlags.Gravity, + TransientState = TransientStateFlags.Active, + Velocity = new Vector3(0.25f, 0f, -0.08f), + }; + + private static PhysicsBody SlidingBody() + => new() + { + State = PhysicsStateFlags.Gravity, + TransientState = TransientStateFlags.Active + | TransientStateFlags.Sliding + | TransientStateFlags.StationaryFall, + SlidingNormal = Vector3.UnitY, + Velocity = new Vector3(0.25f, 0.125f, -0.08f), + }; + + private static PhysicsBody ProjectileBody() + => new() + { + State = PhysicsStateFlags.Missile + | PhysicsStateFlags.PathClipped + | PhysicsStateFlags.Inelastic, + TransientState = TransientStateFlags.Active, + Velocity = new Vector3(20f, 0f, 0f), + }; + + private static void AssertResolveBitwise( + ResolveResult expected, + ResolveResult actual, + string context) + { + AssertVectorBitwise(expected.Position, actual.Position, context); + Assert.Equal(expected.CellId, actual.CellId); + Assert.Equal(expected.IsOnGround, actual.IsOnGround); + Assert.Equal(expected.CollisionNormalValid, actual.CollisionNormalValid); + AssertVectorBitwise(expected.CollisionNormal, actual.CollisionNormal, context); + Assert.Equal(expected.Ok, actual.Ok); + AssertQuaternionBitwise(expected.Orientation, actual.Orientation, context); + Assert.Equal(expected.InContact, actual.InContact); + Assert.Equal(expected.OnWalkable, actual.OnWalkable); + AssertPlaneBitwise(expected.ContactPlane, actual.ContactPlane, context); + Assert.Equal(expected.ContactPlaneCellId, actual.ContactPlaneCellId); + Assert.Equal(expected.ContactPlaneIsWater, actual.ContactPlaneIsWater); + } + + private static void AssertCoverage(ResolveSpec spec, ResolveResult result) + { + switch (spec.Coverage) + { + case CoverageKind.None: + return; + case CoverageKind.Success: + Assert.True(result.Ok, $"{spec.Name} did not reach a successful transition."); + return; + case CoverageKind.Failure: + Assert.False(result.Ok, $"{spec.Name} did not reach the failure branch."); + return; + case CoverageKind.WallBlock: + Assert.True( + result.CollisionNormalValid + || result.Position.X < spec.TargetPos.X, + $"{spec.Name} did not exercise wall collision/blocking."); + return; + case CoverageKind.AirborneDescent: + Assert.True( + result.Position.Z < spec.CurrentPos.Z, + $"{spec.Name} did not preserve airborne descent."); + return; + case CoverageKind.StepUp: + Assert.True( + result.Position.Z >= 0.25f - PhysicsGlobals.EPSILON * 10f, + $"{spec.Name} did not reach the upper floor."); + return; + case CoverageKind.RoofLanding: + Assert.True( + result.InContact || result.IsOnGround, + $"{spec.Name} did not reach the roof contact branch."); + return; + default: + throw new ArgumentOutOfRangeException(); + } + } + + private static void AssertBodyBitwise( + PhysicsBody? expected, + PhysicsBody? actual, + string context) + { + if (expected is null || actual is null) + { + Assert.Equal(expected is null, actual is null); + return; + } + + foreach (PropertyInfo property in typeof(PhysicsBody).GetProperties( + BindingFlags.Instance | BindingFlags.Public)) + { + if (property.GetIndexParameters().Length != 0) + continue; + + object? expectedValue = property.GetValue(expected); + object? actualValue = property.GetValue(actual); + string memberContext = $"{context}: PhysicsBody.{property.Name}"; + + switch (expectedValue) + { + case float expectedFloat: + AssertFloatBitwise( + expectedFloat, + Assert.IsType(actualValue), + memberContext); + break; + case double expectedDouble: + Assert.Equal( + BitConverter.DoubleToInt64Bits(expectedDouble), + BitConverter.DoubleToInt64Bits( + Assert.IsType(actualValue))); + break; + case Vector3 expectedVector: + AssertVectorBitwise( + expectedVector, + Assert.IsType(actualValue), + memberContext); + break; + case Quaternion expectedRotation: + AssertQuaternionBitwise( + expectedRotation, + Assert.IsType(actualValue), + memberContext); + break; + case Plane expectedPlane: + AssertPlaneBitwise( + expectedPlane, + Assert.IsType(actualValue), + memberContext); + break; + case Vector3[] expectedVertices: + { + Vector3[] actualVertices = Assert.IsType(actualValue); + Assert.Equal(expectedVertices.Length, actualVertices.Length); + for (int i = 0; i < expectedVertices.Length; i++) + { + AssertVectorBitwise( + expectedVertices[i], + actualVertices[i], + $"{memberContext}[{i}]"); + } + break; + } + case null: + Assert.Null(actualValue); + break; + default: + Assert.Equal(expectedValue, actualValue); + break; + } + } + } + + private static void AssertVectorBitwise( + Vector3 expected, + Vector3 actual, + string context) + { + AssertFloatBitwise(expected.X, actual.X, $"{context}.X"); + AssertFloatBitwise(expected.Y, actual.Y, $"{context}.Y"); + AssertFloatBitwise(expected.Z, actual.Z, $"{context}.Z"); + } + + private static void AssertQuaternionBitwise( + Quaternion expected, + Quaternion actual, + string context) + { + AssertFloatBitwise(expected.X, actual.X, $"{context}.X"); + AssertFloatBitwise(expected.Y, actual.Y, $"{context}.Y"); + AssertFloatBitwise(expected.Z, actual.Z, $"{context}.Z"); + AssertFloatBitwise(expected.W, actual.W, $"{context}.W"); + } + + private static void AssertPlaneBitwise( + Plane expected, + Plane actual, + string context) + { + AssertVectorBitwise(expected.Normal, actual.Normal, $"{context}.Normal"); + AssertFloatBitwise(expected.D, actual.D, $"{context}.D"); + } + + private static void AssertFloatBitwise( + float expected, + float actual, + string context) + => Assert.True( + BitConverter.SingleToInt32Bits(expected) + == BitConverter.SingleToInt32Bits(actual), + $"{context}: expected 0x{BitConverter.SingleToInt32Bits(expected):X8}, " + + $"actual 0x{BitConverter.SingleToInt32Bits(actual):X8}"); + + private sealed record ResolveSpec( + string Name, + Vector3 CurrentPos, + Vector3 TargetPos, + float SphereRadius, + float SphereHeight, + float StepUpHeight, + float StepDownHeight, + bool IsOnGround, + Func BodyFactory, + ObjectInfoState MoverFlags, + uint MovingEntityId, + Vector3? LocalSphereOrigin = null, + Quaternion? BeginOrientation = null, + Quaternion? EndOrientation = null, + uint DesignatedTargetId = 0, + uint CellId = TransitionScratchDifferentialTests.Cell, + CoverageKind Coverage = CoverageKind.None) + { + internal ResolveResult Resolve(PhysicsEngine engine, PhysicsBody? body) + => engine.ResolveWithTransition( + CurrentPos, + TargetPos, + CellId, + SphereRadius, + SphereHeight, + StepUpHeight, + StepDownHeight, + IsOnGround, + body, + MoverFlags, + MovingEntityId, + LocalSphereOrigin, + BeginOrientation, + EndOrientation, + DesignatedTargetId); + } + + private enum CoverageKind + { + None, + Success, + Failure, + WallBlock, + AirborneDescent, + StepUp, + RoofLanding, + } +} diff --git a/tests/AcDream.Core.Tests/Physics/TransitionScratchResetTests.cs b/tests/AcDream.Core.Tests/Physics/TransitionScratchResetTests.cs new file mode 100644 index 00000000..f657be91 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/TransitionScratchResetTests.cs @@ -0,0 +1,417 @@ +using System.Collections.Generic; +using System.Numerics; +using System.Reflection; +using System.Threading; +using AcDream.Core.Physics; +using DatReaderWriter.Types; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Structural proof for the retained transition lifetime introduced by Slice +/// I1. Reflection deliberately makes a newly added state field fail this test +/// until its poison/reset representation is defined. +/// +public sealed class TransitionScratchResetTests +{ + [Fact] + public void ResetForReuse_PoisonsEveryStoredMemberAndMatchesFreshState() + { + var transition = new Transition(); + PoisonStoredState(transition); + + ObjectInfo objectInfo = transition.ObjectInfo; + SpherePath path = transition.SpherePath; + CollisionInfo collision = transition.CollisionInfo; + Sphere[] localSpheres = path.LocalSphere; + Sphere[] globalSpheres = path.GlobalSphere; + Sphere[] currentSpheres = path.GlobalCurrCenter; + Sphere[] allSpheres = + [ + .. localSpheres, + .. globalSpheres, + .. currentSpheres, + ]; + Vector3[] retainedWalkable = Assert.IsType( + path.RetainedWalkableVertexStorage); + Vector3[] retainedLastWalkable = Assert.IsType( + path.RetainedLastWalkableVertexStorage); + CellArray retainedCells = path.CellCandidates; + CellOrderScratchArena retainedOrder = path.OrderedCellScratch; + List[] retainedOrderRecords = + retainedOrder.RetainedRecords.ToArray(); + List retainedCollisions = collision.CollideObjectGuids; + + transition.ResetForReuse(); + + Assert.Same(objectInfo, transition.ObjectInfo); + Assert.Same(path, transition.SpherePath); + Assert.Same(collision, transition.CollisionInfo); + Assert.Same(localSpheres, path.LocalSphere); + Assert.Same(globalSpheres, path.GlobalSphere); + Assert.Same(currentSpheres, path.GlobalCurrCenter); + Assert.Same(retainedWalkable, path.RetainedWalkableVertexStorage); + Assert.Same(retainedLastWalkable, path.RetainedLastWalkableVertexStorage); + Assert.Same(retainedCells, path.CellCandidates); + Assert.Same(retainedOrder, path.OrderedCellScratch); + Assert.Equal(0, retainedOrder.ActiveDepth); + Assert.Equal(retainedOrderRecords.Length, retainedOrder.RetainedRecordCount); + for (int i = 0; i < retainedOrderRecords.Length; i++) + { + Assert.Same( + retainedOrderRecords[i], + retainedOrder.RetainedRecords[i]); + Assert.Empty(retainedOrderRecords[i]); + } + Assert.Same(retainedCollisions, collision.CollideObjectGuids); + + Sphere[] resetSpheres = + [ + .. path.LocalSphere, + .. path.GlobalSphere, + .. path.GlobalCurrCenter, + ]; + for (int i = 0; i < allSpheres.Length; i++) + Assert.Same(allSpheres[i], resetSpheres[i]); + + Assert.All(retainedWalkable, value => AssertBitwise(Vector3.Zero, value)); + Assert.All(retainedLastWalkable, value => AssertBitwise(Vector3.Zero, value)); + AssertFreshState(new Transition(), transition); + } + + [Fact] + public void WalkableStorage_ReusesOnlyAnExactLogicalLength() + { + var path = new SpherePath(); + var first = new[] + { + new Vector3(1f, 2f, 3f), + new Vector3(4f, 5f, 6f), + new Vector3(7f, 8f, 9f), + }; + var second = new[] + { + new Vector3(-1f, -2f, -3f), + new Vector3(-4f, -5f, -6f), + new Vector3(-7f, -8f, -9f), + }; + + path.SetWalkable(new Plane(Vector3.UnitZ, -3f), first, Vector3.UnitZ); + Vector3[] firstStorage = Assert.IsType(path.WalkableVertices); + Vector3[] firstLastStorage = Assert.IsType(path.LastWalkableVertices); + + path.ResetForReuse(); + path.SetWalkable(new Plane(Vector3.UnitZ, 3f), second, Vector3.UnitZ); + + Assert.Same(firstStorage, path.WalkableVertices); + Assert.Same(firstLastStorage, path.LastWalkableVertices); + AssertVectorsBitwise(second, Assert.IsType(path.WalkableVertices)); + AssertVectorsBitwise(second, Assert.IsType(path.LastWalkableVertices)); + + Vector3[] fourVertices = + [ + .. second, + new Vector3(10f, 11f, 12f), + ]; + path.SetWalkable(new Plane(Vector3.UnitZ, 0f), fourVertices, Vector3.UnitZ); + + Assert.NotSame(firstStorage, path.WalkableVertices); + Assert.NotSame(firstLastStorage, path.LastWalkableVertices); + Assert.Equal(4, path.WalkableVertices!.Length); + Assert.Equal(4, path.LastWalkableVertices!.Length); + AssertVectorsBitwise(fourVertices, path.WalkableVertices); + AssertVectorsBitwise(fourVertices, path.LastWalkableVertices); + } + + [Fact] + public void PhysicsBodyWalkablePublication_RetainsOnlyAnExactLength() + { + var body = new PhysicsBody(); + Vector3[] triangle = + [ + new(1f, 2f, 3f), + new(4f, 5f, 6f), + new(7f, 8f, 9f), + ]; + + body.SetWalkableVerticesExact(triangle); + Vector3[] first = Assert.IsType(body.WalkableVertices); + body.WalkableVertices = null; + body.SetWalkableVerticesExact(triangle); + Assert.Same(first, body.WalkableVertices); + + body.SetWalkableVerticesExact( + [ + .. triangle, + new Vector3(10f, 11f, 12f), + ]); + Assert.NotSame(first, body.WalkableVertices); + Assert.Equal(4, body.WalkableVertices!.Length); + } + + [Fact] + public void Arena_IsTenDeepDistinctAndReusesRecordsInLifoOrder() + { + var arena = new TransitionScratchArena(); + var leased = new Transition[TransitionScratchArena.Capacity]; + + for (int i = 0; i < leased.Length; i++) + { + leased[i] = arena.Rent(); + Assert.Equal(i + 1, arena.ActiveDepth); + Assert.DoesNotContain( + leased[i], + leased.AsSpan(0, i).ToArray()); + } + + Assert.Throws(() => arena.Rent()); + + for (int i = leased.Length - 1; i >= 0; i--) + { + arena.Return(leased[i]); + Assert.Equal(i, arena.ActiveDepth); + } + + Transition reused = arena.Rent(); + Assert.Same(leased[0], reused); + reused.ObjectInfo.State = ObjectInfoState.IsPlayer; + arena.Return(reused); + + reused = arena.Rent(); + Assert.Equal(ObjectInfoState.None, reused.ObjectInfo.State); + arena.Return(reused); + } + + [Fact] + public void Arena_RejectsOutOfOrderAndCrossThreadUse() + { + var arena = new TransitionScratchArena(); + Transition outer = arena.Rent(); + Transition inner = arena.Rent(); + + Assert.Throws(() => arena.Return(outer)); + arena.Return(inner); + + Exception? crossThreadError = null; + var thread = new Thread(() => + { + try + { + arena.Rent(); + } + catch (Exception error) + { + crossThreadError = error; + } + }); + thread.Start(); + thread.Join(); + + Assert.IsType(crossThreadError); + Assert.Equal(1, arena.ActiveDepth); + arena.Return(outer); + Assert.Equal(0, arena.ActiveDepth); + } + + private static void PoisonStoredState(object instance) + { + foreach (FieldInfo field in instance.GetType().GetFields( + BindingFlags.Instance + | BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.DeclaredOnly)) + { + object? current = field.GetValue(instance); + if (field.IsInitOnly) + { + switch (current) + { + case ObjectInfo objectInfo: + PoisonStoredState(objectInfo); + break; + case SpherePath path: + PoisonStoredState(path); + break; + case CollisionInfo collision: + PoisonStoredState(collision); + break; + case Sphere[] spheres: + foreach (Sphere sphere in spheres) + { + sphere.Origin = new Vector3(11f, 12f, 13f); + sphere.Radius = 14f; + } + break; + case CellArray cells: + cells.Add(0xA9B40001u); + break; + case CellOrderScratchArena orderScratch: + orderScratch.Rent().Add(0xA9B40001u); + orderScratch.Rent().Add(0xA9B40100u); + break; + case List values: + values.Add(0xDEADBEEFu); + break; + default: + throw new InvalidOperationException( + $"No retained-state poison rule for " + + $"{instance.GetType().Name}.{field.Name} " + + $"({field.FieldType.Name})."); + } + + continue; + } + + field.SetValue(instance, PoisonValue(field.FieldType)); + } + } + + private static object PoisonValue(Type type) + { + if (type == typeof(bool)) + return true; + if (type == typeof(int)) + return 37; + if (type == typeof(uint)) + return 0xC0FFEEu; + if (type == typeof(float)) + return 17.25f; + if (type == typeof(Vector3)) + return new Vector3(1.25f, -2.5f, 3.75f); + if (type == typeof(Quaternion)) + return new Quaternion(1f, 2f, 3f, 4f); + if (type == typeof(Plane)) + return new Plane(new Vector3(5f, 6f, 7f), 8f); + if (type == typeof(Vector3[])) + { + return new[] + { + new Vector3(1f, 2f, 3f), + new Vector3(4f, 5f, 6f), + new Vector3(7f, 8f, 9f), + }; + } + if (type == typeof(Vector3?)) + return (Vector3?)new Vector3(9f, 8f, 7f); + if (type == typeof(uint?)) + return (uint?)0xBADF00Du; + if (type.IsEnum) + return Enum.ToObject(type, uint.MaxValue); + + throw new InvalidOperationException( + $"No poison value for stored type {type.FullName}."); + } + + private static void AssertFreshState(object expected, object actual) + { + Assert.Equal(expected.GetType(), actual.GetType()); + + foreach (FieldInfo field in expected.GetType().GetFields( + BindingFlags.Instance + | BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.DeclaredOnly)) + { + object? expectedValue = field.GetValue(expected); + object? actualValue = field.GetValue(actual); + + if (field.Name is "_walkableVertexStorage" + or "_lastWalkableVertexStorage") + { + Vector3[] retained = Assert.IsType(actualValue); + Assert.All(retained, value => AssertBitwise(Vector3.Zero, value)); + continue; + } + + switch (expectedValue) + { + case ObjectInfo expectedObject: + AssertFreshState(expectedObject, Assert.IsType(actualValue)); + break; + case SpherePath expectedPath: + AssertFreshState(expectedPath, Assert.IsType(actualValue)); + break; + case CollisionInfo expectedCollision: + AssertFreshState( + expectedCollision, + Assert.IsType(actualValue)); + break; + case Sphere[] expectedSpheres: + { + Sphere[] actualSpheres = Assert.IsType(actualValue); + Assert.Equal(expectedSpheres.Length, actualSpheres.Length); + for (int i = 0; i < expectedSpheres.Length; i++) + { + AssertBitwise(expectedSpheres[i].Origin, actualSpheres[i].Origin); + AssertBitwise(expectedSpheres[i].Radius, actualSpheres[i].Radius); + } + break; + } + case CellArray expectedCells: + Assert.Equal(expectedCells.OrderedIds, Assert.IsType(actualValue).OrderedIds); + break; + case CellOrderScratchArena: + { + CellOrderScratchArena actualScratch = + Assert.IsType(actualValue); + Assert.Equal(0, actualScratch.ActiveDepth); + Assert.All(actualScratch.RetainedRecords, Assert.Empty); + break; + } + case List expectedValues: + Assert.Equal(expectedValues, Assert.IsType>(actualValue)); + break; + case Vector3 expectedVector: + AssertBitwise(expectedVector, Assert.IsType(actualValue)); + break; + case Quaternion expectedRotation: + AssertBitwise(expectedRotation, Assert.IsType(actualValue)); + break; + case Plane expectedPlane: + AssertBitwise(expectedPlane, Assert.IsType(actualValue)); + break; + case null: + Assert.Null(actualValue); + break; + default: + Assert.Equal(expectedValue, actualValue); + break; + } + } + } + + private static void AssertVectorsBitwise( + IReadOnlyList expected, + IReadOnlyList actual) + { + Assert.Equal(expected.Count, actual.Count); + for (int i = 0; i < expected.Count; i++) + AssertBitwise(expected[i], actual[i]); + } + + private static void AssertBitwise(Vector3 expected, Vector3 actual) + { + AssertBitwise(expected.X, actual.X); + AssertBitwise(expected.Y, actual.Y); + AssertBitwise(expected.Z, actual.Z); + } + + private static void AssertBitwise(Quaternion expected, Quaternion actual) + { + AssertBitwise(expected.X, actual.X); + AssertBitwise(expected.Y, actual.Y); + AssertBitwise(expected.Z, actual.Z); + AssertBitwise(expected.W, actual.W); + } + + private static void AssertBitwise(Plane expected, Plane actual) + { + AssertBitwise(expected.Normal, actual.Normal); + AssertBitwise(expected.D, actual.D); + } + + private static void AssertBitwise(float expected, float actual) + => Assert.Equal( + BitConverter.SingleToInt32Bits(expected), + BitConverter.SingleToInt32Bits(actual)); +}