perf(physics): reuse reset-complete transition scratch

Mirror retail's ten-deep LIFO transition lifetime, retain all query scratch with complete reset contracts, and remove Tier-0 enum boxing without changing collision decisions. Fresh and retained engines are bit-identical across the expanded oracle, while measured transition profiles now allocate 0 bytes per resolve.
This commit is contained in:
Erik 2026-07-25 14:37:02 +02:00
parent 2effba5127
commit 16d182c2f0
19 changed files with 3037 additions and 1228 deletions

View file

@ -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 accepted the physical-display visual matrix, and G5 removed the production
`InteriorEntityPartition`. The ordinary production profile sustains 519.7 FPS `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. 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 Slice H's retained UI/frame, bounded-light, and pooled/borrowed/direct network
work is complete; the exact seven-checkpoint connected lifecycle/reconnect gate 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 `docs/plans/2026-07-25-modern-runtime-slice-i.md`. The exact G4 visual rollback
is `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`; do not revert G3 or is `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`; do not revert G3 or
the portal-warmup corrections. Evidence: the portal-warmup corrections. Evidence:
`docs/research/2026-07-25-slice-h-closeout.md` and `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 **Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated behavior-preserving `GameWindow` decomposition slices and the automated

View file

@ -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 accepted the physical-display visual matrix, and G5 removed the production
`InteriorEntityPartition`. The ordinary production profile sustains 519.7 FPS `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. 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 Slice H's retained UI/frame, bounded-light, and pooled/borrowed/direct network
work is complete; the exact seven-checkpoint connected lifecycle/reconnect gate 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 `docs/plans/2026-07-25-modern-runtime-slice-i.md`. The exact G4 visual rollback
is `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`; do not revert G3 or is `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`; do not revert G3 or
the portal-warmup corrections. Evidence: the portal-warmup corrections. Evidence:
`docs/research/2026-07-25-slice-h-closeout.md` and `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 **Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated behavior-preserving `GameWindow` decomposition slices and the automated

View file

@ -327,7 +327,7 @@ covered by a zero-managed-allocation test. Evidence:
## #237 — PhysicsEngine allocates a fresh Transition graph per resolve call ## #237 — PhysicsEngine allocates a fresh Transition graph per resolve call
**Status:** OPEN **Status:** DONE — 2026-07-25, Modern Runtime Slice I1
**Severity:** MEDIUM **Severity:** MEDIUM
**Filed:** 2026-07-24 **Filed:** 2026-07-24
**Component:** physics **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 scales with active-mover count (combat/crowds), a scenario the audit's
dwell-heavy route under-measured. dwell-heavy route under-measured.
**Root cause / status:** 2026-07-24 audit review finding. Pooling is only **Resolution:** Each `PhysicsEngine` now owns retail-shaped ten-deep,
allowed under plan Slice I's identity/lifetime-test condition (the adjacent same-thread, LIFO transition scratch. Every stored member is reset and
`LiveEntityAnimationScheduler` scratch-reuse pattern is the precedent) — reflection-poisoned in tests; exact-length walkable, candidate-cell, collision
Transition state must be provably reset-complete between movers or a pooled GUID, and reentrant neighbor-order storage retains identity without exposing
graph corrupts collision state, which is worse than the allocation. 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,5126,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`; **Files:** `src/AcDream.Core/Physics/TransitionScratchArena.cs`;
`src/AcDream.Core/Physics/TransitionTypes.cs:360-367,694-698`. `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`.
--- ---

View file

@ -1647,14 +1647,16 @@ port in any phase — no separate listing here.
> come from the retained frame product. The exact lifecycle/reconnect matrix, > come from the retained frame product. The exact lifecycle/reconnect matrix,
> capped/uncapped/dense correctness routes, and ordinary production profile > capped/uncapped/dense correctness routes, and ordinary production profile
> pass. The final stable ordinary-production minute sustains 519.7 FPS with > 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 > CPU/GPU p50 1.869/1.096 ms. Slice I1 now removes the isolated
> active Slice-I transition/query-scratch owner. > 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 > The historical exact cutover
> rollback remains > rollback remains
> `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`. Slice H is complete: > `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`. Slice H is complete:
> retained UI/frame work, exact bounded light selection, and > retained UI/frame work, exact bounded light selection, and
> pooled/borrowed/direct network I/O pass 8,292 Release tests / 5 skips and the > 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). > [its detailed plan](2026-07-25-modern-runtime-slice-i.md).
**Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the **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` | | 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) | | 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 2087ms → 610ms, alloc spikes (3075MB single-frame) eliminated, gen2 GC 511/window → ~0; user confirms FPS steady. 4 commits `b8c05e2b`..`91afea24` | | **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 2087ms → 610ms, alloc spikes (3075MB single-frame) eliminated, gen2 GC 511/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) | | 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 | | 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 | | 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 | | MP5 | Job-system parallelism | ⚪ stretch, evidence-gated |
--- ---

View file

@ -116,10 +116,12 @@ the retained frame product without building the old
correctness routes, exact seven-checkpoint lifecycle/reconnect gate, and correctness routes, exact seven-checkpoint lifecycle/reconnect gate, and
ordinary production profile pass. The final stable production minute sustains 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. 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 Slice I1 subsequently removes the isolated per-resolve owner: player, remote,
tests / 5 skips. Slice H is complete: retained UI/frame work, exact bounded projectile, camera, and grounded walkable-publication profiles are all
light selection, and pooled/borrowed/direct network I/O pass. Slice I is active 0 B/resolve with bit-identical fresh-vs-reused outcomes. The complete I1
at I1 under 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). [`2026-07-25-modern-runtime-slice-i.md`](2026-07-25-modern-runtime-slice-i.md).
The historical exact G4 visual rollback remains The historical exact G4 visual rollback remains
`git revert ef1d263337997bb030eadb7b8e71d73dc659907a`. `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`.

View file

@ -2,8 +2,8 @@
**Date:** 2026-07-24 **Date:** 2026-07-24
**Status:** Slices AH and I0 are complete. Slices FL were explicitly **Status:** Slices AH and I0I1 are complete. Slices FL were explicitly
approved 2026-07-24; Slice I1 is active. approved 2026-07-24; Slice I2 is active.
**Scope:** Reconcile and sequence the existing Modern Pipeline (`MP`) and **Scope:** Reconcile and sequence the existing Modern Pipeline (`MP`) and
Linux/headless (`LH`) tracks using the 2026-07-24 connected performance audit. 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: 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 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. 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 Frame-thread allocation at the G5 checkpoint was 22.34 KiB median. I1 has now
the four-mover baseline assign that remainder primarily to reset-complete replaced fresh per-resolve transition graphs and query temporaries with
`Transition` collision/query scratch. I0 has pinned the retail oracle and reset-complete retained scratch: player, remote, projectile, camera, and
fixtures, so I1 is the current execution point. Evidence: 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) [`../research/2026-07-25-slice-g5-production-profile.md`](../research/2026-07-25-slice-g5-production-profile.md)
and 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). [`2026-07-25-modern-runtime-slice-i.md`](2026-07-25-modern-runtime-slice-i.md).
The intended order is therefore: The intended order is therefore:
@ -1212,7 +1216,7 @@ honest metrics + committed baselines (A — exit criteria block C)
-> incremental render scene (F) -> incremental render scene (F)
-> delta GPU submission (G) -> delta GPU submission (G)
-> residual frame cleanup (H-a, H-b, H-c) -> 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) -> presentation-independent runtime (J — §0.3 authorization)
-> Linux/headless/multi-session (K) -> Linux/headless/multi-session (K)
-> evidence-gated GPU jobs (L) -> evidence-gated GPU jobs (L)

View file

@ -1,8 +1,7 @@
# Modern runtime Slice I — flat collision assets and zero-allocation physics # Modern runtime Slice I — flat collision assets and zero-allocation physics
**Status:** I0 COMPLETE — retail oracle, representative graph fixtures, and **Status:** I0I1 COMPLETE — retail oracle, graph fixtures, and
four-mover allocation baselines fixed; G5 production closeout complete; I1 reset-complete zero-allocation transition/query scratch fixed; I2 active
active
**Parent:** `2026-07-24-modern-runtime-architecture.md`, Slice I **Parent:** `2026-07-24-modern-runtime-architecture.md`, Slice I
**Purpose:** remove parsed DAT object graphs and steady collision allocations **Purpose:** remove parsed DAT object graphs and steady collision allocations
without changing one retail collision decision, float, comparison, or traversal 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 Gate: bit-identical `ResolveResult` and body side effects, no state leakage
when alternating hostile fixtures between different mover IDs, and zero 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 ### I2 — immutable flat collision schema and flattener

View file

@ -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.

View file

@ -32,8 +32,8 @@ public static class BSPQuery
// Internal mutable sphere helper // Internal mutable sphere helper
// //
// ACE's Sphere class is mutable (Center + Radius). We mirror that with a // 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, // Small internal value so query scratch lives on the stack. Mutation sites
// exactly as ACE does when passing Sphere by reference through find_walkable. // pass it explicitly by ref, exactly matching retail's Sphere* boundaries.
// ========================================================================= // =========================================================================
/// <summary> /// <summary>
@ -42,7 +42,7 @@ public static class BSPQuery
/// Mirrors ACE's Sphere(Center, Radius) which is passed by reference through /// Mirrors ACE's Sphere(Center, Radius) which is passed by reference through
/// the BSP recursive calls. /// the BSP recursive calls.
/// </summary> /// </summary>
internal sealed class CollisionSphere internal struct CollisionSphere
{ {
public Vector3 Center; public Vector3 Center;
public float Radius; public float Radius;
@ -53,11 +53,6 @@ public static class BSPQuery
Radius = radius; Radius = radius;
} }
public CollisionSphere(CollisionSphere other)
{
Center = other.Center;
Radius = other.Radius;
}
} }
// ========================================================================= // =========================================================================
@ -351,7 +346,7 @@ public static class BSPQuery
private static bool AdjustSphereToPlane( private static bool AdjustSphereToPlane(
ResolvedPolygon poly, ResolvedPolygon poly,
SpherePath path, SpherePath path,
CollisionSphere validPos, ref CollisionSphere validPos,
Vector3 movement) Vector3 movement)
{ {
// A6.P1: snapshot inputs for the [push-back] probe (cheap copy of // A6.P1: snapshot inputs for the [push-back] probe (cheap copy of
@ -499,22 +494,17 @@ public static class BSPQuery
: Vector3.UnitZ; : Vector3.UnitZ;
} }
private static Vector3[] TransformVertices( private static Plane BuildWorldPlane(
ReadOnlySpan<Vector3> vertices, Vector3 worldNormal,
ReadOnlySpan<Vector3> localVertices,
Quaternion localToWorld, Quaternion localToWorld,
float scale, float scale,
Vector3 worldOrigin) Vector3 worldOrigin)
{ {
var result = new Vector3[vertices.Length]; float d = localVertices.Length > 0
for (int i = 0; i < vertices.Length; i++) ? -Vector3.Dot(
result[i] = Vector3.Transform(vertices[i] * scale, localToWorld) + worldOrigin; worldNormal,
return result; Vector3.Transform(localVertices[0] * scale, localToWorld) + worldOrigin)
}
private static Plane BuildWorldPlane(Vector3 worldNormal, ReadOnlySpan<Vector3> worldVertices)
{
float d = worldVertices.Length > 0
? -Vector3.Dot(worldNormal, worldVertices[0])
: 0f; : 0f;
return new Plane(worldNormal, d); return new Plane(worldNormal, d);
} }
@ -537,8 +527,9 @@ public static class BSPQuery
/// </summary> /// </summary>
private static void AdjustToPlacementPoly( private static void AdjustToPlacementPoly(
ResolvedPolygon poly, ResolvedPolygon poly,
CollisionSphere validPos, ref CollisionSphere validPos,
CollisionSphere? validPos2, ref CollisionSphere validPos2,
bool hasValidPos2,
float radius, float radius,
bool centerSolid, bool centerSolid,
bool clearCell) bool clearCell)
@ -563,7 +554,7 @@ public static class BSPQuery
var offset = moveDir * pushAmt; var offset = moveDir * pushAmt;
validPos.Center += offset; validPos.Center += offset;
if (validPos2 is not null) if (hasValidPos2)
validPos2.Center += offset; validPos2.Center += offset;
} }
@ -704,8 +695,8 @@ public static class BSPQuery
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// Note: validPos is a reference type so mutations propagate back to the caller /// Note: validPos is passed by reference so mutations propagate back to the
/// automatically, mirroring ACE's by-ref Sphere passing. /// caller, mirroring retail's by-ref Sphere passing.
/// </para> /// </para>
/// ///
/// <para> /// <para>
@ -716,7 +707,7 @@ public static class BSPQuery
PhysicsBSPNode? node, PhysicsBSPNode? node,
Dictionary<ushort, ResolvedPolygon> resolved, Dictionary<ushort, ResolvedPolygon> resolved,
SpherePath path, SpherePath path,
CollisionSphere validPos, ref CollisionSphere validPos,
Vector3 movement, Vector3 movement,
Vector3 up, Vector3 up,
ref ResolvedPolygon? hitPoly, ref ResolvedPolygon? hitPoly,
@ -736,7 +727,8 @@ public static class BSPQuery
if (!resolved.TryGetValue(polyId, out var poly)) continue; if (!resolved.TryGetValue(polyId, out var poly)) continue;
bool walkable = WalkableHitsSphere(poly, path, validPos, up); 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) if (walkable && adjusted)
{ {
@ -755,22 +747,22 @@ public static class BSPQuery
if (dist >= reach) 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); ref hitPoly, ref hitPolyId, ref changed);
return; return;
} }
if (dist <= -reach) 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); ref hitPoly, ref hitPolyId, ref changed);
return; return;
} }
// Straddles. // Straddles.
FindWalkableInternal(node.PosNode, resolved, path, validPos, movement, up, FindWalkableInternal(node.PosNode, resolved, path, ref validPos, movement, up,
ref hitPoly, ref hitPolyId, ref changed); 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); ref hitPoly, ref hitPolyId, ref changed);
} }
@ -1139,7 +1131,7 @@ public static class BSPQuery
private static bool AdjustToPlane( private static bool AdjustToPlane(
PhysicsBSPNode root, PhysicsBSPNode root,
Dictionary<ushort, ResolvedPolygon> resolved, Dictionary<ushort, ResolvedPolygon> resolved,
CollisionSphere checkPos, ref CollisionSphere checkPos,
Vector3 curPos, Vector3 curPos,
ResolvedPolygon hitPoly, ResolvedPolygon hitPoly,
Vector3 contactPoint) Vector3 contactPoint)
@ -1222,7 +1214,7 @@ public static class BSPQuery
CollisionSphere checkPos, CollisionSphere checkPos,
Vector3 up) Vector3 up)
{ {
var validPos = new CollisionSphere(checkPos); var validPos = checkPos;
return HitsWalkableInternal(root, resolved, path, validPos, up) return HitsWalkableInternal(root, resolved, path, validPos, up)
? TransitionState.Collided ? TransitionState.Collided
: TransitionState.OK; : TransitionState.OK;
@ -1262,12 +1254,12 @@ public static class BSPQuery
float stepDownAmount = -(path.StepDownAmt * path.WalkInterp); 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; bool changed = false;
ResolvedPolygon? polyHit = null; ResolvedPolygon? polyHit = null;
ushort _polyId = 0; // step-down doesn't need the id, but the signature requires it 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); ref polyHit, ref _polyId, ref changed);
if (changed && polyHit is not null) if (changed && polyHit is not null)
@ -1278,11 +1270,21 @@ public static class BSPQuery
path.AddOffsetToCheckPos(offset); path.AddOffsetToCheckPos(offset);
var worldNormal = TransformNormal(polyHit.Plane.Normal, localToWorld); var worldNormal = TransformNormal(polyHit.Plane.Normal, localToWorld);
var worldVertices = TransformVertices(polyHit.Vertices, localToWorld, scale, worldOrigin); var worldPlane = BuildWorldPlane(
var worldPlane = BuildWorldPlane(worldNormal, worldVertices); worldNormal,
polyHit.Vertices,
localToWorld,
scale,
worldOrigin);
collisions.SetContactPlane(worldPlane, path.CheckCellId, false); 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) if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
PhysicsDiagnostics.LastBspHitPoly = polyHit; PhysicsDiagnostics.LastBspHitPoly = polyHit;
@ -1351,20 +1353,62 @@ public static class BSPQuery
out ResolvedPolygon? hitPoly, out ResolvedPolygon? hitPoly,
out ushort hitPolyId, out ushort hitPolyId,
out Vector3 adjustedCenter) 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<ushort, ResolvedPolygon> 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<ushort, ResolvedPolygon> 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; hitPoly = null;
hitPolyId = 0; hitPolyId = 0;
if (root is null) return false; if (root is null) return false;
var validPos = new CollisionSphere(sphere.Origin, sphere.Radius);
var movement = -up * probeDistance; var movement = -up * probeDistance;
bool changed = false; bool changed = false;
ushort polyId = 0; ushort polyId = 0;
ResolvedPolygon? poly = null; ResolvedPolygon? poly = null;
FindWalkableInternal(root, resolved, transition.SpherePath, validPos, FindWalkableInternal(root, resolved, transition.SpherePath, ref validPos,
movement, up, ref poly, ref polyId, ref changed); movement, up, ref poly, ref polyId, ref changed);
if (changed && poly is not null) if (changed && poly is not null)
@ -1496,7 +1540,7 @@ public static class BSPQuery
// ACE: path.LocalSpacePos.LocalToGlobalVec(hitPoly.Plane.Normal) // ACE: path.LocalSpacePos.LocalToGlobalVec(hitPoly.Plane.Normal)
var collisionNormal = Vector3.Transform(hitPoly.Plane.Normal, localToWorld); var collisionNormal = Vector3.Transform(hitPoly.Plane.Normal, localToWorld);
if (!obj.State.HasFlag(ObjectInfoState.PerfectClip)) if ((obj.State & ObjectInfoState.PerfectClip) == 0)
{ {
collisions.SetCollisionNormal(collisionNormal); collisions.SetCollisionNormal(collisionNormal);
// L.2d slice 1 (2026-05-13): diagnostic side-channel. // L.2d slice 1 (2026-05-13): diagnostic side-channel.
@ -1505,9 +1549,9 @@ public static class BSPQuery
return TransitionState.Collided; 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 // L.2d slice 1 (2026-05-13): record the would-have-hit poly before
// the early-out — collisions.SetCollisionNormal isn't called on // the early-out — collisions.SetCollisionNormal isn't called on
@ -1595,8 +1639,9 @@ public static class BSPQuery
float rad = s0.Radius; float rad = s0.Radius;
CollisionSphere? s1 = null; bool hasS1 = path.NumSphere > 1;
if (path.NumSphere > 1) CollisionSphere s1 = default;
if (hasS1)
s1 = new CollisionSphere( s1 = new CollisionSphere(
path.LocalSphere[1].Origin, path.LocalSphere[1].Origin,
path.LocalSphere[1].Radius); path.LocalSphere[1].Radius);
@ -1614,13 +1659,20 @@ public static class BSPQuery
{ {
if (hitPoly is not null) if (hitPoly is not null)
{ {
AdjustToPlacementPoly(hitPoly, s0, s1, rad, centerSolid, clearCell); AdjustToPlacementPoly(
hitPoly,
ref s0,
ref s1,
hasS1,
rad,
centerSolid,
clearCell);
continue; continue;
} }
} }
else else
{ {
if (path.NumSphere >= 2 && s1 is not null) if (hasS1)
{ {
centerSolid = false; centerSolid = false;
hitPoly = null; hitPoly = null;
@ -1630,7 +1682,14 @@ public static class BSPQuery
{ {
if (hitPoly is not null) if (hitPoly is not null)
{ {
AdjustToPlacementPoly(hitPoly, s1, s0, rad, centerSolid, clearCell); AdjustToPlacementPoly(
hitPoly,
ref s1,
ref s0,
true,
rad,
centerSolid,
clearCell);
continue; continue;
} }
} }
@ -1714,6 +1773,76 @@ public static class BSPQuery
Quaternion localToWorld = default, Quaternion localToWorld = default,
PhysicsEngine? engine = null, PhysicsEngine? engine = null,
Vector3 worldOrigin = default) 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);
}
/// <summary>
/// Allocation-free production entry point. Input spheres are copied into
/// value scratch before entering the unchanged six-path dispatcher.
/// </summary>
internal static TransitionState FindCollisions(
PhysicsBSPNode? root,
Dictionary<ushort, ResolvedPolygon> 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<ushort, ResolvedPolygon> 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; if (root is null) return TransitionState.OK;
// Default quaternion (0,0,0,0) → treat as identity // Default quaternion (0,0,0,0) → treat as identity
@ -1723,11 +1852,6 @@ public static class BSPQuery
var collisions = transition.CollisionInfo; var collisions = transition.CollisionInfo;
var obj = transition.ObjectInfo; 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 movement = sphere0.Center - localCurrCenter; var movement = sphere0.Center - localCurrCenter;
// A6.P1: snapshot dispatcher entry for the [push-back-disp] probe. // A6.P1: snapshot dispatcher entry for the [push-back-disp] probe.
@ -1801,7 +1925,7 @@ public static class BSPQuery
PhysicsDiagnostics.LastPlacementFailSolidLeaf = false; PhysicsDiagnostics.LastPlacementFailSolidLeaf = false;
} }
if (sphere1 is not null && if (hasSphere1 &&
SphereIntersectsSolidInternal(root, resolved, sphere1, clearCell)) SphereIntersectsSolidInternal(root, resolved, sphere1, clearCell))
{ {
if (PhysicsDiagnostics.ProbePlacementFailEnabled) if (PhysicsDiagnostics.ProbePlacementFailEnabled)
@ -1836,12 +1960,12 @@ public static class BSPQuery
// ---------------------------------------------------------------- // ----------------------------------------------------------------
if (path.Collide) if (path.Collide)
{ {
var validPos = new CollisionSphere(sphere0); var validPos = sphere0;
ResolvedPolygon? hitPoly = null; ResolvedPolygon? hitPoly = null;
ushort _hitPolyId = 0; // Path 4 doesn't need the id ushort _hitPolyId = 0; // Path 4 doesn't need the id
bool changed = false; bool changed = false;
FindWalkableInternal(root, resolved, path, validPos, movement, localSpaceZ, FindWalkableInternal(root, resolved, path, ref validPos, movement, localSpaceZ,
ref hitPoly, ref _hitPolyId, ref changed); ref hitPoly, ref _hitPolyId, ref changed);
if (changed && hitPoly is not null) if (changed && hitPoly is not null)
@ -1873,10 +1997,20 @@ public static class BSPQuery
path.AddOffsetToCheckPos(worldOffset); path.AddOffsetToCheckPos(worldOffset);
var worldNormal = TransformNormal(hitPoly.Plane.Normal, localToWorld); var worldNormal = TransformNormal(hitPoly.Plane.Normal, localToWorld);
var worldVertices = TransformVertices(hitPoly.Vertices, localToWorld, scale, worldOrigin); var worldPlane = BuildWorldPlane(
var worldPlane = BuildWorldPlane(worldNormal, worldVertices); worldNormal,
hitPoly.Vertices,
localToWorld,
scale,
worldOrigin);
collisions.SetContactPlane(worldPlane, path.CheckCellId, false); 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) if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly; PhysicsDiagnostics.LastBspHitPoly = hitPoly;
@ -1897,7 +2031,7 @@ public static class BSPQuery
// ACE: BSPTree.find_collisions → step_sphere_up (BSPTree.cs, path 5 branch). // ACE: BSPTree.find_collisions → step_sphere_up (BSPTree.cs, path 5 branch).
// Named-retail: BSPTREE::find_collisions Contact branch → step_sphere_up. // 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; ResolvedPolygon? hitPoly0 = null;
Vector3 contact0 = Vector3.Zero; Vector3 contact0 = Vector3.Zero;
@ -1966,7 +2100,7 @@ public static class BSPQuery
// assigns neg_step_up = arg2, so the HEAD near-miss (index 0) slides // 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 // and the FOOT near-miss (index 1) steps up. The head test wins when
// both spheres near-miss (sphere1 is checked first). // both spheres near-miss (sphere1 is checked first).
if (sphere1 is not null) if (hasSphere1)
{ {
ResolvedPolygon? hitPoly1 = null; ResolvedPolygon? hitPoly1 = null;
Vector3 contact1 = Vector3.Zero; Vector3 contact1 = Vector3.Zero;
@ -2021,7 +2155,7 @@ public static class BSPQuery
if (hit0 || hitPoly0 is not null) if (hit0 || hitPoly0 is not null)
{ {
if (obj.State.HasFlag(ObjectInfoState.PathClipped)) if ((obj.State & ObjectInfoState.PathClipped) != 0)
{ {
return CollideWithPt(root, resolved, transition, return CollideWithPt(root, resolved, transition,
sphere0, localCurrCenter, sphere0, localCurrCenter,
@ -2090,7 +2224,7 @@ public static class BSPQuery
return TransitionState.Adjusted; return TransitionState.Adjusted;
} }
if (sphere1 is not null) if (hasSphere1)
{ {
ResolvedPolygon? hitPoly1 = null; ResolvedPolygon? hitPoly1 = null;
Vector3 contact1 = Vector3.Zero; Vector3 contact1 = Vector3.Zero;

View file

@ -705,26 +705,38 @@ public static class CellTransit
out IReadOnlyCollection<uint> cellSet, out IReadOnlyCollection<uint> cellSet,
Vector3? carriedBlockOrigin = null) Vector3? carriedBlockOrigin = null)
{ {
var candidates = new CellArray();
var containing = BuildCellSetAndPickContaining( var containing = BuildCellSetAndPickContaining(
cache, worldSpheres, numSpheres, currentCellId, cache, worldSpheres, numSpheres, currentCellId,
carriedBlockOrigin, out var candidates); carriedBlockOrigin, candidates);
cellSet = candidates; cellSet = candidates;
return containing; return containing;
} }
internal static uint FindCellSet(
PhysicsDataCache cache,
IReadOnlyList<Sphere> worldSpheres,
int numSpheres,
uint currentCellId,
CellArray candidates,
Vector3? carriedBlockOrigin = null)
=> BuildCellSetAndPickContaining(
cache, worldSpheres, numSpheres, currentCellId,
carriedBlockOrigin, candidates);
private static uint BuildCellSetAndPickContaining( private static uint BuildCellSetAndPickContaining(
PhysicsDataCache cache, PhysicsDataCache cache,
IReadOnlyList<Sphere> worldSpheres, IReadOnlyList<Sphere> worldSpheres,
int numSpheres, int numSpheres,
uint currentCellId, uint currentCellId,
Vector3? carriedBlockOrigin, Vector3? carriedBlockOrigin,
out CellArray candidates) CellArray candidates)
{ {
// Ordered, deduped candidate array — retail CELLARRAY (add_cell @701036). // Ordered, deduped candidate array — retail CELLARRAY (add_cell @701036).
// The ORDER is load-bearing: the current cell is added at index 0 and the // 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 // 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). // a boundary straddle and the membership does not ping-pong (the R1 flap).
candidates = new CellArray(); candidates.Clear();
int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres); int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres);
if (sphereCount == 0) return currentCellId; if (sphereCount == 0) return currentCellId;

View file

@ -336,6 +336,28 @@ public sealed class PhysicsBody
/// <summary>Most recent walkable polygon vertices (world-space).</summary> /// <summary>Most recent walkable polygon vertices (world-space).</summary>
public Vector3[]? WalkableVertices { get; set; } 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<Vector3> source)
{
if (_walkableVertexStorage is null
|| _walkableVertexStorage.Length != source.Length)
{
_walkableVertexStorage = new Vector3[source.Length];
}
source.CopyTo(_walkableVertexStorage);
WalkableVertices = _walkableVertexStorage;
}
internal Vector3[]? RetainedWalkableVertexStorage
=> _walkableVertexStorage;
/// <summary>Up vector used by the most recent walkable polygon probe.</summary> /// <summary>Up vector used by the most recent walkable polygon probe.</summary>
public Vector3 WalkableUp { get; set; } = Vector3.UnitZ; public Vector3 WalkableUp { get; set; } = Vector3.UnitZ;
@ -389,10 +411,10 @@ public sealed class PhysicsBody
// ── convenience helpers ──────────────────────────────────────────────── // ── convenience helpers ────────────────────────────────────────────────
public bool HasGravity => State.HasFlag(PhysicsStateFlags.Gravity); public bool HasGravity => (State & PhysicsStateFlags.Gravity) != 0;
public bool OnWalkable => TransientState.HasFlag(TransientStateFlags.OnWalkable); public bool OnWalkable => (TransientState & TransientStateFlags.OnWalkable) != 0;
public bool IsActive => TransientState.HasFlag(TransientStateFlags.Active); public bool IsActive => (TransientState & TransientStateFlags.Active) != 0;
public bool InContact => TransientState.HasFlag(TransientStateFlags.Contact); public bool InContact => (TransientState & TransientStateFlags.Contact) != 0;
// ── FUN_00511420 ─────────────────────────────────────────────────────── // ── FUN_00511420 ───────────────────────────────────────────────────────
@ -411,16 +433,16 @@ public sealed class PhysicsBody
/// </summary> /// </summary>
public void calc_acceleration() public void calc_acceleration()
{ {
if (TransientState.HasFlag(TransientStateFlags.Contact) && if ((TransientState & TransientStateFlags.Contact) != 0 &&
TransientState.HasFlag(TransientStateFlags.OnWalkable) && (TransientState & TransientStateFlags.OnWalkable) != 0 &&
!State.HasFlag(PhysicsStateFlags.Sledding)) (State & PhysicsStateFlags.Sledding) == 0)
{ {
Acceleration = Vector3.Zero; Acceleration = Vector3.Zero;
Omega = Vector3.Zero; Omega = Vector3.Zero;
return; return;
} }
if (State.HasFlag(PhysicsStateFlags.Gravity)) if ((State & PhysicsStateFlags.Gravity) != 0)
Acceleration = new Vector3(0f, 0f, Gravity); Acceleration = new Vector3(0f, 0f, Gravity);
else else
Acceleration = Vector3.Zero; Acceleration = Vector3.Zero;
@ -540,7 +562,7 @@ public sealed class PhysicsBody
/// </summary> /// </summary>
public void calc_friction(float dt, float velocityMag2) public void calc_friction(float dt, float velocityMag2)
{ {
if (!TransientState.HasFlag(TransientStateFlags.OnWalkable)) if ((TransientState & TransientStateFlags.OnWalkable) == 0)
return; return;
float dot = Vector3.Dot(GroundNormal, Velocity); float dot = Vector3.Dot(GroundNormal, Velocity);
@ -553,7 +575,7 @@ public sealed class PhysicsBody
float friction = Friction; float friction = Friction;
// Sledding modifies friction thresholds (from ACE cross-check). // 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 if (velocityMag2 < 1.5625f) // 1.25² — slow sled
friction = 1.0f; friction = 1.0f;
@ -590,7 +612,7 @@ public sealed class PhysicsBody
if (velocityMag2 <= 0f) if (velocityMag2 <= 0f)
{ {
// No movement manager equivalent here; just clear Active if grounded. // No movement manager equivalent here; just clear Active if grounded.
if (TransientState.HasFlag(TransientStateFlags.OnWalkable)) if ((TransientState & TransientStateFlags.OnWalkable) != 0)
TransientState &= ~TransientStateFlags.Active; TransientState &= ~TransientStateFlags.Active;
} }
else else

View file

@ -6,7 +6,7 @@ namespace AcDream.Core.Physics;
internal readonly record struct TerrainWalkableSample( internal readonly record struct TerrainWalkableSample(
System.Numerics.Plane Plane, System.Numerics.Plane Plane,
Vector3[] Vertices, TerrainTriangleVertices Vertices,
float WaterDepth, float WaterDepth,
bool IsWater, bool IsWater,
uint CellId); uint CellId);
@ -27,6 +27,30 @@ internal readonly record struct TerrainWalkableSample(
public sealed class PhysicsEngine public sealed class PhysicsEngine
{ {
private readonly Dictionary<uint, LandblockPhysics> _landblocks = new(); private readonly Dictionary<uint, LandblockPhysics> _landblocks = new();
private readonly TransitionScratchArena? _transitionScratch;
public PhysicsEngine()
: this(reuseTransitionScratch: true)
{
}
/// <summary>
/// Test seam for fresh-versus-reused transition differential evidence.
/// Production always uses the public constructor and owns one retail-shaped
/// scratch arena.
/// </summary>
internal PhysicsEngine(bool reuseTransitionScratch)
{
_transitionScratch = reuseTransitionScratch
? new TransitionScratchArena()
: null;
}
private Transition RentTransition()
=> _transitionScratch?.Rent() ?? new Transition();
private void ReturnTransition(Transition transition)
=> _transitionScratch?.Return(transition);
/// <summary>Number of registered landblocks (diagnostic).</summary> /// <summary>Number of registered landblocks (diagnostic).</summary>
public int LandblockCount => _landblocks.Count; public int LandblockCount => _landblocks.Count;
@ -307,15 +331,10 @@ public sealed class PhysicsEngine
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f) if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
{ {
var sample = lb.Terrain.SampleSurfacePolygon(localX, localY); var sample = lb.Terrain.SampleSurfacePolygon(localX, localY);
var vertices = new Vector3[sample.Vertices.Length]; var vertices = new TerrainTriangleVertices(
for (int i = 0; i < sample.Vertices.Length; i++) OffsetTerrainVertex(sample.Vertices.V0, lb),
{ OffsetTerrainVertex(sample.Vertices.V1, lb),
var v = sample.Vertices[i]; OffsetTerrainVertex(sample.Vertices.V2, lb));
vertices[i] = new Vector3(
v.X + lb.WorldOffsetX,
v.Y + lb.WorldOffsetY,
v.Z);
}
var normal = sample.Normal; var normal = sample.Normal;
float d = -Vector3.Dot(normal, vertices[0]); float d = -Vector3.Dot(normal, vertices[0]);
@ -337,6 +356,12 @@ public sealed class PhysicsEngine
return null; return null;
} }
private static Vector3 OffsetTerrainVertex(Vector3 vertex, LandblockPhysics landblock)
=> new(
vertex.X + landblock.WorldOffsetX,
vertex.Y + landblock.WorldOffsetY,
vertex.Z);
/// <summary> /// <summary>
/// Indoor walking Phase 2 (2026-05-19). Resolves the cell id for a /// Indoor walking Phase 2 (2026-05-19). Resolves the cell id for a
/// given world position via retail's portal-graph traversal for indoor /// given world position via retail's portal-graph traversal for indoor
@ -986,13 +1011,15 @@ public sealed class PhysicsEngine
// the body BEFORE the engine mutates it so the replay test can seed its // the body BEFORE the engine mutates it so the replay test can seed its
// PhysicsBody with the exact pre-call state. See PhysicsResolveCapture.cs. // PhysicsBody with the exact pre-call state. See PhysicsResolveCapture.cs.
bool captureEnabled = PhysicsResolveCapture.IsEnabled bool captureEnabled = PhysicsResolveCapture.IsEnabled
&& moverFlags.HasFlag(ObjectInfoState.IsPlayer); && (moverFlags & ObjectInfoState.IsPlayer) != 0;
PhysicsBodySnapshot? bodyBeforeSnap = PhysicsBodySnapshot? bodyBeforeSnap =
captureEnabled && body is not null captureEnabled && body is not null
? PhysicsResolveCapture.Snapshot(body) ? PhysicsResolveCapture.Snapshot(body)
: null; : null;
var transition = new Transition(); var transition = RentTransition();
try
{
transition.ObjectInfo.StepUpHeight = stepUpHeight; transition.ObjectInfo.StepUpHeight = stepUpHeight;
transition.ObjectInfo.StepDownHeight = stepDownHeight; transition.ObjectInfo.StepDownHeight = stepDownHeight;
transition.ObjectInfo.StepDown = true; transition.ObjectInfo.StepDown = true;
@ -1017,7 +1044,7 @@ public sealed class PhysicsEngine
// CPhysicsObj::get_object_info 0x00511CC0: Missile contributes // CPhysicsObj::get_object_info 0x00511CC0: Missile contributes
// PathClipped only. PerfectClip is deliberately not inferred. // PathClipped only. PerfectClip is deliberately not inferred.
if (transition.ObjectInfo.MoverPhysicsState.HasFlag(PhysicsStateFlags.Missile)) if ((transition.ObjectInfo.MoverPhysicsState & PhysicsStateFlags.Missile) != 0)
transition.ObjectInfo.State |= ObjectInfoState.PathClipped; transition.ObjectInfo.State |= ObjectInfoState.PathClipped;
// frames_stationary_fall gate input: retail reads the mover's GRAVITY state bit // frames_stationary_fall gate input: retail reads the mover's GRAVITY state bit
@ -1063,7 +1090,7 @@ public sealed class PhysicsEngine
// a wall collision at the end of one transition must project the next // a wall collision at the end of one transition must project the next
// frame's movement along the wall instead of hard-stopping again. // frame's movement along the wall instead of hard-stopping again.
if (body is not null if (body is not null
&& body.TransientState.HasFlag(TransientStateFlags.Sliding) && (body.TransientState & TransientStateFlags.Sliding) != 0
&& body.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq) && body.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
{ {
transition.CollisionInfo.SetSlidingNormal(body.SlidingNormal); transition.CollisionInfo.SetSlidingNormal(body.SlidingNormal);
@ -1113,9 +1140,9 @@ public sealed class PhysicsEngine
if (body is not null) if (body is not null)
{ {
transition.CollisionInfo.FramesStationaryFall = transition.CollisionInfo.FramesStationaryFall =
body.TransientState.HasFlag(TransientStateFlags.StationaryStuck) ? 3 : (body.TransientState & TransientStateFlags.StationaryStuck) != 0 ? 3 :
body.TransientState.HasFlag(TransientStateFlags.StationaryStop) ? 2 : (body.TransientState & TransientStateFlags.StationaryStop) != 0 ? 2 :
body.TransientState.HasFlag(TransientStateFlags.StationaryFall) ? 1 : 0; (body.TransientState & TransientStateFlags.StationaryFall) != 0 ? 1 : 0;
} }
bool ok = transition.FindTransitionalPosition(this); bool ok = transition.FindTransitionalPosition(this);
@ -1174,7 +1201,7 @@ public sealed class PhysicsEngine
{ {
body.WalkablePolygonValid = true; body.WalkablePolygonValid = true;
body.WalkablePlane = sp.LastWalkablePlane; body.WalkablePlane = sp.LastWalkablePlane;
body.WalkableVertices = (Vector3[])sp.LastWalkableVertices.Clone(); body.SetWalkableVerticesExact(sp.LastWalkableVertices);
body.WalkableUp = sp.LastWalkableUp; body.WalkableUp = sp.LastWalkableUp;
} }
else if (!isOnGround && !ci.ContactPlaneValid && !ci.LastKnownContactPlaneValid) else if (!isOnGround && !ci.ContactPlaneValid && !ci.LastKnownContactPlaneValid)
@ -1320,7 +1347,7 @@ public sealed class PhysicsEngine
inContact, inContact,
ci.ContactPlane.Normal); ci.ContactPlane.Normal);
bool onGround = inContact bool onGround = inContact
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable); || (transition.ObjectInfo.State & ObjectInfoState.OnWalkable) != 0;
resolveResult = new ResolveResult( resolveResult = new ResolveResult(
sp.CheckPos, sp.CheckPos,
@ -1346,7 +1373,7 @@ public sealed class PhysicsEngine
// If CheckPos hasn't moved from CurPos, the player stays put — // If CheckPos hasn't moved from CurPos, the player stays put —
// this is correct behavior when completely blocked. // this is correct behavior when completely blocked.
bool partialOnGround = ci.ContactPlaneValid bool partialOnGround = ci.ContactPlaneValid
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable) || (transition.ObjectInfo.State & ObjectInfoState.OnWalkable) != 0
|| isOnGround; || isOnGround;
uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId; uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId;
@ -1394,6 +1421,11 @@ public sealed class PhysicsEngine
return resolveResult; return resolveResult;
} }
finally
{
ReturnTransition(transition);
}
}
/// <summary> /// <summary>
/// Runs retail's radius-aware placement-ring search after the host has /// Runs retail's radius-aware placement-ring search after the host has
@ -1413,7 +1445,9 @@ public sealed class PhysicsEngine
ObjectInfoState moverFlags = ObjectInfoState.None, ObjectInfoState moverFlags = ObjectInfoState.None,
uint movingEntityId = 0) uint movingEntityId = 0)
{ {
var transition = new Transition(); var transition = RentTransition();
try
{
transition.ObjectInfo.StepUpHeight = stepUpHeight; transition.ObjectInfo.StepUpHeight = stepUpHeight;
transition.ObjectInfo.StepDownHeight = stepDownHeight; transition.ObjectInfo.StepDownHeight = stepDownHeight;
transition.ObjectInfo.StepDown = true; transition.ObjectInfo.StepDown = true;
@ -1431,7 +1465,7 @@ public sealed class PhysicsEngine
inContact, inContact,
ci.ContactPlane.Normal); ci.ContactPlane.Normal);
bool onGround = inContact bool onGround = inContact
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable); || (transition.ObjectInfo.State & ObjectInfoState.OnWalkable) != 0;
return new ResolveResult( return new ResolveResult(
sp.CurPos, sp.CurPos,
@ -1447,4 +1481,9 @@ public sealed class PhysicsEngine
ContactPlaneCellId: ci.ContactPlaneCellId, ContactPlaneCellId: ci.ContactPlaneCellId,
ContactPlaneIsWater: ci.ContactPlaneIsWater); ContactPlaneIsWater: ci.ContactPlaneIsWater);
} }
finally
{
ReturnTransition(transition);
}
}
} }

View file

@ -6,7 +6,28 @@ namespace AcDream.Core.Physics;
public readonly record struct TerrainSurfacePolygon( public readonly record struct TerrainSurfacePolygon(
float Z, float Z,
Vector3 Normal, Vector3 Normal,
Vector3[] Vertices); TerrainTriangleVertices Vertices);
/// <summary>
/// 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.
/// </summary>
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)),
};
}
/// <summary> /// <summary>
/// Outdoor terrain height resolver for a single landblock. Performs /// Outdoor terrain height resolver for a single landblock. Performs
@ -418,19 +439,19 @@ public sealed class TerrainSurface
Vector3 tl = new(cx * CellSize, (cy + 1) * CellSize, hTL); Vector3 tl = new(cx * CellSize, (cy + 1) * CellSize, hTL);
float z; float z;
Vector3[] vertices; TerrainTriangleVertices vertices;
if (splitSWtoNE) if (splitSWtoNE)
{ {
if (tx > ty) if (tx > ty)
{ {
z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty; z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty;
vertices = new[] { bl, br, tr }; vertices = new TerrainTriangleVertices(bl, br, tr);
} }
else else
{ {
z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty; z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty;
vertices = new[] { bl, tr, tl }; vertices = new TerrainTriangleVertices(bl, tr, tl);
} }
} }
else else
@ -438,12 +459,12 @@ public sealed class TerrainSurface
if (tx + ty <= 1f) if (tx + ty <= 1f)
{ {
z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty; z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty;
vertices = new[] { bl, br, tl }; vertices = new TerrainTriangleVertices(bl, br, tl);
} }
else else
{ {
z = hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); z = hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty);
vertices = new[] { br, tr, tl }; vertices = new TerrainTriangleVertices(br, tr, tl);
} }
} }

View file

@ -0,0 +1,68 @@
using System;
using System.Threading;
namespace AcDream.Core.Physics;
/// <summary>
/// Stack-disciplined reusable transition scratch owned by one
/// <see cref="PhysicsEngine"/>.
///
/// Retail <c>CTransition::makeTransition</c> (0x0050B150) leases one of ten
/// pre-existing records by nesting depth, calls <c>CTransition::init</c>, and
/// releases it through <c>CTransition::cleanupTransition</c> (0x00509DC0).
/// This arena keeps the same ten-deep, LIFO, same-thread contract while
/// lazily constructing records that are actually used.
/// </summary>
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;
}
}

View file

@ -89,13 +89,13 @@ public sealed class ObjectInfo
public bool MoverHasGravity; public bool MoverHasGravity;
// Convenience flag checks // Convenience flag checks
public bool Contact => State.HasFlag(ObjectInfoState.Contact); public bool Contact => (State & ObjectInfoState.Contact) != 0;
public bool OnWalkable => State.HasFlag(ObjectInfoState.OnWalkable); public bool OnWalkable => (State & ObjectInfoState.OnWalkable) != 0;
public bool IsViewer => State.HasFlag(ObjectInfoState.IsViewer); public bool IsViewer => (State & ObjectInfoState.IsViewer) != 0;
public bool IsPlayer => State.HasFlag(ObjectInfoState.IsPlayer); public bool IsPlayer => (State & ObjectInfoState.IsPlayer) != 0;
public bool EdgeSlide => State.HasFlag(ObjectInfoState.EdgeSlide); public bool EdgeSlide => (State & ObjectInfoState.EdgeSlide) != 0;
public bool PathClipped => State.HasFlag(ObjectInfoState.PathClipped); public bool PathClipped => (State & ObjectInfoState.PathClipped) != 0;
public bool FreeRotate => State.HasFlag(ObjectInfoState.FreeRotate); public bool FreeRotate => (State & ObjectInfoState.FreeRotate) != 0;
/// <summary> /// <summary>
/// Verbatim decision tree from retail <c>OBJECTINFO::missile_ignore</c> /// Verbatim decision tree from retail <c>OBJECTINFO::missile_ignore</c>
@ -107,20 +107,20 @@ public sealed class ObjectInfo
EntityCollisionFlags targetFlags) EntityCollisionFlags targetFlags)
{ {
var targetState = (PhysicsStateFlags)targetPhysicsState; var targetState = (PhysicsStateFlags)targetPhysicsState;
if (targetState.HasFlag(PhysicsStateFlags.Missile)) if ((targetState & PhysicsStateFlags.Missile) != 0)
return true; return true;
if (!MoverPhysicsState.HasFlag(PhysicsStateFlags.Missile)) if ((MoverPhysicsState & PhysicsStateFlags.Missile) == 0)
return false; return false;
if (targetEntityId == TargetId) if (targetEntityId == TargetId)
return false; return false;
bool hasWeenie = targetFlags.HasFlag(EntityCollisionFlags.HasWeenie); bool hasWeenie = (targetFlags & EntityCollisionFlags.HasWeenie) != 0;
if (targetState.HasFlag(PhysicsStateFlags.Ethereal) && hasWeenie) if ((targetState & PhysicsStateFlags.Ethereal) != 0 && hasWeenie)
return true; return true;
return TargetId != 0 return TargetId != 0
&& hasWeenie && hasWeenie
&& targetFlags.HasFlag(EntityCollisionFlags.IsCreature); && (targetFlags & EntityCollisionFlags.IsCreature) != 0;
} }
/// <summary> /// <summary>
@ -162,6 +162,26 @@ public sealed class ObjectInfo
/// </para> /// </para>
/// </summary> /// </summary>
public void StopVelocity() { VelocityKilled = true; } public void StopVelocity() { VelocityKilled = true; }
/// <summary>
/// Restores the logical state of this reusable transition record.
/// Retail performs the equivalent reset in <c>OBJECTINFO::init</c> while
/// retaining the surrounding <c>CTransition</c> storage.
/// </summary>
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;
}
} }
/// <summary> /// <summary>
@ -290,7 +310,7 @@ public sealed class CollisionInfo
public int FramesStationaryFall; public int FramesStationaryFall;
public Vector3 AdjustOffset; public Vector3 AdjustOffset;
public List<uint> CollideObjectGuids = new(); public readonly List<uint> CollideObjectGuids = new();
public uint? LastCollidedObjectGuid; public uint? LastCollidedObjectGuid;
/// <summary> /// <summary>
@ -350,12 +370,84 @@ public sealed class CollisionInfo
CollisionNormalValid = true; CollisionNormalValid = true;
CollisionNormal = normal; CollisionNormal = normal;
} }
/// <summary>
/// Retail <c>COLLISIONINFO::init</c> reset for a retained transition
/// record. Backing fields are written directly so diagnostic setters do
/// not report initialization as gameplay state mutation.
/// </summary>
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;
}
} }
/// <summary> /// <summary>
/// Movement path descriptor — tracks sphere positions in multiple /// Retained, stack-disciplined ordering buffers for reentrant neighboring-cell
/// coordinate frames during collision resolution. /// checks. A cell collision can enter another cell check before the outer scan
/// ACE: SpherePath. /// resumes, so each active depth needs an independent list.
/// </summary>
internal sealed class CellOrderScratchArena
{
private readonly List<List<uint>> _records = new();
private int _depth;
internal int ActiveDepth => _depth;
internal int RetainedRecordCount => _records.Count;
internal IReadOnlyList<List<uint>> RetainedRecords => _records;
internal List<uint> Rent()
{
if (_depth == _records.Count)
_records.Add(new List<uint>());
List<uint> record = _records[_depth++];
record.Clear();
return record;
}
internal void Return(List<uint> 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<uint> record in _records)
record.Clear();
_depth = 0;
}
}
/// <summary>
/// Movement path descriptor; tracks sphere positions in multiple coordinate
/// frames during collision resolution. ACE: SpherePath.
/// </summary> /// </summary>
public sealed class SpherePath public sealed class SpherePath
{ {
@ -474,6 +566,18 @@ public sealed class SpherePath
/// </summary> /// </summary>
public bool HitsInteriorCell; 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) public void SetCheckPos(Vector3 pos, uint cellId)
{ {
CheckPos = pos; CheckPos = pos;
@ -522,15 +626,75 @@ public sealed class SpherePath
public void SetWalkable(Plane plane, Vector3[] vertices, Vector3 up) public void SetWalkable(Plane plane, Vector3[] vertices, Vector3 up)
{ {
ArgumentNullException.ThrowIfNull(vertices);
WalkableValid = true; WalkableValid = true;
WalkablePlane = plane; WalkablePlane = plane;
WalkableVertices = (Vector3[])vertices.Clone(); WalkableVertices = CopyExact(vertices, ref _walkableVertexStorage);
WalkableUp = up; WalkableUp = up;
WalkableAllowance = PhysicsGlobals.FloorZ; WalkableAllowance = PhysicsGlobals.FloorZ;
LastWalkableValid = true; LastWalkableValid = true;
LastWalkablePlane = plane; 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;
}
/// <summary>
/// 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.
/// </summary>
internal void SetWalkableTransformed(
Plane plane,
ReadOnlySpan<Vector3> 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; LastWalkableUp = up;
} }
@ -547,11 +711,95 @@ public sealed class SpherePath
WalkableValid = true; WalkableValid = true;
WalkablePlane = LastWalkablePlane; WalkablePlane = LastWalkablePlane;
WalkableVertices = (Vector3[])LastWalkableVertices.Clone(); WalkableVertices = CopyExact(LastWalkableVertices, ref _walkableVertexStorage);
WalkableUp = LastWalkableUp; WalkableUp = LastWalkableUp;
return true; return true;
} }
/// <summary>
/// Retail <c>SPHEREPATH::init</c> 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.
/// </summary>
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<Vector3> 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;
}
}
/// <summary> /// <summary>
/// Slide fallback when step-up fails. Clears the contact-plane state that /// Slide fallback when step-up fails. Clears the contact-plane state that
/// caused the step-up attempt and runs the full sphere-slide computation /// caused the step-up attempt and runs the full sphere-slide computation
@ -693,9 +941,22 @@ public static class PhysicsGlobals
/// </summary> /// </summary>
public sealed class Transition public sealed class Transition
{ {
public ObjectInfo ObjectInfo = new(); public readonly ObjectInfo ObjectInfo = new();
public SpherePath SpherePath = new(); public readonly SpherePath SpherePath = new();
public CollisionInfo CollisionInfo = new(); public readonly CollisionInfo CollisionInfo = new();
/// <summary>
/// Restores this retained record to the state of a freshly constructed
/// transition. Retail <c>CTransition::makeTransition</c> calls
/// <c>CTransition::init</c> on its stack-disciplined scratch record before
/// each lease.
/// </summary>
public void ResetForReuse()
{
ObjectInfo.ResetForReuse();
SpherePath.ResetForReuse();
CollisionInfo.ResetForReuse();
}
private static bool DumpEdgeSlideEnabled => private static bool DumpEdgeSlideEnabled =>
Environment.GetEnvironmentVariable("ACDREAM_DUMP_EDGE_SLIDE") == "1"; Environment.GetEnvironmentVariable("ACDREAM_DUMP_EDGE_SLIDE") == "1";
@ -1087,7 +1348,7 @@ public sealed class Transition
{ {
// COLLISIONINFO::init at retail 0x0050B052. Placement probes are // COLLISIONINFO::init at retail 0x0050B052. Placement probes are
// independent; a failed compass sample must not bias the next one. // independent; a failed compass sample must not bias the next one.
CollisionInfo = new CollisionInfo(); CollisionInfo.ResetForReuse();
} }
return transitionState; return transitionState;
@ -1850,15 +2111,6 @@ public sealed class Transition
if (cellPhysics.BSP?.Root is null) return false; 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 // Save/restore WalkableAllowance: CPolygon::walkable_hits_sphere reads
// path.WalkableAllowance (acclient_2013_pseudo_c.txt:323010). For // path.WalkableAllowance (acclient_2013_pseudo_c.txt:323010). For
// "standing here, find my floor" we want the walkability slope // "standing here, find my floor" we want the walkability slope
@ -1880,7 +2132,8 @@ public sealed class Transition
cellPhysics.BSP.Root, cellPhysics.BSP.Root,
cellPhysics.Resolved, cellPhysics.Resolved,
this, this,
localSphere, localFootCenter,
sphereRadius,
INDOOR_WALKABLE_PROBE_DISTANCE, INDOOR_WALKABLE_PROBE_DISTANCE,
Vector3.UnitZ, // local Z is up for indoor cells (identity transform) Vector3.UnitZ, // local Z is up for indoor cells (identity transform)
out hitPoly, out hitPoly,
@ -1957,7 +2210,19 @@ public sealed class Transition
// Deterministic order for greppable probe logs. Skip the primary // Deterministic order for greppable probe logs. Skip the primary
// cell — caller has already run its BSP. // cell — caller has already run its BSP.
var ordered = new System.Collections.Generic.List<uint>(cellSet); List<uint> ordered = sp.OrderedCellScratch.Rent();
try
{
if (cellSet is CellArray cellArray)
{
for (int i = 0; i < cellArray.Count; i++)
ordered.Add(cellArray.OrderedIds[i]);
}
else
{
foreach (uint id in cellSet)
ordered.Add(id);
}
ordered.Sort(); ordered.Sort();
foreach (uint cellId in ordered) foreach (uint cellId in ordered)
@ -2043,20 +2308,13 @@ public sealed class Transition
var localCenter = Vector3.Transform(footCenter, cell.InverseWorldTransform); var localCenter = Vector3.Transform(footCenter, cell.InverseWorldTransform);
var localCurrCenter = Vector3.Transform(sp.GlobalCurrCenter[0].Origin, cell.InverseWorldTransform); var localCurrCenter = Vector3.Transform(sp.GlobalCurrCenter[0].Origin, cell.InverseWorldTransform);
var localSphere = new DatReaderWriter.Types.Sphere bool hasLocalSphere1 = sp.NumSphere > 1;
{ Vector3 localSphere1Center = hasLocalSphere1
Origin = localCenter, ? Vector3.Transform(sp.GlobalSphere[1].Origin, cell.InverseWorldTransform)
Radius = sphereRadius, : Vector3.Zero;
}; float localSphere1Radius = hasLocalSphere1
DatReaderWriter.Types.Sphere? localSphere1 = null; ? sp.GlobalSphere[1].Radius
if (sp.NumSphere > 1) : 0f;
{
localSphere1 = new DatReaderWriter.Types.Sphere
{
Origin = Vector3.Transform(sp.GlobalSphere[1].Origin, cell.InverseWorldTransform),
Radius = sp.GlobalSphere[1].Radius,
};
}
System.Numerics.Quaternion cellRotation; System.Numerics.Quaternion cellRotation;
Vector3 cellOrigin; Vector3 cellOrigin;
@ -2074,7 +2332,12 @@ public sealed class Transition
var result = BSPQuery.FindCollisions( var result = BSPQuery.FindCollisions(
cell.BSP.Root, cell.Resolved, this, cell.BSP.Root, cell.Resolved, this,
localSphere, localSphere1, localCurrCenter, localCenter,
sphereRadius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrCenter,
Vector3.UnitZ, 1.0f, cellRotation, engine, Vector3.UnitZ, 1.0f, cellRotation, engine,
worldOrigin: cellOrigin); worldOrigin: cellOrigin);
@ -2123,6 +2386,11 @@ public sealed class Transition
return TransitionState.OK; return TransitionState.OK;
} }
finally
{
sp.OrderedCellScratch.Return(ordered);
}
}
private void LogIssue98CellSetSummary( private void LogIssue98CellSetSummary(
PhysicsEngine engine, PhysicsEngine engine,
@ -2295,7 +2563,7 @@ public sealed class Transition
{ {
case TransitionState.Collided: case TransitionState.Collided:
case TransitionState.Adjusted: case TransitionState.Adjusted:
if (!ObjectInfo.State.HasFlag(ObjectInfoState.Contact)) if ((ObjectInfo.State & ObjectInfoState.Contact) == 0)
CollisionInfo.CollidedWithEnvironment = true; CollisionInfo.CollidedWithEnvironment = true;
return true; return true;
case TransitionState.Slid: case TransitionState.Slid:
@ -2358,23 +2626,16 @@ public sealed class Transition
var localCenter = Vector3.Transform(footCenter, cellPhysics.InverseWorldTransform); var localCenter = Vector3.Transform(footCenter, cellPhysics.InverseWorldTransform);
var localCurrCenter = Vector3.Transform(sp.GlobalCurrCenter[0].Origin, 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. // Second sphere (head) in local space, if present.
DatReaderWriter.Types.Sphere? localSphere1 = null; bool hasLocalSphere1 = sp.NumSphere > 1;
if (sp.NumSphere > 1) Vector3 localSphere1Center = hasLocalSphere1
{ ? Vector3.Transform(
var headCenter = sp.GlobalSphere[1].Origin; sp.GlobalSphere[1].Origin,
localSphere1 = new DatReaderWriter.Types.Sphere cellPhysics.InverseWorldTransform)
{ : Vector3.Zero;
Origin = Vector3.Transform(headCenter, cellPhysics.InverseWorldTransform), float localSphere1Radius = hasLocalSphere1
Radius = sp.GlobalSphere[1].Radius, ? sp.GlobalSphere[1].Radius
}; : 0f;
}
// Indoor walking Phase 1 (2026-05-19): clear the LastBspHitPoly // Indoor walking Phase 1 (2026-05-19): clear the LastBspHitPoly
// side-channel before the call so a missed write (no collision) // side-channel before the call so a missed write (no collision)
@ -2420,8 +2681,11 @@ public sealed class Transition
cellPhysics.BSP.Root, cellPhysics.BSP.Root,
cellPhysics.Resolved, cellPhysics.Resolved,
this, this,
localSphere, localCenter,
localSphere1, sphereRadius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrCenter, localCurrCenter,
Vector3.UnitZ, // local space Z is up Vector3.UnitZ, // local space Z is up
1.0f, // scale = 1.0 for cell geometry 1.0f, // scale = 1.0 for cell geometry
@ -2443,7 +2707,7 @@ public sealed class Transition
if (cellState != TransitionState.OK) if (cellState != TransitionState.OK)
{ {
if (!ObjectInfo.State.HasFlag(ObjectInfoState.Contact)) if ((ObjectInfo.State & ObjectInfoState.Contact) == 0)
ci.CollidedWithEnvironment = true; ci.CollidedWithEnvironment = true;
return cellState; return cellState;
} }
@ -2553,8 +2817,13 @@ public sealed class Transition
sp.HitsInteriorCell = false; sp.HitsInteriorCell = false;
uint containingCellId = CellTransit.FindCellSet( 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); sp.CarriedBlockOrigin);
CellArray cellSet = sp.CellCandidates;
LogIssue98CellSetSummary(engine, containingCellId, cellSet, footCenter, sphereRadius); LogIssue98CellSetSummary(engine, containingCellId, cellSet, footCenter, sphereRadius);
if ((sp.CheckCellId & 0xFFFFu) >= 0x0100u if ((sp.CheckCellId & 0xFFFFu) >= 0x0100u
@ -2564,8 +2833,9 @@ public sealed class Transition
} }
else 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; } if ((id & 0xFFFFu) >= 0x0100u) { sp.HitsInteriorCell = true; break; }
} }
} }
@ -2610,18 +2880,12 @@ public sealed class Transition
private TransitionState ValidateWalkable(Vector3 sphereCenter, float sphereRadius, private TransitionState ValidateWalkable(Vector3 sphereCenter, float sphereRadius,
System.Numerics.Plane contactPlane, System.Numerics.Plane contactPlane,
bool isWater, float waterDepth, uint cellId, bool isWater, float waterDepth, uint cellId,
Vector3[]? walkableVertices = null) TerrainTriangleVertices? walkableVertices = null)
{ {
var sp = SpherePath; var sp = SpherePath;
var ci = CollisionInfo; var ci = CollisionInfo;
var oi = ObjectInfo; 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. // Low point of the sphere.
var lowPoint = sphereCenter - new Vector3(0f, 0f, sphereRadius); var lowPoint = sphereCenter - new Vector3(0f, 0f, sphereRadius);
@ -2646,7 +2910,7 @@ public sealed class Transition
if (sp.StepDown || !oi.OnWalkable || walkableNormal) if (sp.StepDown || !oi.OnWalkable || walkableNormal)
{ {
ci.SetContactPlane(contactPlane, cellId, isWater); ci.SetContactPlane(contactPlane, cellId, isWater);
CacheWalkableContext(); CacheWalkableContext(sp, contactPlane, walkableVertices);
} }
if (!oi.Contact && !sp.StepDown) if (!oi.Contact && !sp.StepDown)
@ -2669,7 +2933,7 @@ public sealed class Transition
if (sp.StepDown || !oi.OnWalkable || walkable) if (sp.StepDown || !oi.OnWalkable || walkable)
{ {
ci.SetContactPlane(contactPlane, cellId, isWater); ci.SetContactPlane(contactPlane, cellId, isWater);
CacheWalkableContext(); CacheWalkableContext(sp, contactPlane, walkableVertices);
if (sp.StepDown) if (sp.StepDown)
{ {
@ -2693,6 +2957,18 @@ public sealed class Transition
return TransitionState.Adjusted; 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 // Object collision — static BSP objects
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@ -2880,23 +3156,22 @@ public sealed class Transition
var invRot = Quaternion.Inverse(obj.Rotation); var invRot = Quaternion.Inverse(obj.Rotation);
float invScale = obj.Scale > 0 ? 1.0f / obj.Scale : 1.0f; float invScale = obj.Scale > 0 ? 1.0f / obj.Scale : 1.0f;
var localSphere0 = new DatReaderWriter.Types.Sphere Vector3 localSphere0Center =
{ Vector3.Transform(sp.GlobalSphere[0].Origin - obj.Position, invRot)
Origin = Vector3.Transform(sp.GlobalSphere[0].Origin - obj.Position, invRot) * invScale, * invScale;
Radius = sp.GlobalSphere[0].Radius * invScale, float localSphere0Radius = sp.GlobalSphere[0].Radius * invScale;
};
var localCurrCenter = Vector3.Transform( var localCurrCenter = Vector3.Transform(
sp.GlobalCurrCenter[0].Origin - obj.Position, invRot) * invScale; sp.GlobalCurrCenter[0].Origin - obj.Position, invRot) * invScale;
DatReaderWriter.Types.Sphere? localSphere1 = null; bool hasLocalSphere1 = sp.NumSphere > 1;
if (sp.NumSphere > 1) Vector3 localSphere1Center = hasLocalSphere1
{ ? Vector3.Transform(
localSphere1 = new DatReaderWriter.Types.Sphere sp.GlobalSphere[1].Origin - obj.Position,
{ invRot) * invScale
Origin = Vector3.Transform(sp.GlobalSphere[1].Origin - obj.Position, invRot) * invScale, : Vector3.Zero;
Radius = sp.GlobalSphere[1].Radius * invScale, float localSphere1Radius = hasLocalSphere1
}; ? sp.GlobalSphere[1].Radius * invScale
} : 0f;
// Local-space Z (up direction rotated into object space). // Local-space Z (up direction rotated into object space).
var localSpaceZ = Vector3.Transform(Vector3.UnitZ, invRot); var localSpaceZ = Vector3.Transform(Vector3.UnitZ, invRot);
@ -2908,8 +3183,11 @@ public sealed class Transition
physics.BSP.Root, physics.BSP.Root,
physics.Resolved, physics.Resolved,
this, this,
localSphere0, localSphere0Center,
localSphere1, localSphere0Radius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrCenter, localCurrCenter,
localSpaceZ, localSpaceZ,
obj.Scale, // scale for local→world offsets obj.Scale, // scale for local→world offsets
@ -3212,20 +3490,16 @@ public sealed class Transition
} }
var invRot = Quaternion.Inverse(bldRotation); var invRot = Quaternion.Inverse(bldRotation);
var localSphere0 = new DatReaderWriter.Types.Sphere Vector3 localSphere0Center =
{ Vector3.Transform(sp.GlobalSphere[0].Origin - bldOrigin, invRot);
Origin = Vector3.Transform(sp.GlobalSphere[0].Origin - bldOrigin, invRot), float localSphere0Radius = sp.GlobalSphere[0].Radius;
Radius = sp.GlobalSphere[0].Radius, bool hasLocalSphere1 = sp.NumSphere > 1;
}; Vector3 localSphere1Center = hasLocalSphere1
DatReaderWriter.Types.Sphere? localSphere1 = null; ? Vector3.Transform(sp.GlobalSphere[1].Origin - bldOrigin, invRot)
if (sp.NumSphere > 1) : Vector3.Zero;
{ float localSphere1Radius = hasLocalSphere1
localSphere1 = new DatReaderWriter.Types.Sphere ? sp.GlobalSphere[1].Radius
{ : 0f;
Origin = Vector3.Transform(sp.GlobalSphere[1].Origin - bldOrigin, invRot),
Radius = sp.GlobalSphere[1].Radius,
};
}
var localCurrCenter = Vector3.Transform( var localCurrCenter = Vector3.Transform(
sp.GlobalCurrCenter[0].Origin - bldOrigin, invRot); sp.GlobalCurrCenter[0].Origin - bldOrigin, invRot);
var localSpaceZ = Vector3.Transform(Vector3.UnitZ, invRot); var localSpaceZ = Vector3.Transform(Vector3.UnitZ, invRot);
@ -3239,8 +3513,11 @@ public sealed class Transition
physics.BSP.Root, physics.BSP.Root,
physics.Resolved, physics.Resolved,
this, this,
localSphere0, localSphere0Center,
localSphere1, localSphere0Radius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrCenter, localCurrCenter,
localSpaceZ, localSpaceZ,
1.0f, // buildings are unscaled 1.0f, // buildings are unscaled
@ -3513,7 +3790,7 @@ public sealed class Transition
Vector3 gCenter = sp.GlobalCurrCenter[sphereNum].Origin; Vector3 gCenter = sp.GlobalCurrCenter[sphereNum].Origin;
Vector3 globalOffset = gCenter - obj.Position; Vector3 globalOffset = gCenter - obj.Position;
if (!ObjectInfo.State.HasFlag(ObjectInfoState.PerfectClip)) if ((ObjectInfo.State & ObjectInfoState.PerfectClip) == 0)
{ {
if (!NormalizeCheckSmall(ref globalOffset)) if (!NormalizeCheckSmall(ref globalOffset))
CollisionInfo.SetCollisionNormal(globalOffset); CollisionInfo.SetCollisionNormal(globalOffset);
@ -3946,7 +4223,7 @@ public sealed class Transition
if (NormalizeCheckSmall(ref n)) if (NormalizeCheckSmall(ref n))
return TransitionState.Collided; return TransitionState.Collided;
if (!ObjectInfo.State.HasFlag(ObjectInfoState.PerfectClip)) if ((ObjectInfo.State & ObjectInfoState.PerfectClip) == 0)
{ {
CollisionInfo.SetCollisionNormal(n); CollisionInfo.SetCollisionNormal(n);
return TransitionState.Collided; return TransitionState.Collided;
@ -4564,7 +4841,7 @@ public sealed class Transition
Console.WriteLine( Console.WriteLine(
$"stepup: enter normal=({collisionNormal.X:F3},{collisionNormal.Y:F3},{collisionNormal.Z:F3}) " + $"stepup: enter normal=({collisionNormal.X:F3},{collisionNormal.Y:F3},{collisionNormal.Z:F3}) " +
$"|Z|={collisionNormal.Z:F3} vs FloorZ={floor:F3} → {verdict}, " + $"|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}, " + $"StepUpHeight={oi.StepUpHeight:F3}, " +
$"CurPos=({sp.CurPos.X:F2},{sp.CurPos.Y:F2},{sp.CurPos.Z:F2})"); $"CurPos=({sp.CurPos.X:F2},{sp.CurPos.Y:F2},{sp.CurPos.Z:F2})");
} }
@ -4592,7 +4869,7 @@ public sealed class Transition
float stepDownHeight = 0.04f; 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; stepDownHeight = oi.StepUpHeight;
@ -4673,7 +4950,7 @@ public sealed class Transition
var sp = SpherePath; var sp = SpherePath;
var oi = ObjectInfo; var oi = ObjectInfo;
if (!oi.State.HasFlag(ObjectInfoState.OnWalkable)) if ((oi.State & ObjectInfoState.OnWalkable) == 0)
return true; return true;
// If the current walkable entry is still valid, skip the probe. // If the current walkable entry is still valid, skip the probe.

View file

@ -134,9 +134,13 @@ public class TerrainSurfaceTests
var sample = surface.SampleSurfacePolygon(2f, 2f); var sample = surface.SampleSurfacePolygon(2f, 2f);
Assert.Equal(3, sample.Vertices.Length); 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.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] [Fact]

View file

@ -9,11 +9,11 @@ using Xunit.Abstractions;
namespace AcDream.Core.Tests.Physics; namespace AcDream.Core.Tests.Physics;
/// <summary> /// <summary>
/// Slice I0 evidence harness for the four production /// Slice I allocation gate for the four production
/// <see cref="PhysicsEngine.ResolveWithTransition"/> call shapes. This is not a /// <see cref="PhysicsEngine.ResolveWithTransition"/> call shapes. This is not a
/// budget assertion: I1 deliberately changes the expected allocation from the /// broad frame-allocation budget: it isolates transition/query scratch and
/// recorded graph-path baseline to zero steady transition/query-scratch bytes. /// requires zero steady bytes after all retained buffers and tiered code paths
/// The harness stays in-tree so the before/after figure is reproducible. /// have warmed.
/// </summary> /// </summary>
public sealed class TransitionAllocationBaselineTests public sealed class TransitionAllocationBaselineTests
{ {
@ -27,32 +27,33 @@ public sealed class TransitionAllocationBaselineTests
=> _output = output; => _output = output;
[Fact] [Fact]
public void GraphPath_RecordsPerResolveAllocationForEveryMoverFamily() public void ReusedScratch_AllocatesZeroBytesPerResolveForEveryMoverFamily()
{ {
var (root, resolved) = BSPStepUpFixtures.TallWall(); var (root, resolved) = BSPStepUpFixtures.TallWall();
var engine = BuildEngine(root, resolved); var engine = BuildEngine(root, resolved);
var groundEngine = BuildGroundEngine();
long player = Measure(() => ResolvePlayer(engine)); long player = Measure(() => ResolvePlayer(engine));
long groundedPublication = Measure(
() => ResolveGroundedPublication(groundEngine));
long remote = Measure(() => ResolveRemote(engine)); long remote = Measure(() => ResolveRemote(engine));
long projectile = Measure(() => ResolveProjectile(engine)); long projectile = Measure(() => ResolveProjectile(engine));
long camera = Measure(() => ResolveCamera(engine)); long camera = Measure(() => ResolveCamera(engine));
_output.WriteLine( _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($" player: {player,8:N0} B/resolve");
_output.WriteLine(
$" grounded: {groundedPublication,8:N0} B/resolve");
_output.WriteLine($" remote: {remote,8:N0} B/resolve"); _output.WriteLine($" remote: {remote,8:N0} B/resolve");
_output.WriteLine($" projectile: {projectile,8:N0} B/resolve"); _output.WriteLine($" projectile: {projectile,8:N0} B/resolve");
_output.WriteLine($" camera: {camera,8:N0} B/resolve"); _output.WriteLine($" camera: {camera,8:N0} B/resolve");
// The current graph path constructs a Transition object graph and Assert.Equal(0, player);
// CollisionSphere helpers on every resolve. I1 replaces that lifetime Assert.Equal(0, groundedPublication);
// shape; this lower bound merely proves the baseline capture actually Assert.Equal(0, remote);
// observed those allocations instead of an optimized-away call. Assert.Equal(0, projectile);
Assert.All( Assert.Equal(0, camera);
new[] { player, remote, projectile, camera },
bytes => Assert.True(
bytes > 0,
"The I0 baseline must observe the current per-resolve allocation."));
} }
private static long Measure(Func<ResolveResult> resolve) private static long Measure(Func<ResolveResult> resolve)
@ -106,6 +107,24 @@ public sealed class TransitionAllocationBaselineTests
movingEntityId: 0x80000001u); 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) private static ResolveResult ResolveProjectile(PhysicsEngine engine)
{ {
var body = Bodies.Projectile; var body = Bodies.Projectile;
@ -197,9 +216,25 @@ public sealed class TransitionAllocationBaselineTests
return engine; 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<CellSurface>(),
Array.Empty<PortalPlane>(),
0f,
0f);
return engine;
}
private static class Bodies private static class Bodies
{ {
internal static readonly PhysicsBody Player = new(); internal static readonly PhysicsBody Player = new();
internal static readonly PhysicsBody Grounded = new();
internal static readonly PhysicsBody Remote = new(); internal static readonly PhysicsBody Remote = new();
internal static readonly PhysicsBody Projectile = new(); internal static readonly PhysicsBody Projectile = new();
} }

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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<bool, PhysicsEngine> 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<ushort, ResolvedPolygon> 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<ushort, Polygon>(),
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<CellSurface>(),
Array.Empty<PortalPlane>(),
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<float>(actualValue),
memberContext);
break;
case double expectedDouble:
Assert.Equal(
BitConverter.DoubleToInt64Bits(expectedDouble),
BitConverter.DoubleToInt64Bits(
Assert.IsType<double>(actualValue)));
break;
case Vector3 expectedVector:
AssertVectorBitwise(
expectedVector,
Assert.IsType<Vector3>(actualValue),
memberContext);
break;
case Quaternion expectedRotation:
AssertQuaternionBitwise(
expectedRotation,
Assert.IsType<Quaternion>(actualValue),
memberContext);
break;
case Plane expectedPlane:
AssertPlaneBitwise(
expectedPlane,
Assert.IsType<Plane>(actualValue),
memberContext);
break;
case Vector3[] expectedVertices:
{
Vector3[] actualVertices = Assert.IsType<Vector3[]>(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<PhysicsBody?> 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,
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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<Vector3[]>(
path.RetainedWalkableVertexStorage);
Vector3[] retainedLastWalkable = Assert.IsType<Vector3[]>(
path.RetainedLastWalkableVertexStorage);
CellArray retainedCells = path.CellCandidates;
CellOrderScratchArena retainedOrder = path.OrderedCellScratch;
List<uint>[] retainedOrderRecords =
retainedOrder.RetainedRecords.ToArray();
List<uint> 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<Vector3[]>(path.WalkableVertices);
Vector3[] firstLastStorage = Assert.IsType<Vector3[]>(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<Vector3[]>(path.WalkableVertices));
AssertVectorsBitwise(second, Assert.IsType<Vector3[]>(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<Vector3[]>(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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(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<uint> 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<Vector3[]>(actualValue);
Assert.All(retained, value => AssertBitwise(Vector3.Zero, value));
continue;
}
switch (expectedValue)
{
case ObjectInfo expectedObject:
AssertFreshState(expectedObject, Assert.IsType<ObjectInfo>(actualValue));
break;
case SpherePath expectedPath:
AssertFreshState(expectedPath, Assert.IsType<SpherePath>(actualValue));
break;
case CollisionInfo expectedCollision:
AssertFreshState(
expectedCollision,
Assert.IsType<CollisionInfo>(actualValue));
break;
case Sphere[] expectedSpheres:
{
Sphere[] actualSpheres = Assert.IsType<Sphere[]>(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<CellArray>(actualValue).OrderedIds);
break;
case CellOrderScratchArena:
{
CellOrderScratchArena actualScratch =
Assert.IsType<CellOrderScratchArena>(actualValue);
Assert.Equal(0, actualScratch.ActiveDepth);
Assert.All(actualScratch.RetainedRecords, Assert.Empty);
break;
}
case List<uint> expectedValues:
Assert.Equal(expectedValues, Assert.IsType<List<uint>>(actualValue));
break;
case Vector3 expectedVector:
AssertBitwise(expectedVector, Assert.IsType<Vector3>(actualValue));
break;
case Quaternion expectedRotation:
AssertBitwise(expectedRotation, Assert.IsType<Quaternion>(actualValue));
break;
case Plane expectedPlane:
AssertBitwise(expectedPlane, Assert.IsType<Plane>(actualValue));
break;
case null:
Assert.Null(actualValue);
break;
default:
Assert.Equal(expectedValue, actualValue);
break;
}
}
}
private static void AssertVectorsBitwise(
IReadOnlyList<Vector3> expected,
IReadOnlyList<Vector3> 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));
}