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
`InteriorEntityPartition`. The ordinary production profile sustains 519.7 FPS
with CPU/GPU p50 1.869/1.096 ms and 652.1/928.3 MiB working/private memory.
The measured 22.34 KiB/frame remainder is assigned to Slice I1.
The G5 profile's 22.34 KiB/frame remainder led into Slice I1. That slice is
now complete: player, remote, projectile, camera, and grounded
walkable-publication profiles each measure 0 B/resolve, with bit-identical
fresh-vs-reused results and complete reset/reentrancy gates.
Slice H's retained UI/frame, bounded-light, and pooled/borrowed/direct network
work is complete; the exact seven-checkpoint connected lifecycle/reconnect gate
passes. Slice I is active at I1 from
passes. Slice I is active at I2 from
`docs/plans/2026-07-25-modern-runtime-slice-i.md`. The exact G4 visual rollback
is `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`; do not revert G3 or
the portal-warmup corrections. Evidence:
`docs/research/2026-07-25-slice-h-closeout.md` and
`docs/research/2026-07-25-slice-g5-production-profile.md`.
`docs/research/2026-07-25-slice-g5-production-profile.md` and
`docs/research/2026-07-25-slice-i1-transition-scratch.md`.
**Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated

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
`InteriorEntityPartition`. The ordinary production profile sustains 519.7 FPS
with CPU/GPU p50 1.869/1.096 ms and 652.1/928.3 MiB working/private memory.
The measured 22.34 KiB/frame remainder is assigned to Slice I1.
The G5 profile's 22.34 KiB/frame remainder led into Slice I1. That slice is
now complete: player, remote, projectile, camera, and grounded
walkable-publication profiles each measure 0 B/resolve, with bit-identical
fresh-vs-reused results and complete reset/reentrancy gates.
Slice H's retained UI/frame, bounded-light, and pooled/borrowed/direct network
work is complete; the exact seven-checkpoint connected lifecycle/reconnect gate
passes. Slice I is active at I1 from
passes. Slice I is active at I2 from
`docs/plans/2026-07-25-modern-runtime-slice-i.md`. The exact G4 visual rollback
is `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`; do not revert G3 or
the portal-warmup corrections. Evidence:
`docs/research/2026-07-25-slice-h-closeout.md` and
`docs/research/2026-07-25-slice-g5-production-profile.md`.
`docs/research/2026-07-25-slice-g5-production-profile.md` and
`docs/research/2026-07-25-slice-i1-transition-scratch.md`.
**Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated

View file

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

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,
> capped/uncapped/dense correctness routes, and ordinary production profile
> pass. The final stable ordinary-production minute sustains 519.7 FPS with
> CPU/GPU p50 1.869/1.096 ms; the measured 22.34 KiB/frame remainder is the
> active Slice-I transition/query-scratch owner.
> CPU/GPU p50 1.869/1.096 ms. Slice I1 now removes the isolated
> transition/query owner: player, remote, projectile, camera, and grounded
> walkable-publication profiles each measure 0 B/resolve, and retained
> transitions remain bit-identical to the fresh-graph oracle.
> The historical exact cutover
> rollback remains
> `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`. Slice H is complete:
> retained UI/frame work, exact bounded light selection, and
> pooled/borrowed/direct network I/O pass 8,292 Release tests / 5 skips and the
> connected lifecycle/reconnect gate. Slice I is active at I1 from
> connected lifecycle/reconnect gate. Slice I is active at I2 from
> [its detailed plan](2026-07-25-modern-runtime-slice-i.md).
**Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the
@ -1679,11 +1681,11 @@ hitch and follows.
| MP1a | `AcDream.Content` extraction (GL-free MeshExtractor + boundary records out of App) | ✅ SHIPPED 2026-07-05 — user-gated (renders identical, zero tripwires, perf-neutral); 8 commits `651d041e`..`b0758d77` |
| MP1b | Pak format + `acdream-bake` CLI + mmap `PakReader` + equivalence/full-scale gate | ✅ **SHIPPED 2026-07-24** — 729,888 EnvCell keys → 17,117 unique geometries + 712,771 aliases (42.6×), 751,141 total keys / 38,370 physical blobs, zero failures, 28,192.4 MiB, 81.4 s validated atomic publish; full-DAT gate also corrected a real collision in WB's legacy geometry hash. [Report](../research/2026-07-24-slice-b-full-bake-report.md) |
| **MP-Alloc (safe batch)** | Reuse per-frame buffers: anim pose, particle draw-list, interior partition, animatedIds/drawableCells sets | ✅ SHIPPED + USER-GATED 2026-07-05 — dense-town frame-time spikes 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) |
| MP2 | Retail particle distance/cell-view degradation: DAT-authored range, exact infinite/finite off-view update branches, emitter-first render scan, user-requested 2× default range | ✅ SHIPPED 2026-07-17 — Aerlinthe hotspot `0x32000223` resolves to retail 64m and defaults to 128m; landscape/entity streaming distance unchanged |
| MP3 | Arch ECS render world + delta submission (the 300-FPS lever) | ⚪ — note: does NOT fix the steady-state GC churn (that's MP-Alloc); MP3 is the draw-submission throughput lever |
| MP4 | Zero-alloc frame loop + flat physics data (residual, post-MP-Alloc) | ⚪ hard-queued behind M1.5 #137 |
| MP4 | Zero-alloc frame loop + flat physics data (residual, post-MP-Alloc) | 🟡 ACTIVE — Slice I1 zero-allocation transition scratch shipped; I2 immutable flat collision assets in progress. |
| MP5 | Job-system parallelism | ⚪ stretch, evidence-gated |
---

View file

@ -116,10 +116,12 @@ the retained frame product without building the old
correctness routes, exact seven-checkpoint lifecycle/reconnect gate, and
ordinary production profile pass. The final stable production minute sustains
519.7 FPS with CPU/GPU p50 1.869/1.096 ms and 22.34 KiB/frame allocation.
The complete Release gate is 3,826 App tests / 3 skips and 8,335 solution
tests / 5 skips. Slice H is complete: retained UI/frame work, exact bounded
light selection, and pooled/borrowed/direct network I/O pass. Slice I is active
at I1 under
Slice I1 subsequently removes the isolated per-resolve owner: player, remote,
projectile, camera, and grounded walkable-publication profiles are all
0 B/resolve with bit-identical fresh-vs-reused outcomes. The complete I1
Release gate is 3,826 App tests / 3 skips and 8,342 solution tests / 5 skips.
Slice H is complete: retained UI/frame work, exact bounded light selection,
and pooled/borrowed/direct network I/O pass. Slice I is active at I2 under
[`2026-07-25-modern-runtime-slice-i.md`](2026-07-25-modern-runtime-slice-i.md).
The historical exact G4 visual rollback remains
`git revert ef1d263337997bb030eadb7b8e71d73dc659907a`.

View file

@ -2,8 +2,8 @@
**Date:** 2026-07-24
**Status:** Slices AH and I0 are complete. Slices FL were explicitly
approved 2026-07-24; Slice I1 is active.
**Status:** Slices AH and I0I1 are complete. Slices FL were explicitly
approved 2026-07-24; Slice I2 is active.
**Scope:** Reconcile and sequence the existing Modern Pipeline (`MP`) and
Linux/headless (`LH`) tracks using the 2026-07-24 connected performance audit.
@ -1193,12 +1193,16 @@ seven-checkpoint lifecycle/reconnect gate pass with graceful shutdown.
The final ordinary-production minute on exact `14fbe92b` sustained 519.7 FPS:
CPU p50/p95/p99 1.869/2.306/2.484 ms and GPU
1.096/1.108/1.136 ms. Working/private memory was 652.1/928.3 MiB.
Frame-thread allocation is now 22.34 KiB median; allocation-tick evidence and
the four-mover baseline assign that remainder primarily to reset-complete
`Transition` collision/query scratch. I0 has pinned the retail oracle and
fixtures, so I1 is the current execution point. Evidence:
Frame-thread allocation at the G5 checkpoint was 22.34 KiB median. I1 has now
replaced fresh per-resolve transition graphs and query temporaries with
reset-complete retained scratch: player, remote, projectile, camera, and
grounded walkable-publication profiles each measure 0 B/resolve, while
fresh-vs-reused output and body state remain bit-identical. I2 immutable flat
collision assets are the current execution point. Evidence:
[`../research/2026-07-25-slice-g5-production-profile.md`](../research/2026-07-25-slice-g5-production-profile.md)
and
[`../research/2026-07-25-slice-i1-transition-scratch.md`](../research/2026-07-25-slice-i1-transition-scratch.md)
and
[`2026-07-25-modern-runtime-slice-i.md`](2026-07-25-modern-runtime-slice-i.md).
The intended order is therefore:
@ -1212,7 +1216,7 @@ honest metrics + committed baselines (A — exit criteria block C)
-> incremental render scene (F)
-> delta GPU submission (G)
-> residual frame cleanup (H-a, H-b, H-c)
-> flat collision assets (I — CURRENT at I1)
-> flat collision assets (I — CURRENT at I2)
-> presentation-independent runtime (J — §0.3 authorization)
-> Linux/headless/multi-session (K)
-> evidence-gated GPU jobs (L)

View file

@ -1,8 +1,7 @@
# Modern runtime Slice I — flat collision assets and zero-allocation physics
**Status:** I0 COMPLETE — retail oracle, representative graph fixtures, and
four-mover allocation baselines fixed; G5 production closeout complete; I1
active
**Status:** I0I1 COMPLETE — retail oracle, graph fixtures, and
reset-complete zero-allocation transition/query scratch fixed; I2 active
**Parent:** `2026-07-24-modern-runtime-architecture.md`, Slice I
**Purpose:** remove parsed DAT object graphs and steady collision allocations
without changing one retail collision decision, float, comparison, or traversal
@ -123,7 +122,11 @@ graph path, and no behavior code changed.
Gate: bit-identical `ResolveResult` and body side effects, no state leakage
when alternating hostile fixtures between different mover IDs, and zero
steady transition/query-scratch allocation. Close issue #237 only here.
steady transition/query-scratch allocation. **PASSED 2026-07-25:** the expanded
118-test graph oracle, structural poison/reset suite, hostile fresh-vs-reused
differential, Core/solution Release gates, and all five measured profiles pass.
Issue #237 is closed. Evidence:
[`../research/2026-07-25-slice-i1-transition-scratch.md`](../research/2026-07-25-slice-i1-transition-scratch.md).
### I2 — immutable flat collision schema and flattener

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.

File diff suppressed because it is too large Load diff

View file

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

View file

@ -25,21 +25,21 @@ namespace AcDream.Core.Physics;
[Flags]
public enum PhysicsStateFlags : uint
{
None = 0x00000000,
Static = 0x00000001,
Ethereal = 0x00000004,
ReportCollisions = 0x00000008,
IgnoreCollisions = 0x00000010,
NoDraw = 0x00000020,
Missile = 0x00000040,
Pushable = 0x00000080,
AlignPath = 0x00000100,
PathClipped = 0x00000200,
Gravity = 0x00000400,
Lighting = 0x00000800,
ParticleEmitter = 0x00001000,
Hidden = 0x00004000,
ScriptedCollision = 0x00008000,
None = 0x00000000,
Static = 0x00000001,
Ethereal = 0x00000004,
ReportCollisions = 0x00000008,
IgnoreCollisions = 0x00000010,
NoDraw = 0x00000020,
Missile = 0x00000040,
Pushable = 0x00000080,
AlignPath = 0x00000100,
PathClipped = 0x00000200,
Gravity = 0x00000400,
Lighting = 0x00000800,
ParticleEmitter = 0x00001000,
Hidden = 0x00004000,
ScriptedCollision = 0x00008000,
/// <summary>
/// A6.P7 (2026-05-25): retail HAS_PHYSICS_BSP_PS bit
/// (acclient.h:2833). When set, the entity exposes a per-Setup
@ -52,7 +52,7 @@ public enum PhysicsStateFlags : uint
/// state 0x10008 (STATIC | REPORT_COLLISIONS | HAS_PHYSICS_BSP).
/// ACE name: <c>PhysicsState.HasPhysicsBSP</c>.
/// </summary>
HasPhysicsBsp = 0x00010000,
HasPhysicsBsp = 0x00010000,
/// <summary>
/// L.3a (2026-04-30): retail INELASTIC_PS bit (acclient.h:2834).
/// When set, wall-collisions zero the velocity instead of reflecting.
@ -60,14 +60,14 @@ public enum PhysicsStateFlags : uint
/// impact rather than bounce. The player NEVER has this flag set —
/// player wall-hits use the reflection path with elasticity ~0.05.
/// </summary>
Inelastic = 0x00020000,
HasDefaultAnim = 0x00040000,
HasDefaultScript = 0x00080000,
Cloaked = 0x00100000,
Inelastic = 0x00020000,
HasDefaultAnim = 0x00040000,
HasDefaultScript = 0x00080000,
Cloaked = 0x00100000,
ReportAsEnvironment = 0x00200000,
EdgeSlide = 0x00400000,
Sledding = 0x00800000,
Frozen = 0x01000000,
EdgeSlide = 0x00400000,
Sledding = 0x00800000,
Frozen = 0x01000000,
}
/// <summary>
@ -77,17 +77,17 @@ public enum PhysicsStateFlags : uint
[Flags]
public enum TransientStateFlags : uint
{
None = 0,
Contact = 0x00000001, // bit 0 — touching any surface
None = 0,
Contact = 0x00000001, // bit 0 — touching any surface
OnWalkable = 0x00000002, // bit 1 — standing on a walkable surface
Sliding = 0x00000004, // bit 2 — carry sliding normal into next transition
Sliding = 0x00000004, // bit 2 — carry sliding normal into next transition
// retail frames_stationary_fall carried across frames: transition() seeds fsf from
// these bits before the sweep (pc:280940-947); handle_all_collisions re-encodes fsf
// into them at the end of the frame (pc:282743/282749/282753).
StationaryFall = 0x00000010, // bit 4 — fsf == 1
StationaryStop = 0x00000020, // bit 5 — fsf == 2
StationaryFall = 0x00000010, // bit 4 — fsf == 1
StationaryStop = 0x00000020, // bit 5 — fsf == 2
StationaryStuck = 0x00000040, // bit 6 — fsf == 3
Active = 0x00000080, // bit 7 — object needs per-frame update
Active = 0x00000080, // bit 7 — object needs per-frame update
}
/// <summary>
@ -99,17 +99,17 @@ public sealed class PhysicsBody
{
// ── constants ──────────────────────────────────────────────────────────
// From PhysicsGlobals.cs / confirmed by DAT_007c78a4 reference in decompiled code.
public const float MaxVelocity = 50.0f;
public const float MaxVelocity = 50.0f;
public const float MaxVelocitySquared = MaxVelocity * MaxVelocity;
public const float Gravity = -9.8f; // DAT_0082223c in FUN_00511420
public const float SmallVelocity = 0.25f;
public const float Gravity = -9.8f; // DAT_0082223c in FUN_00511420
public const float SmallVelocity = 0.25f;
public const float SmallVelocitySquared = SmallVelocity * SmallVelocity;
public const float DefaultFriction = 0.95f;
public const float MinQuantum = 1.0f / 30.0f; // ~0.0333 s
public const float DefaultFriction = 0.95f;
public const float MinQuantum = 1.0f / 30.0f; // ~0.0333 s
// Matching-client disassembly resolves the named lift's stripped global to
// 0.2 seconds. update_object consumes larger gaps as repeated 0.2 s quanta.
public const float MaxQuantum = 0.2f;
public const float HugeQuantum = 2.0f; // discard stale dt
public const float MaxQuantum = 0.2f;
public const float HugeQuantum = 2.0f; // discard stale dt
// ── struct fields ──────────────────────────────────────────────────────
// Offsets from acclient_function_map.md §PhysicsObj Struct Layout.
@ -336,6 +336,28 @@ public sealed class PhysicsBody
/// <summary>Most recent walkable polygon vertices (world-space).</summary>
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>
public Vector3 WalkableUp { get; set; } = Vector3.UnitZ;
@ -389,10 +411,10 @@ public sealed class PhysicsBody
// ── convenience helpers ────────────────────────────────────────────────
public bool HasGravity => State.HasFlag(PhysicsStateFlags.Gravity);
public bool OnWalkable => TransientState.HasFlag(TransientStateFlags.OnWalkable);
public bool IsActive => TransientState.HasFlag(TransientStateFlags.Active);
public bool InContact => TransientState.HasFlag(TransientStateFlags.Contact);
public bool HasGravity => (State & PhysicsStateFlags.Gravity) != 0;
public bool OnWalkable => (TransientState & TransientStateFlags.OnWalkable) != 0;
public bool IsActive => (TransientState & TransientStateFlags.Active) != 0;
public bool InContact => (TransientState & TransientStateFlags.Contact) != 0;
// ── FUN_00511420 ───────────────────────────────────────────────────────
@ -411,16 +433,16 @@ public sealed class PhysicsBody
/// </summary>
public void calc_acceleration()
{
if (TransientState.HasFlag(TransientStateFlags.Contact) &&
TransientState.HasFlag(TransientStateFlags.OnWalkable) &&
!State.HasFlag(PhysicsStateFlags.Sledding))
if ((TransientState & TransientStateFlags.Contact) != 0 &&
(TransientState & TransientStateFlags.OnWalkable) != 0 &&
(State & PhysicsStateFlags.Sledding) == 0)
{
Acceleration = Vector3.Zero;
Omega = Vector3.Zero;
return;
}
if (State.HasFlag(PhysicsStateFlags.Gravity))
if ((State & PhysicsStateFlags.Gravity) != 0)
Acceleration = new Vector3(0f, 0f, Gravity);
else
Acceleration = Vector3.Zero;
@ -540,7 +562,7 @@ public sealed class PhysicsBody
/// </summary>
public void calc_friction(float dt, float velocityMag2)
{
if (!TransientState.HasFlag(TransientStateFlags.OnWalkable))
if ((TransientState & TransientStateFlags.OnWalkable) == 0)
return;
float dot = Vector3.Dot(GroundNormal, Velocity);
@ -553,7 +575,7 @@ public sealed class PhysicsBody
float friction = Friction;
// Sledding modifies friction thresholds (from ACE cross-check).
if (State.HasFlag(PhysicsStateFlags.Sledding))
if ((State & PhysicsStateFlags.Sledding) != 0)
{
if (velocityMag2 < 1.5625f) // 1.25² — slow sled
friction = 1.0f;
@ -590,7 +612,7 @@ public sealed class PhysicsBody
if (velocityMag2 <= 0f)
{
// No movement manager equivalent here; just clear Active if grounded.
if (TransientState.HasFlag(TransientStateFlags.OnWalkable))
if ((TransientState & TransientStateFlags.OnWalkable) != 0)
TransientState &= ~TransientStateFlags.Active;
}
else

View file

@ -6,7 +6,7 @@ namespace AcDream.Core.Physics;
internal readonly record struct TerrainWalkableSample(
System.Numerics.Plane Plane,
Vector3[] Vertices,
TerrainTriangleVertices Vertices,
float WaterDepth,
bool IsWater,
uint CellId);
@ -27,6 +27,30 @@ internal readonly record struct TerrainWalkableSample(
public sealed class PhysicsEngine
{
private readonly Dictionary<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>
public int LandblockCount => _landblocks.Count;
@ -63,13 +87,13 @@ public sealed class PhysicsEngine
int cx = (int)((cellOrLandblockId >> 24) & 0xFFu);
int cy = (int)((cellOrLandblockId >> 16) & 0xFFu);
for (int dx = -radius; dx <= radius; dx++)
for (int dy = -radius; dy <= radius; dy++)
{
int nx = cx + dx, ny = cy + dy;
if (nx < 0 || nx > 254 || ny < 0 || ny > 254) continue; // off-map: skip
uint prefix = ((uint)nx << 24) | ((uint)ny << 16);
if (!resident.Contains(prefix)) return false;
}
for (int dy = -radius; dy <= radius; dy++)
{
int nx = cx + dx, ny = cy + dy;
if (nx < 0 || nx > 254 || ny < 0 || ny > 254) continue; // off-map: skip
uint prefix = ((uint)nx << 24) | ((uint)ny << 16);
if (!resident.Contains(prefix)) return false;
}
return true;
}
@ -195,13 +219,13 @@ public sealed class PhysicsEngine
float localY = worldY - lb.WorldOffsetY;
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
{
landblockId = kvp.Key;
landblockId = kvp.Key;
worldOffsetX = lb.WorldOffsetX;
worldOffsetY = lb.WorldOffsetY;
return true;
}
}
landblockId = 0;
landblockId = 0;
worldOffsetX = 0f;
worldOffsetY = 0f;
return false;
@ -307,15 +331,10 @@ public sealed class PhysicsEngine
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
{
var sample = lb.Terrain.SampleSurfacePolygon(localX, localY);
var vertices = new Vector3[sample.Vertices.Length];
for (int i = 0; i < sample.Vertices.Length; i++)
{
var v = sample.Vertices[i];
vertices[i] = new Vector3(
v.X + lb.WorldOffsetX,
v.Y + lb.WorldOffsetY,
v.Z);
}
var vertices = new TerrainTriangleVertices(
OffsetTerrainVertex(sample.Vertices.V0, lb),
OffsetTerrainVertex(sample.Vertices.V1, lb),
OffsetTerrainVertex(sample.Vertices.V2, lb));
var normal = sample.Normal;
float d = -Vector3.Dot(normal, vertices[0]);
@ -337,6 +356,12 @@ public sealed class PhysicsEngine
return null;
}
private static Vector3 OffsetTerrainVertex(Vector3 vertex, LandblockPhysics landblock)
=> new(
vertex.X + landblock.WorldOffsetX,
vertex.Y + landblock.WorldOffsetY,
vertex.Z);
/// <summary>
/// Indoor walking Phase 2 (2026-05-19). Resolves the cell id for a
/// given world position via retail's portal-graph traversal for indoor
@ -986,413 +1011,420 @@ public sealed class PhysicsEngine
// the body BEFORE the engine mutates it so the replay test can seed its
// PhysicsBody with the exact pre-call state. See PhysicsResolveCapture.cs.
bool captureEnabled = PhysicsResolveCapture.IsEnabled
&& moverFlags.HasFlag(ObjectInfoState.IsPlayer);
&& (moverFlags & ObjectInfoState.IsPlayer) != 0;
PhysicsBodySnapshot? bodyBeforeSnap =
captureEnabled && body is not null
? PhysicsResolveCapture.Snapshot(body)
: null;
var transition = new Transition();
transition.ObjectInfo.StepUpHeight = stepUpHeight;
transition.ObjectInfo.StepDownHeight = stepDownHeight;
transition.ObjectInfo.StepDown = true;
// Fix #42 (2026-05-05): the moving entity's ShadowEntry must be
// skipped in FindObjCollisions or the sweep collides with self.
// Default 0 keeps tests / one-shot callers (no registered entity)
// working. Plumbed through ObjectInfo because retail stores the
// self pointer on OBJECTINFO::object (named-retail
// acclient_2013_pseudo_c.txt:274435 OBJECTINFO::init →
// this->object = arg2). The skip itself is at
// CObjCell::find_obj_collisions line 308931.
transition.ObjectInfo.SelfEntityId = movingEntityId;
transition.ObjectInfo.MoverPhysicsState = body?.State ?? PhysicsStateFlags.None;
transition.ObjectInfo.TargetId = designatedTargetId;
// Commit C 2026-04-29 — caller-supplied mover flags drive the
// retail PvP exemption block in FindObjCollisions. The local
// player passes IsPlayer (and PK/PKLite/Impenetrable when known
// from PlayerDescription); remote dead-reckoning passes None
// (matches non-player movement, all targets collide).
transition.ObjectInfo.State |= moverFlags;
// CPhysicsObj::get_object_info 0x00511CC0: Missile contributes
// PathClipped only. PerfectClip is deliberately not inferred.
if (transition.ObjectInfo.MoverPhysicsState.HasFlag(PhysicsStateFlags.Missile))
transition.ObjectInfo.State |= ObjectInfoState.PathClipped;
// frames_stationary_fall gate input: retail reads the mover's GRAVITY state bit
// (object_info.object->state & 0x400, pc:272625). Seed it from the body so the ladder
// in ValidateTransition runs for gravity movers (the player) and not floating props.
transition.ObjectInfo.MoverHasGravity = body?.HasGravity ?? false;
if (isOnGround)
transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable;
// K-fix7 (2026-04-26): only seed the contact plane when the body
// is actually grounded. Pre-seeding while AIRBORNE caused
// AdjustOffset's "Have a contact plane / Moving away from plane"
// branch to fire on every jump step — which calls
// Plane::snap_to_plane on the offset and ZEROES the Z component,
// killing all upward jump motion.
//
// We KEEP the seeding when isOnGround for slope-walking + step-up
// continuity (the original concern that motivated the seed).
// BSP step_up needs ContactPlane on sub-step 1 to compute the
// correct lift direction; removing the seed breaks stair-walking
// at the last step (verified by A6.P3 slice 2 first attempt
// 2026-05-22, reverted in this commit). Retail's CTransition::init
// explicitly CLEARS contact_plane_valid; we deliberately diverge
// for step_up correctness.
//
// A6.P3 slice 2 (2026-05-22) — to close issue #96 (per-tick CP-write
// blowup) without breaking stair-walking, the no-op-if-unchanged
// guard inside CollisionInfo.SetContactPlane (TransitionTypes.cs:259)
// collapses redundant seeds (same plane every tick) to a true no-op.
// The seed still fires the function call but only counts as a write
// when the plane values actually change.
if (isOnGround && body is not null && body.ContactPlaneValid)
var transition = RentTransition();
try
{
transition.CollisionInfo.SetContactPlane(
body.ContactPlane,
body.ContactPlaneCellId,
body.ContactPlaneIsWater);
}
transition.ObjectInfo.StepUpHeight = stepUpHeight;
transition.ObjectInfo.StepDownHeight = stepDownHeight;
transition.ObjectInfo.StepDown = true;
// Fix #42 (2026-05-05): the moving entity's ShadowEntry must be
// skipped in FindObjCollisions or the sweep collides with self.
// Default 0 keeps tests / one-shot callers (no registered entity)
// working. Plumbed through ObjectInfo because retail stores the
// self pointer on OBJECTINFO::object (named-retail
// acclient_2013_pseudo_c.txt:274435 OBJECTINFO::init →
// this->object = arg2). The skip itself is at
// CObjCell::find_obj_collisions line 308931.
transition.ObjectInfo.SelfEntityId = movingEntityId;
transition.ObjectInfo.MoverPhysicsState = body?.State ?? PhysicsStateFlags.None;
transition.ObjectInfo.TargetId = designatedTargetId;
// Retail CPhysicsObj::get_object_info also seeds SlidingNormal when
// transient_state has bit 2 set. This matters for one-step/frame hits:
// a wall collision at the end of one transition must project the next
// frame's movement along the wall instead of hard-stopping again.
if (body is not null
&& body.TransientState.HasFlag(TransientStateFlags.Sliding)
&& body.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
{
transition.CollisionInfo.SetSlidingNormal(body.SlidingNormal);
}
// Commit C 2026-04-29 — caller-supplied mover flags drive the
// retail PvP exemption block in FindObjCollisions. The local
// player passes IsPlayer (and PK/PKLite/Impenetrable when known
// from PlayerDescription); remote dead-reckoning passes None
// (matches non-player movement, all targets collide).
transition.ObjectInfo.State |= moverFlags;
transition.SpherePath.InitPath(
currentPos,
targetPos,
cellId,
sphereRadius,
sphereHeight,
localSphereOrigin,
beginOrientation,
endOrientation);
// CPhysicsObj::get_object_info 0x00511CC0: Missile contributes
// PathClipped only. PerfectClip is deliberately not inferred.
if ((transition.ObjectInfo.MoverPhysicsState & PhysicsStateFlags.Missile) != 0)
transition.ObjectInfo.State |= ObjectInfoState.PathClipped;
// #145: supply the carried cell-relative frame anchor to the outdoor
// membership pick. body.Position - body.CellPosition.Frame.Origin is the TRUE
// landblock world origin, correct even for an UNSTREAMED neighbour — replacing
// the terrain-registry origin that returns (0,0) and marches the cell id one
// landblock per tick (the #145 far-town cascade). Engaged only for a SEEDED
// OUTDOOR body whose carried landblock matches the resolve cell (the controller
// passes body.CellPosition.ObjCellId for the outdoor case, so they agree);
// null otherwise → legacy TryGetTerrainOrigin for NPCs/tests/indoor.
transition.SpherePath.CarriedBlockOrigin =
body is not null
&& (cellId & 0xFFFFu) is >= 1u and <= 0x40u // resolve cell is an outdoor landcell
&& (body.CellPosition.ObjCellId & 0xFFFFu) is >= 1u and <= 0x40u // carried cell is outdoor (seeded)
&& (cellId >> 16) == (body.CellPosition.ObjCellId >> 16) // same landblock → anchor consistent
? body.Position - body.CellPosition.Frame.Origin
: null;
// frames_stationary_fall gate input: retail reads the mover's GRAVITY state bit
// (object_info.object->state & 0x400, pc:272625). Seed it from the body so the ladder
// in ValidateTransition runs for gravity movers (the player) and not floating props.
transition.ObjectInfo.MoverHasGravity = body?.HasGravity ?? false;
if (isOnGround && body is not null
&& body.WalkablePolygonValid
&& body.WalkableVertices is { Length: >= 3 })
{
transition.SpherePath.SetWalkable(
body.WalkablePlane,
body.WalkableVertices,
body.WalkableUp);
}
if (isOnGround)
transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable;
// Seed collision_info.frames_stationary_fall from the body's carried Stationary*
// transient bits — retail transition() 0x00512dc0 seeds fsf from transient_state
// 0x40/0x20/0x10 AFTER init_path and immediately BEFORE find_valid_position
// (pc:280939-949). Placed here (post-InitPath) so InitPath's CollisionInfo reset
// doesn't wipe the seed.
if (body is not null)
{
transition.CollisionInfo.FramesStationaryFall =
body.TransientState.HasFlag(TransientStateFlags.StationaryStuck) ? 3 :
body.TransientState.HasFlag(TransientStateFlags.StationaryStop) ? 2 :
body.TransientState.HasFlag(TransientStateFlags.StationaryFall) ? 1 : 0;
}
// K-fix7 (2026-04-26): only seed the contact plane when the body
// is actually grounded. Pre-seeding while AIRBORNE caused
// AdjustOffset's "Have a contact plane / Moving away from plane"
// branch to fire on every jump step — which calls
// Plane::snap_to_plane on the offset and ZEROES the Z component,
// killing all upward jump motion.
//
// We KEEP the seeding when isOnGround for slope-walking + step-up
// continuity (the original concern that motivated the seed).
// BSP step_up needs ContactPlane on sub-step 1 to compute the
// correct lift direction; removing the seed breaks stair-walking
// at the last step (verified by A6.P3 slice 2 first attempt
// 2026-05-22, reverted in this commit). Retail's CTransition::init
// explicitly CLEARS contact_plane_valid; we deliberately diverge
// for step_up correctness.
//
// A6.P3 slice 2 (2026-05-22) — to close issue #96 (per-tick CP-write
// blowup) without breaking stair-walking, the no-op-if-unchanged
// guard inside CollisionInfo.SetContactPlane (TransitionTypes.cs:259)
// collapses redundant seeds (same plane every tick) to a true no-op.
// The seed still fires the function call but only counts as a write
// when the plane values actually change.
if (isOnGround && body is not null && body.ContactPlaneValid)
{
transition.CollisionInfo.SetContactPlane(
body.ContactPlane,
body.ContactPlaneCellId,
body.ContactPlaneIsWater);
}
bool ok = transition.FindTransitionalPosition(this);
// Retail CPhysicsObj::get_object_info also seeds SlidingNormal when
// transient_state has bit 2 set. This matters for one-step/frame hits:
// a wall collision at the end of one transition must project the next
// frame's movement along the wall instead of hard-stopping again.
if (body is not null
&& (body.TransientState & TransientStateFlags.Sliding) != 0
&& body.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
{
transition.CollisionInfo.SetSlidingNormal(body.SlidingNormal);
}
var sp = transition.SpherePath;
var ci = transition.CollisionInfo;
transition.SpherePath.InitPath(
currentPos,
targetPos,
cellId,
sphereRadius,
sphereHeight,
localSphereOrigin,
beginOrientation,
endOrientation);
// Persist the resulting contact plane state back to the body so the
// next frame's transition can seed from it. Uses LastKnownContactPlane
// when current is invalid (e.g., airborne this frame), matching retail.
if (body is not null)
{
// CPhysicsObj::transition 0x00512DC0 discards its CTransition when
// find_valid_position fails. Only SetPositionInternal 0x00515330
// publishes contact/fsf/walkable/sliding state, and that function
// is unreachable on the UpdateObjectInternal failure branch.
// #145: supply the carried cell-relative frame anchor to the outdoor
// membership pick. body.Position - body.CellPosition.Frame.Origin is the TRUE
// landblock world origin, correct even for an UNSTREAMED neighbour — replacing
// the terrain-registry origin that returns (0,0) and marches the cell id one
// landblock per tick (the #145 far-town cascade). Engaged only for a SEEDED
// OUTDOOR body whose carried landblock matches the resolve cell (the controller
// passes body.CellPosition.ObjCellId for the outdoor case, so they agree);
// null otherwise → legacy TryGetTerrainOrigin for NPCs/tests/indoor.
transition.SpherePath.CarriedBlockOrigin =
body is not null
&& (cellId & 0xFFFFu) is >= 1u and <= 0x40u // resolve cell is an outdoor landcell
&& (body.CellPosition.ObjCellId & 0xFFFFu) is >= 1u and <= 0x40u // carried cell is outdoor (seeded)
&& (cellId >> 16) == (body.CellPosition.ObjCellId >> 16) // same landblock → anchor consistent
? body.Position - body.CellPosition.Frame.Origin
: null;
if (isOnGround && body is not null
&& body.WalkablePolygonValid
&& body.WalkableVertices is { Length: >= 3 })
{
transition.SpherePath.SetWalkable(
body.WalkablePlane,
body.WalkableVertices,
body.WalkableUp);
}
// Seed collision_info.frames_stationary_fall from the body's carried Stationary*
// transient bits — retail transition() 0x00512dc0 seeds fsf from transient_state
// 0x40/0x20/0x10 AFTER init_path and immediately BEFORE find_valid_position
// (pc:280939-949). Placed here (post-InitPath) so InitPath's CollisionInfo reset
// doesn't wipe the seed.
if (body is not null)
{
transition.CollisionInfo.FramesStationaryFall =
(body.TransientState & TransientStateFlags.StationaryStuck) != 0 ? 3 :
(body.TransientState & TransientStateFlags.StationaryStop) != 0 ? 2 :
(body.TransientState & TransientStateFlags.StationaryFall) != 0 ? 1 : 0;
}
bool ok = transition.FindTransitionalPosition(this);
var sp = transition.SpherePath;
var ci = transition.CollisionInfo;
// Persist the resulting contact plane state back to the body so the
// next frame's transition can seed from it. Uses LastKnownContactPlane
// when current is invalid (e.g., airborne this frame), matching retail.
if (body is not null)
{
// CPhysicsObj::transition 0x00512DC0 discards its CTransition when
// find_valid_position fails. Only SetPositionInternal 0x00515330
// publishes contact/fsf/walkable/sliding state, and that function
// is unreachable on the UpdateObjectInternal failure branch.
if (ok)
{
if (ci.ContactPlaneValid)
{
body.ContactPlaneValid = true;
body.ContactPlane = ci.ContactPlane;
body.ContactPlaneCellId = ci.ContactPlaneCellId;
body.ContactPlaneIsWater = ci.ContactPlaneIsWater;
}
else if (ci.LastKnownContactPlaneValid)
{
body.ContactPlaneValid = true;
body.ContactPlane = ci.LastKnownContactPlane;
body.ContactPlaneCellId = ci.LastKnownContactPlaneCellId;
body.ContactPlaneIsWater = ci.LastKnownContactPlaneIsWater;
}
else
{
body.ContactPlaneValid = false;
}
// Publish frames_stationary_fall + carry it to the next frame via the Stationary*
// transient bits. Retail encodes these bits in handle_all_collisions (pc:282737-758);
// acdream co-locates the encode with the fsf writeback here (STRUCTURAL ADAPTATION,
// register) so the round-trip (seed→ladder→writeback→seed) is self-contained in Core.
// handle_all_collisions (PhysicsObjUpdate) then only READS body.FramesStationaryFall.
body.FramesStationaryFall = ci.FramesStationaryFall;
body.TransientState &= ~(TransientStateFlags.StationaryFall
| TransientStateFlags.StationaryStop
| TransientStateFlags.StationaryStuck);
body.TransientState |= ci.FramesStationaryFall switch
{
1 => TransientStateFlags.StationaryFall,
2 => TransientStateFlags.StationaryStop,
3 => TransientStateFlags.StationaryStuck,
_ => TransientStateFlags.None,
};
if (sp.HasLastWalkablePolygon && sp.LastWalkableVertices is not null)
{
body.WalkablePolygonValid = true;
body.WalkablePlane = sp.LastWalkablePlane;
body.SetWalkableVerticesExact(sp.LastWalkableVertices);
body.WalkableUp = sp.LastWalkableUp;
}
else if (!isOnGround && !ci.ContactPlaneValid && !ci.LastKnownContactPlaneValid)
{
body.WalkablePolygonValid = false;
body.WalkableVertices = null;
}
// Retail persists sliding state to the body ONLY on transition
// SUCCESS: CPhysicsObj::SetPositionInternal copies the normal at
// 0x005154c2 and syncs SLIDING_TS (bit 4) from the transition's
// final sliding_normal_valid at 0x005154e1 — and SetPositionInternal
// is unreachable when find_valid_position fails (the transition is
// discarded whole; the body keeps its prior state). #137 mechanism
// 2: an unconditional writeback here could persist a normal retail
// would discard.
if (ci.SlidingNormalValid
&& ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
{
body.SlidingNormal = ci.SlidingNormal;
body.TransientState |= TransientStateFlags.Sliding;
}
else
{
body.SlidingNormal = Vector3.Zero;
body.TransientState &= ~TransientStateFlags.Sliding;
}
}
// L.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity.
// Phase 3's reset path sets VelocityKilled when an airborne hit
// can't find a walkable surface (steep roof, wall) AND the
// body had a last_known_contact_plane (i.e., was grounded
// recently). Retail zeros all three velocity components so
// gravity restarts cleanly next frame.
//
// Named-retail: OBJECTINFO::kill_velocity → CPhysicsObj::set_velocity({0,0,0}, 0)
// acclient_2013_pseudo_c.txt:274467-274475
// Called from CTransition::transitional_insert reset path:
// acclient_2013_pseudo_c.txt:273237 (Phase 3)
// acclient_2013_pseudo_c.txt:272567 (validate_transition)
if (transition.ObjectInfo.VelocityKilled)
{
if (PhysicsDiagnostics.DumpSteepRoofEnabled)
Console.WriteLine($"[steep-roof] KILL-VELOCITY-APPLIED Vbefore=({body.Velocity.X:F2},{body.Velocity.Y:F2},{body.Velocity.Z:F2}) → 0,0,0");
body.Velocity = Vector3.Zero;
}
}
// L.3a (2026-04-30): surface the wall normal so callers can apply
// retail's velocity-reflection bounce (CPhysicsObj::handle_all_collisions
// at acclient_2013_pseudo_c.txt:282699-282715, ACE PhysicsObj.cs:
// 2692-2697). The reflection itself is applied in
// PlayerMovementController after the position commit, gated on
// apply_bounce = !(prevOnWalkable && newOnWalkable) — airborne wall
// hits bounce, grounded wall slides don't.
bool collisionNormalValid = ci.CollisionNormalValid;
Vector3 collisionNormal = ci.CollisionNormal;
// #42 diagnostic (2026-05-05): trace airborne sweeps to identify the
// source of the ~1m XY drift on retail-observed stationary jumps.
// Gated on ACDREAM_AIRBORNE_DIAG=1 and !isOnGround. One line per
// resolve call. deltaXY = post - target tells us how much the sweep
// diverged from the requested target; for a clean stationary +Z
// jump we expect (0,0). cp=valid with a tilted normal would confirm
// H1 (initial-overlap depenetration → next-step AdjustOffset projects
// the +Z offset along a non-+Z normal). User repros at flat plaza /
// east hillside / north hillside; if drift direction tracks terrain
// orientation, H1 is the cause; if it tracks actor facing, H2 / H3.
if (!isOnGround
&& Environment.GetEnvironmentVariable("ACDREAM_AIRBORNE_DIAG") == "1")
{
var post = sp.CheckPos;
float dx = post.X - targetPos.X;
float dy = post.Y - targetPos.Y;
string cpInfo = ci.ContactPlaneValid
? $"valid cpN=({ci.ContactPlane.Normal.X:F3},{ci.ContactPlane.Normal.Y:F3},{ci.ContactPlane.Normal.Z:F3})"
: "none";
Console.WriteLine(
$"[SWEEP] airborne pre=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) " +
$"target=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) " +
$"post=({post.X:F3},{post.Y:F3},{post.Z:F3}) " +
$"cell={cellId:X8}->{sp.CheckCellId:X8} ok={ok} " +
$"deltaXY=({dx:F3},{dy:F3}) cp={cpInfo}");
}
// L.2a slice 1 (2026-05-12): general-purpose resolver probe.
// One line per call when PhysicsDiagnostics.ProbeResolveEnabled
// is set (env var ACDREAM_PROBE_RESOLVE=1 at startup, or the
// DebugPanel checkbox flipped at runtime). Captures every
// dimension L.2 cares about: input/output position, input/output
// cell, ok-vs-partial, grounded-in vs contact-out, contact-plane
// status, wall normal if hit, walkable polygon valid. Zero cost
// when off (one static-bool read).
if (PhysicsDiagnostics.ProbeResolveEnabled)
{
var probePost = sp.CheckPos;
string probeCp = ci.ContactPlaneValid
? "valid"
: (ci.LastKnownContactPlaneValid ? "lastKnown" : "none");
string probeHit;
if (collisionNormalValid)
{
// L.2a slice 2 (2026-05-12): include the hit object's guid +
// environment flag so we can tell whether the wall is a building
// (CBuildingObj), a door (CC0Cxxxx range), an NPC, or terrain.
// Without this we know the wall normal but not the responsible
// entity — half the L.2d sub-direction call.
string objPart = ci.LastCollidedObjectGuid.HasValue
? System.FormattableString.Invariant(
$" obj=0x{ci.LastCollidedObjectGuid.Value:X8}")
: "";
string envPart = ci.CollidedWithEnvironment ? " env" : "";
int objCount = ci.CollideObjectGuids.Count;
string objCountPart = objCount > 1
? System.FormattableString.Invariant($" nObj={objCount}")
: "";
probeHit = System.FormattableString.Invariant(
$"yes n=({collisionNormal.X:F2},{collisionNormal.Y:F2},{collisionNormal.Z:F2}){objPart}{envPart}{objCountPart}");
}
else
{
probeHit = "no";
}
Console.WriteLine(System.FormattableString.Invariant(
$"[resolve] ent=0x{movingEntityId:X8} in=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cell=0x{cellId:X8} tgt=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) out=({probePost.X:F3},{probePost.Y:F3},{probePost.Z:F3}) cell=0x{sp.CheckCellId:X8} ok={ok} groundedIn={isOnGround} cp={probeCp} hit={probeHit} walkable={sp.HasLastWalkablePolygon}"));
}
// Phase W Stage 0 (2026-06-02): [cell-swept] probe — swept cell vs static-derived cell.
// Emits before the ResolveResult is built so it shows what BOTH paths would return.
// No ResolveCellId call here (it has a CellGraph.CurrCell side effect). No behavior change.
if (PhysicsDiagnostics.ProbeSweptEnabled)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[cell-swept] ent=0x{movingEntityId:X8} ok={ok} inCell=0x{cellId:X8} curCell=0x{sp.CurCellId:X8} checkCell=0x{sp.CheckCellId:X8} curPos=({sp.CurPos.X:F3},{sp.CurPos.Y:F3},{sp.CurPos.Z:F3}) checkPos=({sp.CheckPos.X:F3},{sp.CheckPos.Y:F3},{sp.CheckPos.Z:F3})"));
}
ResolveResult resolveResult;
if (ok)
{
if (ci.ContactPlaneValid)
{
body.ContactPlaneValid = true;
body.ContactPlane = ci.ContactPlane;
body.ContactPlaneCellId = ci.ContactPlaneCellId;
body.ContactPlaneIsWater = ci.ContactPlaneIsWater;
}
else if (ci.LastKnownContactPlaneValid)
{
body.ContactPlaneValid = true;
body.ContactPlane = ci.LastKnownContactPlane;
body.ContactPlaneCellId = ci.LastKnownContactPlaneCellId;
body.ContactPlaneIsWater = ci.LastKnownContactPlaneIsWater;
}
else
{
body.ContactPlaneValid = false;
}
bool inContact = ci.ContactPlaneValid;
bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
inContact,
ci.ContactPlane.Normal);
bool onGround = inContact
|| (transition.ObjectInfo.State & ObjectInfoState.OnWalkable) != 0;
// Publish frames_stationary_fall + carry it to the next frame via the Stationary*
// transient bits. Retail encodes these bits in handle_all_collisions (pc:282737-758);
// acdream co-locates the encode with the fsf writeback here (STRUCTURAL ADAPTATION,
// register) so the round-trip (seed→ladder→writeback→seed) is self-contained in Core.
// handle_all_collisions (PhysicsObjUpdate) then only READS body.FramesStationaryFall.
body.FramesStationaryFall = ci.FramesStationaryFall;
body.TransientState &= ~(TransientStateFlags.StationaryFall
| TransientStateFlags.StationaryStop
| TransientStateFlags.StationaryStuck);
body.TransientState |= ci.FramesStationaryFall switch
{
1 => TransientStateFlags.StationaryFall,
2 => TransientStateFlags.StationaryStop,
3 => TransientStateFlags.StationaryStuck,
_ => TransientStateFlags.None,
};
if (sp.HasLastWalkablePolygon && sp.LastWalkableVertices is not null)
{
body.WalkablePolygonValid = true;
body.WalkablePlane = sp.LastWalkablePlane;
body.WalkableVertices = (Vector3[])sp.LastWalkableVertices.Clone();
body.WalkableUp = sp.LastWalkableUp;
}
else if (!isOnGround && !ci.ContactPlaneValid && !ci.LastKnownContactPlaneValid)
{
body.WalkablePolygonValid = false;
body.WalkableVertices = null;
}
// Retail persists sliding state to the body ONLY on transition
// SUCCESS: CPhysicsObj::SetPositionInternal copies the normal at
// 0x005154c2 and syncs SLIDING_TS (bit 4) from the transition's
// final sliding_normal_valid at 0x005154e1 — and SetPositionInternal
// is unreachable when find_valid_position fails (the transition is
// discarded whole; the body keeps its prior state). #137 mechanism
// 2: an unconditional writeback here could persist a normal retail
// would discard.
if (ci.SlidingNormalValid
&& ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
{
body.SlidingNormal = ci.SlidingNormal;
body.TransientState |= TransientStateFlags.Sliding;
}
else
{
body.SlidingNormal = Vector3.Zero;
body.TransientState &= ~TransientStateFlags.Sliding;
}
}
// L.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity.
// Phase 3's reset path sets VelocityKilled when an airborne hit
// can't find a walkable surface (steep roof, wall) AND the
// body had a last_known_contact_plane (i.e., was grounded
// recently). Retail zeros all three velocity components so
// gravity restarts cleanly next frame.
//
// Named-retail: OBJECTINFO::kill_velocity → CPhysicsObj::set_velocity({0,0,0}, 0)
// acclient_2013_pseudo_c.txt:274467-274475
// Called from CTransition::transitional_insert reset path:
// acclient_2013_pseudo_c.txt:273237 (Phase 3)
// acclient_2013_pseudo_c.txt:272567 (validate_transition)
if (transition.ObjectInfo.VelocityKilled)
{
if (PhysicsDiagnostics.DumpSteepRoofEnabled)
Console.WriteLine($"[steep-roof] KILL-VELOCITY-APPLIED Vbefore=({body.Velocity.X:F2},{body.Velocity.Y:F2},{body.Velocity.Z:F2}) → 0,0,0");
body.Velocity = Vector3.Zero;
}
}
// L.3a (2026-04-30): surface the wall normal so callers can apply
// retail's velocity-reflection bounce (CPhysicsObj::handle_all_collisions
// at acclient_2013_pseudo_c.txt:282699-282715, ACE PhysicsObj.cs:
// 2692-2697). The reflection itself is applied in
// PlayerMovementController after the position commit, gated on
// apply_bounce = !(prevOnWalkable && newOnWalkable) — airborne wall
// hits bounce, grounded wall slides don't.
bool collisionNormalValid = ci.CollisionNormalValid;
Vector3 collisionNormal = ci.CollisionNormal;
// #42 diagnostic (2026-05-05): trace airborne sweeps to identify the
// source of the ~1m XY drift on retail-observed stationary jumps.
// Gated on ACDREAM_AIRBORNE_DIAG=1 and !isOnGround. One line per
// resolve call. deltaXY = post - target tells us how much the sweep
// diverged from the requested target; for a clean stationary +Z
// jump we expect (0,0). cp=valid with a tilted normal would confirm
// H1 (initial-overlap depenetration → next-step AdjustOffset projects
// the +Z offset along a non-+Z normal). User repros at flat plaza /
// east hillside / north hillside; if drift direction tracks terrain
// orientation, H1 is the cause; if it tracks actor facing, H2 / H3.
if (!isOnGround
&& Environment.GetEnvironmentVariable("ACDREAM_AIRBORNE_DIAG") == "1")
{
var post = sp.CheckPos;
float dx = post.X - targetPos.X;
float dy = post.Y - targetPos.Y;
string cpInfo = ci.ContactPlaneValid
? $"valid cpN=({ci.ContactPlane.Normal.X:F3},{ci.ContactPlane.Normal.Y:F3},{ci.ContactPlane.Normal.Z:F3})"
: "none";
Console.WriteLine(
$"[SWEEP] airborne pre=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) " +
$"target=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) " +
$"post=({post.X:F3},{post.Y:F3},{post.Z:F3}) " +
$"cell={cellId:X8}->{sp.CheckCellId:X8} ok={ok} " +
$"deltaXY=({dx:F3},{dy:F3}) cp={cpInfo}");
}
// L.2a slice 1 (2026-05-12): general-purpose resolver probe.
// One line per call when PhysicsDiagnostics.ProbeResolveEnabled
// is set (env var ACDREAM_PROBE_RESOLVE=1 at startup, or the
// DebugPanel checkbox flipped at runtime). Captures every
// dimension L.2 cares about: input/output position, input/output
// cell, ok-vs-partial, grounded-in vs contact-out, contact-plane
// status, wall normal if hit, walkable polygon valid. Zero cost
// when off (one static-bool read).
if (PhysicsDiagnostics.ProbeResolveEnabled)
{
var probePost = sp.CheckPos;
string probeCp = ci.ContactPlaneValid
? "valid"
: (ci.LastKnownContactPlaneValid ? "lastKnown" : "none");
string probeHit;
if (collisionNormalValid)
{
// L.2a slice 2 (2026-05-12): include the hit object's guid +
// environment flag so we can tell whether the wall is a building
// (CBuildingObj), a door (CC0Cxxxx range), an NPC, or terrain.
// Without this we know the wall normal but not the responsible
// entity — half the L.2d sub-direction call.
string objPart = ci.LastCollidedObjectGuid.HasValue
? System.FormattableString.Invariant(
$" obj=0x{ci.LastCollidedObjectGuid.Value:X8}")
: "";
string envPart = ci.CollidedWithEnvironment ? " env" : "";
int objCount = ci.CollideObjectGuids.Count;
string objCountPart = objCount > 1
? System.FormattableString.Invariant($" nObj={objCount}")
: "";
probeHit = System.FormattableString.Invariant(
$"yes n=({collisionNormal.X:F2},{collisionNormal.Y:F2},{collisionNormal.Z:F2}){objPart}{envPart}{objCountPart}");
resolveResult = new ResolveResult(
sp.CheckPos,
// Phase W Stage 1: return the transition's SWEPT cell (retail SetPositionInternal
// reads sphere_path.curr_cell), not a static re-derive from the resting origin.
// ValidateTransition advances sp.CurCellId only on accepted moves / reverts on
// blocks, so push-back or standing still cannot flip it. The render root
// (CellGraph.CurrCell) is NOT written here — this runs for EVERY entity; it is set
// from this id only by the player's UpdateCellId (see UpdatePlayerCurrCell).
sp.CurCellId,
onGround,
collisionNormalValid,
collisionNormal,
Orientation: sp.CurOrientation,
InContact: inContact,
OnWalkable: onWalkable);
}
else
{
probeHit = "no";
// Transition failed (e.g., stuck in corner, too many steps).
// Use whatever position the transition reached (partial movement)
// instead of falling back to the no-collision Resolve.
// If CheckPos hasn't moved from CurPos, the player stays put —
// this is correct behavior when completely blocked.
bool partialOnGround = ci.ContactPlaneValid
|| (transition.ObjectInfo.State & ObjectInfoState.OnWalkable) != 0
|| isOnGround;
uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId;
resolveResult = new ResolveResult(
sp.CheckPos,
// Phase W Stage 1: prefer the swept cell; fall back to partialCellId only when
// sp.CurCellId is zero (transition never advanced — teleport or physics reset).
// (Render root set by the player's UpdateCellId, not here — see UpdatePlayerCurrCell.)
sp.CurCellId != 0 ? sp.CurCellId : partialCellId,
partialOnGround,
collisionNormalValid,
collisionNormal,
Ok: false,
Orientation: sp.CurOrientation); // Render Residual A — the sweep failed (find_valid_position == 0)
}
Console.WriteLine(System.FormattableString.Invariant(
$"[resolve] ent=0x{movingEntityId:X8} in=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cell=0x{cellId:X8} tgt=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) out=({probePost.X:F3},{probePost.Y:F3},{probePost.Z:F3}) cell=0x{sp.CheckCellId:X8} ok={ok} groundedIn={isOnGround} cp={probeCp} hit={probeHit} walkable={sp.HasLastWalkablePolygon}"));
}
// Phase W Stage 0 (2026-06-02): [cell-swept] probe — swept cell vs static-derived cell.
// Emits before the ResolveResult is built so it shows what BOTH paths would return.
// No ResolveCellId call here (it has a CellGraph.CurrCell side effect). No behavior change.
if (PhysicsDiagnostics.ProbeSweptEnabled)
// A6.P3 #98 capture: emit one JSON Lines record per player call,
// with bodyBefore snapshot (taken at method entry, before any
// engine mutation) + bodyAfter snapshot (taken now, after the
// engine wrote back the contact plane / walkable / sliding state
// to the body). Loaded by CellarUpTrajectoryReplayTests.cs.
if (captureEnabled)
{
PhysicsResolveCapture.LogCall(
new ResolveCallInputs(
CurrentPos: currentPos,
TargetPos: targetPos,
CellId: cellId,
SphereRadius: sphereRadius,
SphereHeight: sphereHeight,
StepUpHeight: stepUpHeight,
StepDownHeight: stepDownHeight,
IsOnGround: isOnGround,
MoverFlags: (uint)moverFlags,
MovingEntityId: movingEntityId),
bodyBeforeSnap,
new ResolveCallResult(
Position: resolveResult.Position,
CellId: resolveResult.CellId,
IsOnGround: resolveResult.IsOnGround,
CollisionNormalValid: resolveResult.CollisionNormalValid,
CollisionNormal: resolveResult.CollisionNormal),
body is not null ? PhysicsResolveCapture.Snapshot(body) : null);
}
return resolveResult;
}
finally
{
Console.WriteLine(System.FormattableString.Invariant(
$"[cell-swept] ent=0x{movingEntityId:X8} ok={ok} inCell=0x{cellId:X8} curCell=0x{sp.CurCellId:X8} checkCell=0x{sp.CheckCellId:X8} curPos=({sp.CurPos.X:F3},{sp.CurPos.Y:F3},{sp.CurPos.Z:F3}) checkPos=({sp.CheckPos.X:F3},{sp.CheckPos.Y:F3},{sp.CheckPos.Z:F3})"));
ReturnTransition(transition);
}
ResolveResult resolveResult;
if (ok)
{
bool inContact = ci.ContactPlaneValid;
bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
inContact,
ci.ContactPlane.Normal);
bool onGround = inContact
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
resolveResult = new ResolveResult(
sp.CheckPos,
// Phase W Stage 1: return the transition's SWEPT cell (retail SetPositionInternal
// reads sphere_path.curr_cell), not a static re-derive from the resting origin.
// ValidateTransition advances sp.CurCellId only on accepted moves / reverts on
// blocks, so push-back or standing still cannot flip it. The render root
// (CellGraph.CurrCell) is NOT written here — this runs for EVERY entity; it is set
// from this id only by the player's UpdateCellId (see UpdatePlayerCurrCell).
sp.CurCellId,
onGround,
collisionNormalValid,
collisionNormal,
Orientation: sp.CurOrientation,
InContact: inContact,
OnWalkable: onWalkable);
}
else
{
// Transition failed (e.g., stuck in corner, too many steps).
// Use whatever position the transition reached (partial movement)
// instead of falling back to the no-collision Resolve.
// If CheckPos hasn't moved from CurPos, the player stays put —
// this is correct behavior when completely blocked.
bool partialOnGround = ci.ContactPlaneValid
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable)
|| isOnGround;
uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId;
resolveResult = new ResolveResult(
sp.CheckPos,
// Phase W Stage 1: prefer the swept cell; fall back to partialCellId only when
// sp.CurCellId is zero (transition never advanced — teleport or physics reset).
// (Render root set by the player's UpdateCellId, not here — see UpdatePlayerCurrCell.)
sp.CurCellId != 0 ? sp.CurCellId : partialCellId,
partialOnGround,
collisionNormalValid,
collisionNormal,
Ok: false,
Orientation: sp.CurOrientation); // Render Residual A — the sweep failed (find_valid_position == 0)
}
// A6.P3 #98 capture: emit one JSON Lines record per player call,
// with bodyBefore snapshot (taken at method entry, before any
// engine mutation) + bodyAfter snapshot (taken now, after the
// engine wrote back the contact plane / walkable / sliding state
// to the body). Loaded by CellarUpTrajectoryReplayTests.cs.
if (captureEnabled)
{
PhysicsResolveCapture.LogCall(
new ResolveCallInputs(
CurrentPos: currentPos,
TargetPos: targetPos,
CellId: cellId,
SphereRadius: sphereRadius,
SphereHeight: sphereHeight,
StepUpHeight: stepUpHeight,
StepDownHeight: stepDownHeight,
IsOnGround: isOnGround,
MoverFlags: (uint)moverFlags,
MovingEntityId: movingEntityId),
bodyBeforeSnap,
new ResolveCallResult(
Position: resolveResult.Position,
CellId: resolveResult.CellId,
IsOnGround: resolveResult.IsOnGround,
CollisionNormalValid: resolveResult.CollisionNormalValid,
CollisionNormal: resolveResult.CollisionNormal),
body is not null ? PhysicsResolveCapture.Snapshot(body) : null);
}
return resolveResult;
}
/// <summary>
@ -1413,38 +1445,45 @@ public sealed class PhysicsEngine
ObjectInfoState moverFlags = ObjectInfoState.None,
uint movingEntityId = 0)
{
var transition = new Transition();
transition.ObjectInfo.StepUpHeight = stepUpHeight;
transition.ObjectInfo.StepDownHeight = stepDownHeight;
transition.ObjectInfo.StepDown = true;
transition.ObjectInfo.SelfEntityId = movingEntityId;
transition.ObjectInfo.State = moverFlags;
transition.SpherePath.InitPath(
position, position, cellId, sphereRadius, sphereHeight);
transition.SpherePath.InsertType = InsertType.Placement;
var transition = RentTransition();
try
{
transition.ObjectInfo.StepUpHeight = stepUpHeight;
transition.ObjectInfo.StepDownHeight = stepDownHeight;
transition.ObjectInfo.StepDown = true;
transition.ObjectInfo.SelfEntityId = movingEntityId;
transition.ObjectInfo.State = moverFlags;
transition.SpherePath.InitPath(
position, position, cellId, sphereRadius, sphereHeight);
transition.SpherePath.InsertType = InsertType.Placement;
bool ok = transition.FindPlacementPos(this);
var sp = transition.SpherePath;
var ci = transition.CollisionInfo;
bool inContact = ci.ContactPlaneValid;
bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
inContact,
ci.ContactPlane.Normal);
bool onGround = inContact
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
bool ok = transition.FindPlacementPos(this);
var sp = transition.SpherePath;
var ci = transition.CollisionInfo;
bool inContact = ci.ContactPlaneValid;
bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
inContact,
ci.ContactPlane.Normal);
bool onGround = inContact
|| (transition.ObjectInfo.State & ObjectInfoState.OnWalkable) != 0;
return new ResolveResult(
sp.CurPos,
sp.CurCellId != 0 ? sp.CurCellId : cellId,
onGround,
ci.CollisionNormalValid,
ci.CollisionNormal,
ok,
Orientation: sp.CurOrientation,
InContact: inContact,
OnWalkable: onWalkable,
ContactPlane: ci.ContactPlane,
ContactPlaneCellId: ci.ContactPlaneCellId,
ContactPlaneIsWater: ci.ContactPlaneIsWater);
return new ResolveResult(
sp.CurPos,
sp.CurCellId != 0 ? sp.CurCellId : cellId,
onGround,
ci.CollisionNormalValid,
ci.CollisionNormal,
ok,
Orientation: sp.CurOrientation,
InContact: inContact,
OnWalkable: onWalkable,
ContactPlane: ci.ContactPlane,
ContactPlaneCellId: ci.ContactPlaneCellId,
ContactPlaneIsWater: ci.ContactPlaneIsWater);
}
finally
{
ReturnTransition(transition);
}
}
}

View file

@ -6,7 +6,28 @@ namespace AcDream.Core.Physics;
public readonly record struct TerrainSurfacePolygon(
float Z,
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>
/// Outdoor terrain height resolver for a single landblock. Performs
@ -27,8 +48,8 @@ public sealed class TerrainSurface
public const int CellsPerSide = 8; // 192 / 24
private readonly float[,] _z; // pre-resolved heights [x, y]
private readonly bool[,] _cornerIsWater; // per-VERTEX water flag [x, y] — SurfChar[(type >> 2) & 0x1F]
private readonly byte[,] _cellWaterType; // per-CELL 0=NotWater, 1=Partially, 2=Entirely [cx, cy]
private readonly bool[,] _cornerIsWater; // per-VERTEX water flag [x, y] — SurfChar[(type >> 2) & 0x1F]
private readonly byte[,] _cellWaterType; // per-CELL 0=NotWater, 1=Partially, 2=Entirely [cx, cy]
private readonly uint _landblockX;
private readonly uint _landblockY;
@ -49,8 +70,8 @@ public sealed class TerrainSurface
// Pre-resolve all 81 heights so SampleZ is a pure lookup + lerp.
_z = new float[HeightmapSide, HeightmapSide];
for (int x = 0; x < HeightmapSide; x++)
for (int y = 0; y < HeightmapSide; y++)
_z[x, y] = heightTable[heights[x * HeightmapSide + y]];
for (int y = 0; y < HeightmapSide; y++)
_z[x, y] = heightTable[heights[x * HeightmapSide + y]];
// Per-vertex water flag. TerrainType lives in bits 2-6 of each
// TerrainInfo byte; water is types 0x10-0x14 inclusive (per
@ -62,11 +83,11 @@ public sealed class TerrainSurface
if (terrainTypes is not null && terrainTypes.Length >= 81)
{
for (int x = 0; x < HeightmapSide; x++)
for (int y = 0; y < HeightmapSide; y++)
{
int typeBits = (terrainTypes[x * HeightmapSide + y] >> 2) & 0x1F;
_cornerIsWater[x, y] = typeBits >= 0x10 && typeBits <= 0x14;
}
for (int y = 0; y < HeightmapSide; y++)
{
int typeBits = (terrainTypes[x * HeightmapSide + y] >> 2) & 0x1F;
_cornerIsWater[x, y] = typeBits >= 0x10 && typeBits <= 0x14;
}
}
// Per-cell water classification (mirrors ACE
@ -74,21 +95,21 @@ public sealed class TerrainSurface
// vertex corners; count how many are water type.
_cellWaterType = new byte[CellsPerSide, CellsPerSide];
for (int cx = 0; cx < CellsPerSide; cx++)
for (int cy = 0; cy < CellsPerSide; cy++)
{
int waterCorners = 0;
if (_cornerIsWater[cx, cy ]) waterCorners++;
if (_cornerIsWater[cx + 1, cy ]) waterCorners++;
if (_cornerIsWater[cx + 1, cy + 1]) waterCorners++;
if (_cornerIsWater[cx, cy + 1]) waterCorners++;
_cellWaterType[cx, cy] = waterCorners switch
for (int cy = 0; cy < CellsPerSide; cy++)
{
0 => 0, // NotWater
4 => 2, // EntirelyWater
_ => 1, // PartiallyWater
};
}
int waterCorners = 0;
if (_cornerIsWater[cx, cy]) waterCorners++;
if (_cornerIsWater[cx + 1, cy]) waterCorners++;
if (_cornerIsWater[cx + 1, cy + 1]) waterCorners++;
if (_cornerIsWater[cx, cy + 1]) waterCorners++;
_cellWaterType[cx, cy] = waterCorners switch
{
0 => 0, // NotWater
4 => 2, // EntirelyWater
_ => 1, // PartiallyWater
};
}
}
/// <summary>
@ -134,10 +155,10 @@ public sealed class TerrainSurface
float ty = fy - cy;
// Four corner heights (BL=SW, BR=SE, TR=NE, TL=NW)
float hBL = _z[cx, cy ];
float hBR = _z[cx + 1, cy ];
float hBL = _z[cx, cy];
float hBR = _z[cx + 1, cy];
float hTR = _z[cx + 1, cy + 1];
float hTL = _z[cx, cy + 1];
float hTL = _z[cx, cy + 1];
// Split direction — same formula as TerrainBlending.CalculateSplitDirection
// and ACE's LandblockStruct.ConstructPolygons.
@ -189,10 +210,10 @@ public sealed class TerrainSurface
// x-major heightmap indexing matches TerrainSurface's pre-resolution
// (heights[x * 9 + y]) and ACE LandblockStruct.
float hBL = heightTable[heights[cx * HeightmapSide + cy ]];
float hBR = heightTable[heights[(cx+1) * HeightmapSide + cy ]];
float hTR = heightTable[heights[(cx+1) * HeightmapSide + (cy+1)]];
float hTL = heightTable[heights[cx * HeightmapSide + (cy+1)]];
float hBL = heightTable[heights[cx * HeightmapSide + cy]];
float hBR = heightTable[heights[(cx + 1) * HeightmapSide + cy]];
float hTR = heightTable[heights[(cx + 1) * HeightmapSide + (cy + 1)]];
float hTL = heightTable[heights[cx * HeightmapSide + (cy + 1)]];
bool splitSWtoNE = IsSplitSWtoNE(landblockX, (uint)cx, landblockY, (uint)cy);
return InterpolateZInTriangle(hBL, hBR, hTR, hTL, tx, ty, splitSWtoNE);
@ -227,10 +248,10 @@ public sealed class TerrainSurface
float tx = fx - cx;
float ty = fy - cy;
float hBL = heightTable[heights[cx * HeightmapSide + cy ]];
float hBR = heightTable[heights[(cx+1) * HeightmapSide + cy ]];
float hTR = heightTable[heights[(cx+1) * HeightmapSide + (cy+1)]];
float hTL = heightTable[heights[cx * HeightmapSide + (cy+1)]];
float hBL = heightTable[heights[cx * HeightmapSide + cy]];
float hBR = heightTable[heights[(cx + 1) * HeightmapSide + cy]];
float hTR = heightTable[heights[(cx + 1) * HeightmapSide + (cy + 1)]];
float hTL = heightTable[heights[cx * HeightmapSide + (cy + 1)]];
bool splitSWtoNE = IsSplitSWtoNE(landblockX, (uint)cx, landblockY, (uint)cy);
@ -332,10 +353,10 @@ public sealed class TerrainSurface
float tx = fx - cx;
float ty = fy - cy;
float hBL = _z[cx, cy ];
float hBR = _z[cx + 1, cy ];
float hBL = _z[cx, cy];
float hBR = _z[cx + 1, cy];
float hTR = _z[cx + 1, cy + 1];
float hTL = _z[cx, cy + 1];
float hTL = _z[cx, cy + 1];
bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy);
@ -352,14 +373,14 @@ public sealed class TerrainSurface
if (tx > ty)
{
// {BL,BR,TR}: Z = hBL + (hBR-hBL)·tx + (hTR-hBR)·ty
z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty;
z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty;
dzdx = (hBR - hBL) / CellSize;
dzdy = (hTR - hBR) / CellSize;
}
else
{
// {BL,TR,TL}: Z = hBL + (hTR-hTL)·tx + (hTL-hBL)·ty
z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty;
z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty;
dzdx = (hTR - hTL) / CellSize;
dzdy = (hTL - hBL) / CellSize;
}
@ -370,7 +391,7 @@ public sealed class TerrainSurface
if (tx + ty <= 1f)
{
// {BL,BR,TL}: Z = hBL + (hBR-hBL)·tx + (hTL-hBL)·ty
z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty;
z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty;
dzdx = (hBR - hBL) / CellSize;
dzdy = (hTL - hBL) / CellSize;
}
@ -378,7 +399,7 @@ public sealed class TerrainSurface
{
// {BR,TR,TL}: Z = hTR + (hTL-hTR)(1-tx) + (hBR-hTR)(1-ty)
// Equivalent linear form: Z = [hBR+hTL-hTR] + (hTR-hTL)·tx + (hTR-hBR)·ty
z = hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty);
z = hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty);
dzdx = (hTR - hTL) / CellSize;
dzdy = (hTR - hBR) / CellSize;
}
@ -405,32 +426,32 @@ public sealed class TerrainSurface
float tx = fx - cx;
float ty = fy - cy;
float hBL = _z[cx, cy ];
float hBR = _z[cx + 1, cy ];
float hBL = _z[cx, cy];
float hBR = _z[cx + 1, cy];
float hTR = _z[cx + 1, cy + 1];
float hTL = _z[cx, cy + 1];
float hTL = _z[cx, cy + 1];
bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy);
Vector3 bl = new(cx * CellSize, cy * CellSize, hBL);
Vector3 br = new((cx + 1) * CellSize, cy * CellSize, hBR);
Vector3 bl = new(cx * CellSize, cy * CellSize, hBL);
Vector3 br = new((cx + 1) * CellSize, cy * CellSize, hBR);
Vector3 tr = new((cx + 1) * CellSize, (cy + 1) * CellSize, hTR);
Vector3 tl = new(cx * CellSize, (cy + 1) * CellSize, hTL);
Vector3 tl = new(cx * CellSize, (cy + 1) * CellSize, hTL);
float z;
Vector3[] vertices;
TerrainTriangleVertices vertices;
if (splitSWtoNE)
{
if (tx > ty)
{
z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty;
vertices = new[] { bl, br, tr };
vertices = new TerrainTriangleVertices(bl, br, tr);
}
else
{
z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty;
vertices = new[] { bl, tr, tl };
vertices = new TerrainTriangleVertices(bl, tr, tl);
}
}
else
@ -438,12 +459,12 @@ public sealed class TerrainSurface
if (tx + ty <= 1f)
{
z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty;
vertices = new[] { bl, br, tl };
vertices = new TerrainTriangleVertices(bl, br, tl);
}
else
{
z = hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty);
vertices = new[] { br, tr, tl };
vertices = new TerrainTriangleVertices(br, tr, tl);
}
}

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

File diff suppressed because it is too large Load diff

View file

@ -134,9 +134,13 @@ public class TerrainSurfaceTests
var sample = surface.SampleSurfacePolygon(2f, 2f);
Assert.Equal(3, sample.Vertices.Length);
Assert.All(sample.Vertices, v => Assert.Equal(50f, v.Z));
for (int i = 0; i < sample.Vertices.Length; i++)
Assert.Equal(50f, sample.Vertices[i].Z);
Assert.Equal(1f, sample.Normal.Z, precision: 3);
Assert.Contains(sample.Vertices, v => v.X == 0f && v.Y == 0f);
Assert.True(
sample.Vertices.V0.X == 0f && sample.Vertices.V0.Y == 0f
|| sample.Vertices.V1.X == 0f && sample.Vertices.V1.Y == 0f
|| sample.Vertices.V2.X == 0f && sample.Vertices.V2.Y == 0f);
}
[Fact]

View file

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

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