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));
+}