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:
parent
2effba5127
commit
16d182c2f0
19 changed files with 3037 additions and 1228 deletions
10
AGENTS.md
10
AGENTS.md
|
|
@ -156,15 +156,19 @@ complete. G4's 46,599-comparison automated gate has zero mismatches, the user
|
|||
accepted the physical-display visual matrix, and G5 removed the production
|
||||
`InteriorEntityPartition`. The ordinary production profile sustains 519.7 FPS
|
||||
with CPU/GPU p50 1.869/1.096 ms and 652.1/928.3 MiB working/private memory.
|
||||
The measured 22.34 KiB/frame remainder is assigned to Slice I1.
|
||||
The G5 profile's 22.34 KiB/frame remainder led into Slice I1. That slice is
|
||||
now complete: player, remote, projectile, camera, and grounded
|
||||
walkable-publication profiles each measure 0 B/resolve, with bit-identical
|
||||
fresh-vs-reused results and complete reset/reentrancy gates.
|
||||
Slice H's retained UI/frame, bounded-light, and pooled/borrowed/direct network
|
||||
work is complete; the exact seven-checkpoint connected lifecycle/reconnect gate
|
||||
passes. Slice I is active at I1 from
|
||||
passes. Slice I is active at I2 from
|
||||
`docs/plans/2026-07-25-modern-runtime-slice-i.md`. The exact G4 visual rollback
|
||||
is `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`; do not revert G3 or
|
||||
the portal-warmup corrections. Evidence:
|
||||
`docs/research/2026-07-25-slice-h-closeout.md` and
|
||||
`docs/research/2026-07-25-slice-g5-production-profile.md`.
|
||||
`docs/research/2026-07-25-slice-g5-production-profile.md` and
|
||||
`docs/research/2026-07-25-slice-i1-transition-scratch.md`.
|
||||
|
||||
**Structural prerequisite before new M4 subsystem work:** all eight
|
||||
behavior-preserving `GameWindow` decomposition slices and the automated
|
||||
|
|
|
|||
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -154,15 +154,19 @@ complete. G4's 46,599-comparison automated gate has zero mismatches, the user
|
|||
accepted the physical-display visual matrix, and G5 removed the production
|
||||
`InteriorEntityPartition`. The ordinary production profile sustains 519.7 FPS
|
||||
with CPU/GPU p50 1.869/1.096 ms and 652.1/928.3 MiB working/private memory.
|
||||
The measured 22.34 KiB/frame remainder is assigned to Slice I1.
|
||||
The G5 profile's 22.34 KiB/frame remainder led into Slice I1. That slice is
|
||||
now complete: player, remote, projectile, camera, and grounded
|
||||
walkable-publication profiles each measure 0 B/resolve, with bit-identical
|
||||
fresh-vs-reused results and complete reset/reentrancy gates.
|
||||
Slice H's retained UI/frame, bounded-light, and pooled/borrowed/direct network
|
||||
work is complete; the exact seven-checkpoint connected lifecycle/reconnect gate
|
||||
passes. Slice I is active at I1 from
|
||||
passes. Slice I is active at I2 from
|
||||
`docs/plans/2026-07-25-modern-runtime-slice-i.md`. The exact G4 visual rollback
|
||||
is `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`; do not revert G3 or
|
||||
the portal-warmup corrections. Evidence:
|
||||
`docs/research/2026-07-25-slice-h-closeout.md` and
|
||||
`docs/research/2026-07-25-slice-g5-production-profile.md`.
|
||||
`docs/research/2026-07-25-slice-g5-production-profile.md` and
|
||||
`docs/research/2026-07-25-slice-i1-transition-scratch.md`.
|
||||
|
||||
**Structural prerequisite before new M4 subsystem work:** all eight
|
||||
behavior-preserving `GameWindow` decomposition slices and the automated
|
||||
|
|
|
|||
|
|
@ -327,7 +327,7 @@ covered by a zero-managed-allocation test. Evidence:
|
|||
|
||||
## #237 — PhysicsEngine allocates a fresh Transition graph per resolve call
|
||||
|
||||
**Status:** OPEN
|
||||
**Status:** DONE — 2026-07-25, Modern Runtime Slice I1
|
||||
**Severity:** MEDIUM
|
||||
**Filed:** 2026-07-24
|
||||
**Component:** physics
|
||||
|
|
@ -339,14 +339,21 @@ NPC/remote, projectiles, and the camera probe — per-tick GC pressure that
|
|||
scales with active-mover count (combat/crowds), a scenario the audit's
|
||||
dwell-heavy route under-measured.
|
||||
|
||||
**Root cause / status:** 2026-07-24 audit review finding. Pooling is only
|
||||
allowed under plan Slice I's identity/lifetime-test condition (the adjacent
|
||||
`LiveEntityAnimationScheduler` scratch-reuse pattern is the precedent) —
|
||||
Transition state must be provably reset-complete between movers or a pooled
|
||||
graph corrupts collision state, which is worse than the allocation.
|
||||
**Resolution:** Each `PhysicsEngine` now owns retail-shaped ten-deep,
|
||||
same-thread, LIFO transition scratch. Every stored member is reset and
|
||||
reflection-poisoned in tests; exact-length walkable, candidate-cell, collision
|
||||
GUID, and reentrant neighbor-order storage retains identity without exposing
|
||||
stale state. Fresh-vs-reused engines are bit-identical across hostile
|
||||
player/remote/projectile/camera/placement/failure/step/slide cases. Release
|
||||
allocation fell from 2,512–6,848 B/resolve to 0 B/resolve for all measured
|
||||
mover profiles. Evidence:
|
||||
`docs/research/2026-07-25-slice-i1-transition-scratch.md`.
|
||||
|
||||
**Files:** `src/AcDream.Core/Physics/PhysicsEngine.cs:995`;
|
||||
`src/AcDream.Core/Physics/TransitionTypes.cs:360-367,694-698`.
|
||||
**Files:** `src/AcDream.Core/Physics/TransitionScratchArena.cs`;
|
||||
`src/AcDream.Core/Physics/PhysicsEngine.cs`;
|
||||
`src/AcDream.Core/Physics/TransitionTypes.cs`;
|
||||
`tests/AcDream.Core.Tests/Physics/TransitionScratchResetTests.cs`;
|
||||
`tests/AcDream.Core.Tests/Physics/TransitionScratchDifferentialTests.cs`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1647,14 +1647,16 @@ port in any phase — no separate listing here.
|
|||
> come from the retained frame product. The exact lifecycle/reconnect matrix,
|
||||
> capped/uncapped/dense correctness routes, and ordinary production profile
|
||||
> pass. The final stable ordinary-production minute sustains 519.7 FPS with
|
||||
> CPU/GPU p50 1.869/1.096 ms; the measured 22.34 KiB/frame remainder is the
|
||||
> active Slice-I transition/query-scratch owner.
|
||||
> CPU/GPU p50 1.869/1.096 ms. Slice I1 now removes the isolated
|
||||
> transition/query owner: player, remote, projectile, camera, and grounded
|
||||
> walkable-publication profiles each measure 0 B/resolve, and retained
|
||||
> transitions remain bit-identical to the fresh-graph oracle.
|
||||
> The historical exact cutover
|
||||
> rollback remains
|
||||
> `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`. Slice H is complete:
|
||||
> retained UI/frame work, exact bounded light selection, and
|
||||
> pooled/borrowed/direct network I/O pass 8,292 Release tests / 5 skips and the
|
||||
> connected lifecycle/reconnect gate. Slice I is active at I1 from
|
||||
> connected lifecycle/reconnect gate. Slice I is active at I2 from
|
||||
> [its detailed plan](2026-07-25-modern-runtime-slice-i.md).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the
|
||||
|
|
@ -1679,11 +1681,11 @@ hitch and follows.
|
|||
| MP1a | `AcDream.Content` extraction (GL-free MeshExtractor + boundary records out of App) | ✅ SHIPPED 2026-07-05 — user-gated (renders identical, zero tripwires, perf-neutral); 8 commits `651d041e`..`b0758d77` |
|
||||
| MP1b | Pak format + `acdream-bake` CLI + mmap `PakReader` + equivalence/full-scale gate | ✅ **SHIPPED 2026-07-24** — 729,888 EnvCell keys → 17,117 unique geometries + 712,771 aliases (42.6×), 751,141 total keys / 38,370 physical blobs, zero failures, 28,192.4 MiB, 81.4 s validated atomic publish; full-DAT gate also corrected a real collision in WB's legacy geometry hash. [Report](../research/2026-07-24-slice-b-full-bake-report.md) |
|
||||
| **MP-Alloc (safe batch)** | Reuse per-frame buffers: anim pose, particle draw-list, interior partition, animatedIds/drawableCells sets | ✅ SHIPPED + USER-GATED 2026-07-05 — dense-town frame-time spikes 20–87ms → 6–10ms, alloc spikes (30–75MB single-frame) eliminated, gen2 GC 5–11/window → ~0; user confirms FPS steady. 4 commits `b8c05e2b`..`91afea24` |
|
||||
| MP-Alloc (hard sites) | EnvCell settled-camera rebuild gate + physics `Transition` pooling — the residual steady ~1.6MB/frame | ⚪ OPTIONAL follow-up — would lower the median too, but the wild-swing complaint is already resolved; each needs its own careful gate (batch correctness / physics faithfulness) |
|
||||
| MP-Alloc (hard sites) | EnvCell settled-camera rebuild gate + physics `Transition` reuse | 🟡 Physics half SHIPPED 2026-07-25 — retail-shaped reset-complete transition/query scratch is bit-identical and 0 B/resolve across five profiles; flat collision storage continues in Slice I2. |
|
||||
| MP1c | Streaming cutover to pak + hitch gate | ✅ **SHIPPED 2026-07-24** — typed package-only production source, explicit Setup probes, non-allocating Portal-first lookup, exact translucency metadata, and ordered teardown; capped/uncapped/dense physical routes + user visual gate pass. Uncapped CPU p99 7.825→6.496 ms, GPU p99 3.406→2.706 ms, portal frame max 202.9→39.9 MiB, process allocation −41.8%, zero invalid Setup probes. [Report](../research/2026-07-24-slice-c-prepared-asset-cutover-report.md) |
|
||||
| MP2 | Retail particle distance/cell-view degradation: DAT-authored range, exact infinite/finite off-view update branches, emitter-first render scan, user-requested 2× default range | ✅ SHIPPED 2026-07-17 — Aerlinthe hotspot `0x32000223` resolves to retail 64m and defaults to 128m; landscape/entity streaming distance unchanged |
|
||||
| MP3 | Arch ECS render world + delta submission (the 300-FPS lever) | ⚪ — note: does NOT fix the steady-state GC churn (that's MP-Alloc); MP3 is the draw-submission throughput lever |
|
||||
| MP4 | Zero-alloc frame loop + flat physics data (residual, post-MP-Alloc) | ⚪ hard-queued behind M1.5 #137 |
|
||||
| MP4 | Zero-alloc frame loop + flat physics data (residual, post-MP-Alloc) | 🟡 ACTIVE — Slice I1 zero-allocation transition scratch shipped; I2 immutable flat collision assets in progress. |
|
||||
| MP5 | Job-system parallelism | ⚪ stretch, evidence-gated |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
**Date:** 2026-07-24
|
||||
|
||||
**Status:** Slices A–H and I0 are complete. Slices F–L were explicitly
|
||||
approved 2026-07-24; Slice I1 is active.
|
||||
**Status:** Slices A–H and I0–I1 are complete. Slices F–L were explicitly
|
||||
approved 2026-07-24; Slice I2 is active.
|
||||
|
||||
**Scope:** Reconcile and sequence the existing Modern Pipeline (`MP`) and
|
||||
Linux/headless (`LH`) tracks using the 2026-07-24 connected performance audit.
|
||||
|
|
@ -1193,12 +1193,16 @@ seven-checkpoint lifecycle/reconnect gate pass with graceful shutdown.
|
|||
The final ordinary-production minute on exact `14fbe92b` sustained 519.7 FPS:
|
||||
CPU p50/p95/p99 1.869/2.306/2.484 ms and GPU
|
||||
1.096/1.108/1.136 ms. Working/private memory was 652.1/928.3 MiB.
|
||||
Frame-thread allocation is now 22.34 KiB median; allocation-tick evidence and
|
||||
the four-mover baseline assign that remainder primarily to reset-complete
|
||||
`Transition` collision/query scratch. I0 has pinned the retail oracle and
|
||||
fixtures, so I1 is the current execution point. Evidence:
|
||||
Frame-thread allocation at the G5 checkpoint was 22.34 KiB median. I1 has now
|
||||
replaced fresh per-resolve transition graphs and query temporaries with
|
||||
reset-complete retained scratch: player, remote, projectile, camera, and
|
||||
grounded walkable-publication profiles each measure 0 B/resolve, while
|
||||
fresh-vs-reused output and body state remain bit-identical. I2 immutable flat
|
||||
collision assets are the current execution point. Evidence:
|
||||
[`../research/2026-07-25-slice-g5-production-profile.md`](../research/2026-07-25-slice-g5-production-profile.md)
|
||||
and
|
||||
[`../research/2026-07-25-slice-i1-transition-scratch.md`](../research/2026-07-25-slice-i1-transition-scratch.md)
|
||||
and
|
||||
[`2026-07-25-modern-runtime-slice-i.md`](2026-07-25-modern-runtime-slice-i.md).
|
||||
|
||||
The intended order is therefore:
|
||||
|
|
@ -1212,7 +1216,7 @@ honest metrics + committed baselines (A — exit criteria block C)
|
|||
-> incremental render scene (F)
|
||||
-> delta GPU submission (G)
|
||||
-> residual frame cleanup (H-a, H-b, H-c)
|
||||
-> flat collision assets (I — CURRENT at I1)
|
||||
-> flat collision assets (I — CURRENT at I2)
|
||||
-> presentation-independent runtime (J — §0.3 authorization)
|
||||
-> Linux/headless/multi-session (K)
|
||||
-> evidence-gated GPU jobs (L)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
# Modern runtime Slice I — flat collision assets and zero-allocation physics
|
||||
|
||||
**Status:** I0 COMPLETE — retail oracle, representative graph fixtures, and
|
||||
four-mover allocation baselines fixed; G5 production closeout complete; I1
|
||||
active
|
||||
**Status:** I0–I1 COMPLETE — retail oracle, graph fixtures, and
|
||||
reset-complete zero-allocation transition/query scratch fixed; I2 active
|
||||
**Parent:** `2026-07-24-modern-runtime-architecture.md`, Slice I
|
||||
**Purpose:** remove parsed DAT object graphs and steady collision allocations
|
||||
without changing one retail collision decision, float, comparison, or traversal
|
||||
|
|
@ -123,7 +122,11 @@ graph path, and no behavior code changed.
|
|||
|
||||
Gate: bit-identical `ResolveResult` and body side effects, no state leakage
|
||||
when alternating hostile fixtures between different mover IDs, and zero
|
||||
steady transition/query-scratch allocation. Close issue #237 only here.
|
||||
steady transition/query-scratch allocation. **PASSED 2026-07-25:** the expanded
|
||||
118-test graph oracle, structural poison/reset suite, hostile fresh-vs-reused
|
||||
differential, Core/solution Release gates, and all five measured profiles pass.
|
||||
Issue #237 is closed. Evidence:
|
||||
[`../research/2026-07-25-slice-i1-transition-scratch.md`](../research/2026-07-25-slice-i1-transition-scratch.md).
|
||||
|
||||
### I2 — immutable flat collision schema and flattener
|
||||
|
||||
|
|
|
|||
108
docs/research/2026-07-25-slice-i1-transition-scratch.md
Normal file
108
docs/research/2026-07-25-slice-i1-transition-scratch.md
Normal 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.
|
||||
|
|
@ -32,8 +32,8 @@ public static class BSPQuery
|
|||
// Internal mutable sphere helper
|
||||
//
|
||||
// ACE's Sphere class is mutable (Center + Radius). We mirror that with a
|
||||
// small internal class so adjust_sphere_to_plane can mutate validPos in place,
|
||||
// exactly as ACE does when passing Sphere by reference through find_walkable.
|
||||
// Small internal value so query scratch lives on the stack. Mutation sites
|
||||
// pass it explicitly by ref, exactly matching retail's Sphere* boundaries.
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -42,7 +42,7 @@ public static class BSPQuery
|
|||
/// Mirrors ACE's Sphere(Center, Radius) which is passed by reference through
|
||||
/// the BSP recursive calls.
|
||||
/// </summary>
|
||||
internal sealed class CollisionSphere
|
||||
internal struct CollisionSphere
|
||||
{
|
||||
public Vector3 Center;
|
||||
public float Radius;
|
||||
|
|
@ -53,11 +53,6 @@ public static class BSPQuery
|
|||
Radius = radius;
|
||||
}
|
||||
|
||||
public CollisionSphere(CollisionSphere other)
|
||||
{
|
||||
Center = other.Center;
|
||||
Radius = other.Radius;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
|
@ -351,7 +346,7 @@ public static class BSPQuery
|
|||
private static bool AdjustSphereToPlane(
|
||||
ResolvedPolygon poly,
|
||||
SpherePath path,
|
||||
CollisionSphere validPos,
|
||||
ref CollisionSphere validPos,
|
||||
Vector3 movement)
|
||||
{
|
||||
// A6.P1: snapshot inputs for the [push-back] probe (cheap copy of
|
||||
|
|
@ -499,22 +494,17 @@ public static class BSPQuery
|
|||
: Vector3.UnitZ;
|
||||
}
|
||||
|
||||
private static Vector3[] TransformVertices(
|
||||
ReadOnlySpan<Vector3> vertices,
|
||||
private static Plane BuildWorldPlane(
|
||||
Vector3 worldNormal,
|
||||
ReadOnlySpan<Vector3> localVertices,
|
||||
Quaternion localToWorld,
|
||||
float scale,
|
||||
Vector3 worldOrigin)
|
||||
{
|
||||
var result = new Vector3[vertices.Length];
|
||||
for (int i = 0; i < vertices.Length; i++)
|
||||
result[i] = Vector3.Transform(vertices[i] * scale, localToWorld) + worldOrigin;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Plane BuildWorldPlane(Vector3 worldNormal, ReadOnlySpan<Vector3> worldVertices)
|
||||
{
|
||||
float d = worldVertices.Length > 0
|
||||
? -Vector3.Dot(worldNormal, worldVertices[0])
|
||||
float d = localVertices.Length > 0
|
||||
? -Vector3.Dot(
|
||||
worldNormal,
|
||||
Vector3.Transform(localVertices[0] * scale, localToWorld) + worldOrigin)
|
||||
: 0f;
|
||||
return new Plane(worldNormal, d);
|
||||
}
|
||||
|
|
@ -537,8 +527,9 @@ public static class BSPQuery
|
|||
/// </summary>
|
||||
private static void AdjustToPlacementPoly(
|
||||
ResolvedPolygon poly,
|
||||
CollisionSphere validPos,
|
||||
CollisionSphere? validPos2,
|
||||
ref CollisionSphere validPos,
|
||||
ref CollisionSphere validPos2,
|
||||
bool hasValidPos2,
|
||||
float radius,
|
||||
bool centerSolid,
|
||||
bool clearCell)
|
||||
|
|
@ -563,7 +554,7 @@ public static class BSPQuery
|
|||
var offset = moveDir * pushAmt;
|
||||
validPos.Center += offset;
|
||||
|
||||
if (validPos2 is not null)
|
||||
if (hasValidPos2)
|
||||
validPos2.Center += offset;
|
||||
}
|
||||
|
||||
|
|
@ -704,8 +695,8 @@ public static class BSPQuery
|
|||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Note: validPos is a reference type so mutations propagate back to the caller
|
||||
/// automatically, mirroring ACE's by-ref Sphere passing.
|
||||
/// Note: validPos is passed by reference so mutations propagate back to the
|
||||
/// caller, mirroring retail's by-ref Sphere passing.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -716,7 +707,7 @@ public static class BSPQuery
|
|||
PhysicsBSPNode? node,
|
||||
Dictionary<ushort, ResolvedPolygon> resolved,
|
||||
SpherePath path,
|
||||
CollisionSphere validPos,
|
||||
ref CollisionSphere validPos,
|
||||
Vector3 movement,
|
||||
Vector3 up,
|
||||
ref ResolvedPolygon? hitPoly,
|
||||
|
|
@ -736,7 +727,8 @@ public static class BSPQuery
|
|||
if (!resolved.TryGetValue(polyId, out var poly)) continue;
|
||||
|
||||
bool walkable = WalkableHitsSphere(poly, path, validPos, up);
|
||||
bool adjusted = walkable && AdjustSphereToPlane(poly, path, validPos, movement);
|
||||
bool adjusted = walkable
|
||||
&& AdjustSphereToPlane(poly, path, ref validPos, movement);
|
||||
|
||||
if (walkable && adjusted)
|
||||
{
|
||||
|
|
@ -755,22 +747,22 @@ public static class BSPQuery
|
|||
|
||||
if (dist >= reach)
|
||||
{
|
||||
FindWalkableInternal(node.PosNode, resolved, path, validPos, movement, up,
|
||||
FindWalkableInternal(node.PosNode, resolved, path, ref validPos, movement, up,
|
||||
ref hitPoly, ref hitPolyId, ref changed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dist <= -reach)
|
||||
{
|
||||
FindWalkableInternal(node.NegNode, resolved, path, validPos, movement, up,
|
||||
FindWalkableInternal(node.NegNode, resolved, path, ref validPos, movement, up,
|
||||
ref hitPoly, ref hitPolyId, ref changed);
|
||||
return;
|
||||
}
|
||||
|
||||
// Straddles.
|
||||
FindWalkableInternal(node.PosNode, resolved, path, validPos, movement, up,
|
||||
FindWalkableInternal(node.PosNode, resolved, path, ref validPos, movement, up,
|
||||
ref hitPoly, ref hitPolyId, ref changed);
|
||||
FindWalkableInternal(node.NegNode, resolved, path, validPos, movement, up,
|
||||
FindWalkableInternal(node.NegNode, resolved, path, ref validPos, movement, up,
|
||||
ref hitPoly, ref hitPolyId, ref changed);
|
||||
}
|
||||
|
||||
|
|
@ -1139,7 +1131,7 @@ public static class BSPQuery
|
|||
private static bool AdjustToPlane(
|
||||
PhysicsBSPNode root,
|
||||
Dictionary<ushort, ResolvedPolygon> resolved,
|
||||
CollisionSphere checkPos,
|
||||
ref CollisionSphere checkPos,
|
||||
Vector3 curPos,
|
||||
ResolvedPolygon hitPoly,
|
||||
Vector3 contactPoint)
|
||||
|
|
@ -1222,7 +1214,7 @@ public static class BSPQuery
|
|||
CollisionSphere checkPos,
|
||||
Vector3 up)
|
||||
{
|
||||
var validPos = new CollisionSphere(checkPos);
|
||||
var validPos = checkPos;
|
||||
return HitsWalkableInternal(root, resolved, path, validPos, up)
|
||||
? TransitionState.Collided
|
||||
: TransitionState.OK;
|
||||
|
|
@ -1262,12 +1254,12 @@ public static class BSPQuery
|
|||
float stepDownAmount = -(path.StepDownAmt * path.WalkInterp);
|
||||
var movement = up * stepDownAmount * (1f / scale);
|
||||
|
||||
var validPos = new CollisionSphere(checkPos);
|
||||
var validPos = checkPos;
|
||||
bool changed = false;
|
||||
ResolvedPolygon? polyHit = null;
|
||||
ushort _polyId = 0; // step-down doesn't need the id, but the signature requires it
|
||||
|
||||
FindWalkableInternal(root, resolved, path, validPos, movement, up,
|
||||
FindWalkableInternal(root, resolved, path, ref validPos, movement, up,
|
||||
ref polyHit, ref _polyId, ref changed);
|
||||
|
||||
if (changed && polyHit is not null)
|
||||
|
|
@ -1278,11 +1270,21 @@ public static class BSPQuery
|
|||
path.AddOffsetToCheckPos(offset);
|
||||
|
||||
var worldNormal = TransformNormal(polyHit.Plane.Normal, localToWorld);
|
||||
var worldVertices = TransformVertices(polyHit.Vertices, localToWorld, scale, worldOrigin);
|
||||
var worldPlane = BuildWorldPlane(worldNormal, worldVertices);
|
||||
var worldPlane = BuildWorldPlane(
|
||||
worldNormal,
|
||||
polyHit.Vertices,
|
||||
localToWorld,
|
||||
scale,
|
||||
worldOrigin);
|
||||
collisions.SetContactPlane(worldPlane, path.CheckCellId, false);
|
||||
|
||||
path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ);
|
||||
path.SetWalkableTransformed(
|
||||
worldPlane,
|
||||
polyHit.Vertices,
|
||||
localToWorld,
|
||||
scale,
|
||||
worldOrigin,
|
||||
Vector3.UnitZ);
|
||||
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||
PhysicsDiagnostics.LastBspHitPoly = polyHit;
|
||||
|
|
@ -1351,20 +1353,62 @@ public static class BSPQuery
|
|||
out ResolvedPolygon? hitPoly,
|
||||
out ushort hitPolyId,
|
||||
out Vector3 adjustedCenter)
|
||||
=> FindWalkableSphereCore(
|
||||
root,
|
||||
resolved,
|
||||
transition,
|
||||
new CollisionSphere(sphere.Origin, sphere.Radius),
|
||||
probeDistance,
|
||||
up,
|
||||
out hitPoly,
|
||||
out hitPolyId,
|
||||
out adjustedCenter);
|
||||
|
||||
internal static bool FindWalkableSphere(
|
||||
PhysicsBSPNode? root,
|
||||
Dictionary<ushort, ResolvedPolygon> resolved,
|
||||
Transition transition,
|
||||
Vector3 sphereCenter,
|
||||
float sphereRadius,
|
||||
float probeDistance,
|
||||
Vector3 up,
|
||||
out ResolvedPolygon? hitPoly,
|
||||
out ushort hitPolyId,
|
||||
out Vector3 adjustedCenter)
|
||||
=> FindWalkableSphereCore(
|
||||
root,
|
||||
resolved,
|
||||
transition,
|
||||
new CollisionSphere(sphereCenter, sphereRadius),
|
||||
probeDistance,
|
||||
up,
|
||||
out hitPoly,
|
||||
out hitPolyId,
|
||||
out adjustedCenter);
|
||||
|
||||
private static bool FindWalkableSphereCore(
|
||||
PhysicsBSPNode? root,
|
||||
Dictionary<ushort, ResolvedPolygon> resolved,
|
||||
Transition transition,
|
||||
CollisionSphere validPos,
|
||||
float probeDistance,
|
||||
Vector3 up,
|
||||
out ResolvedPolygon? hitPoly,
|
||||
out ushort hitPolyId,
|
||||
out Vector3 adjustedCenter)
|
||||
{
|
||||
adjustedCenter = sphere.Origin;
|
||||
adjustedCenter = validPos.Center;
|
||||
hitPoly = null;
|
||||
hitPolyId = 0;
|
||||
|
||||
if (root is null) return false;
|
||||
|
||||
var validPos = new CollisionSphere(sphere.Origin, sphere.Radius);
|
||||
var movement = -up * probeDistance;
|
||||
bool changed = false;
|
||||
ushort polyId = 0;
|
||||
ResolvedPolygon? poly = null;
|
||||
|
||||
FindWalkableInternal(root, resolved, transition.SpherePath, validPos,
|
||||
FindWalkableInternal(root, resolved, transition.SpherePath, ref validPos,
|
||||
movement, up, ref poly, ref polyId, ref changed);
|
||||
|
||||
if (changed && poly is not null)
|
||||
|
|
@ -1496,7 +1540,7 @@ public static class BSPQuery
|
|||
// ACE: path.LocalSpacePos.LocalToGlobalVec(hitPoly.Plane.Normal)
|
||||
var collisionNormal = Vector3.Transform(hitPoly.Plane.Normal, localToWorld);
|
||||
|
||||
if (!obj.State.HasFlag(ObjectInfoState.PerfectClip))
|
||||
if ((obj.State & ObjectInfoState.PerfectClip) == 0)
|
||||
{
|
||||
collisions.SetCollisionNormal(collisionNormal);
|
||||
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
||||
|
|
@ -1505,9 +1549,9 @@ public static class BSPQuery
|
|||
return TransitionState.Collided;
|
||||
}
|
||||
|
||||
var validPos = new CollisionSphere(checkPos);
|
||||
var validPos = checkPos;
|
||||
|
||||
if (!AdjustToPlane(root, resolved, validPos, curPos, hitPoly, contactPoint))
|
||||
if (!AdjustToPlane(root, resolved, ref validPos, curPos, hitPoly, contactPoint))
|
||||
{
|
||||
// L.2d slice 1 (2026-05-13): record the would-have-hit poly before
|
||||
// the early-out — collisions.SetCollisionNormal isn't called on
|
||||
|
|
@ -1595,8 +1639,9 @@ public static class BSPQuery
|
|||
|
||||
float rad = s0.Radius;
|
||||
|
||||
CollisionSphere? s1 = null;
|
||||
if (path.NumSphere > 1)
|
||||
bool hasS1 = path.NumSphere > 1;
|
||||
CollisionSphere s1 = default;
|
||||
if (hasS1)
|
||||
s1 = new CollisionSphere(
|
||||
path.LocalSphere[1].Origin,
|
||||
path.LocalSphere[1].Radius);
|
||||
|
|
@ -1614,13 +1659,20 @@ public static class BSPQuery
|
|||
{
|
||||
if (hitPoly is not null)
|
||||
{
|
||||
AdjustToPlacementPoly(hitPoly, s0, s1, rad, centerSolid, clearCell);
|
||||
AdjustToPlacementPoly(
|
||||
hitPoly,
|
||||
ref s0,
|
||||
ref s1,
|
||||
hasS1,
|
||||
rad,
|
||||
centerSolid,
|
||||
clearCell);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (path.NumSphere >= 2 && s1 is not null)
|
||||
if (hasS1)
|
||||
{
|
||||
centerSolid = false;
|
||||
hitPoly = null;
|
||||
|
|
@ -1630,7 +1682,14 @@ public static class BSPQuery
|
|||
{
|
||||
if (hitPoly is not null)
|
||||
{
|
||||
AdjustToPlacementPoly(hitPoly, s1, s0, rad, centerSolid, clearCell);
|
||||
AdjustToPlacementPoly(
|
||||
hitPoly,
|
||||
ref s1,
|
||||
ref s0,
|
||||
true,
|
||||
rad,
|
||||
centerSolid,
|
||||
clearCell);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
|
@ -1714,6 +1773,76 @@ public static class BSPQuery
|
|||
Quaternion localToWorld = default,
|
||||
PhysicsEngine? engine = null,
|
||||
Vector3 worldOrigin = default)
|
||||
{
|
||||
var sphere0 = new CollisionSphere(localSphere.Origin, localSphere.Radius);
|
||||
bool hasSphere1 = localSphere1 is not null;
|
||||
CollisionSphere sphere1 = hasSphere1
|
||||
? new CollisionSphere(localSphere1!.Origin, localSphere1.Radius)
|
||||
: default;
|
||||
|
||||
return FindCollisionsCore(
|
||||
root,
|
||||
resolved,
|
||||
transition,
|
||||
sphere0,
|
||||
hasSphere1,
|
||||
sphere1,
|
||||
localCurrCenter,
|
||||
localSpaceZ,
|
||||
scale,
|
||||
localToWorld,
|
||||
engine,
|
||||
worldOrigin);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allocation-free production entry point. Input spheres are copied into
|
||||
/// value scratch before entering the unchanged six-path dispatcher.
|
||||
/// </summary>
|
||||
internal static TransitionState FindCollisions(
|
||||
PhysicsBSPNode? root,
|
||||
Dictionary<ushort, ResolvedPolygon> resolved,
|
||||
Transition transition,
|
||||
Vector3 localSphereCenter,
|
||||
float localSphereRadius,
|
||||
bool hasLocalSphere1,
|
||||
Vector3 localSphere1Center,
|
||||
float localSphere1Radius,
|
||||
Vector3 localCurrCenter,
|
||||
Vector3 localSpaceZ,
|
||||
float scale,
|
||||
Quaternion localToWorld = default,
|
||||
PhysicsEngine? engine = null,
|
||||
Vector3 worldOrigin = default)
|
||||
=> FindCollisionsCore(
|
||||
root,
|
||||
resolved,
|
||||
transition,
|
||||
new CollisionSphere(localSphereCenter, localSphereRadius),
|
||||
hasLocalSphere1,
|
||||
hasLocalSphere1
|
||||
? new CollisionSphere(localSphere1Center, localSphere1Radius)
|
||||
: default,
|
||||
localCurrCenter,
|
||||
localSpaceZ,
|
||||
scale,
|
||||
localToWorld,
|
||||
engine,
|
||||
worldOrigin);
|
||||
|
||||
private static TransitionState FindCollisionsCore(
|
||||
PhysicsBSPNode? root,
|
||||
Dictionary<ushort, ResolvedPolygon> resolved,
|
||||
Transition transition,
|
||||
CollisionSphere sphere0,
|
||||
bool hasSphere1,
|
||||
CollisionSphere sphere1,
|
||||
Vector3 localCurrCenter,
|
||||
Vector3 localSpaceZ,
|
||||
float scale,
|
||||
Quaternion localToWorld,
|
||||
PhysicsEngine? engine,
|
||||
Vector3 worldOrigin)
|
||||
{
|
||||
if (root is null) return TransitionState.OK;
|
||||
// Default quaternion (0,0,0,0) → treat as identity
|
||||
|
|
@ -1723,11 +1852,6 @@ public static class BSPQuery
|
|||
var collisions = transition.CollisionInfo;
|
||||
var obj = transition.ObjectInfo;
|
||||
|
||||
var sphere0 = new CollisionSphere(localSphere.Origin, localSphere.Radius);
|
||||
CollisionSphere? sphere1 = localSphere1 is not null
|
||||
? new CollisionSphere(localSphere1.Origin, localSphere1.Radius)
|
||||
: null;
|
||||
|
||||
var movement = sphere0.Center - localCurrCenter;
|
||||
|
||||
// A6.P1: snapshot dispatcher entry for the [push-back-disp] probe.
|
||||
|
|
@ -1801,7 +1925,7 @@ public static class BSPQuery
|
|||
PhysicsDiagnostics.LastPlacementFailSolidLeaf = false;
|
||||
}
|
||||
|
||||
if (sphere1 is not null &&
|
||||
if (hasSphere1 &&
|
||||
SphereIntersectsSolidInternal(root, resolved, sphere1, clearCell))
|
||||
{
|
||||
if (PhysicsDiagnostics.ProbePlacementFailEnabled)
|
||||
|
|
@ -1836,12 +1960,12 @@ public static class BSPQuery
|
|||
// ----------------------------------------------------------------
|
||||
if (path.Collide)
|
||||
{
|
||||
var validPos = new CollisionSphere(sphere0);
|
||||
var validPos = sphere0;
|
||||
ResolvedPolygon? hitPoly = null;
|
||||
ushort _hitPolyId = 0; // Path 4 doesn't need the id
|
||||
bool changed = false;
|
||||
|
||||
FindWalkableInternal(root, resolved, path, validPos, movement, localSpaceZ,
|
||||
FindWalkableInternal(root, resolved, path, ref validPos, movement, localSpaceZ,
|
||||
ref hitPoly, ref _hitPolyId, ref changed);
|
||||
|
||||
if (changed && hitPoly is not null)
|
||||
|
|
@ -1873,10 +1997,20 @@ public static class BSPQuery
|
|||
path.AddOffsetToCheckPos(worldOffset);
|
||||
|
||||
var worldNormal = TransformNormal(hitPoly.Plane.Normal, localToWorld);
|
||||
var worldVertices = TransformVertices(hitPoly.Vertices, localToWorld, scale, worldOrigin);
|
||||
var worldPlane = BuildWorldPlane(worldNormal, worldVertices);
|
||||
var worldPlane = BuildWorldPlane(
|
||||
worldNormal,
|
||||
hitPoly.Vertices,
|
||||
localToWorld,
|
||||
scale,
|
||||
worldOrigin);
|
||||
collisions.SetContactPlane(worldPlane, path.CheckCellId, false);
|
||||
path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ);
|
||||
path.SetWalkableTransformed(
|
||||
worldPlane,
|
||||
hitPoly.Vertices,
|
||||
localToWorld,
|
||||
scale,
|
||||
worldOrigin,
|
||||
Vector3.UnitZ);
|
||||
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly;
|
||||
|
|
@ -1897,7 +2031,7 @@ public static class BSPQuery
|
|||
// ACE: BSPTree.find_collisions → step_sphere_up (BSPTree.cs, path 5 branch).
|
||||
// Named-retail: BSPTREE::find_collisions Contact branch → step_sphere_up.
|
||||
// ----------------------------------------------------------------
|
||||
if (obj.State.HasFlag(ObjectInfoState.Contact))
|
||||
if ((obj.State & ObjectInfoState.Contact) != 0)
|
||||
{
|
||||
ResolvedPolygon? hitPoly0 = null;
|
||||
Vector3 contact0 = Vector3.Zero;
|
||||
|
|
@ -1966,7 +2100,7 @@ public static class BSPQuery
|
|||
// assigns neg_step_up = arg2, so the HEAD near-miss (index 0) slides
|
||||
// and the FOOT near-miss (index 1) steps up. The head test wins when
|
||||
// both spheres near-miss (sphere1 is checked first).
|
||||
if (sphere1 is not null)
|
||||
if (hasSphere1)
|
||||
{
|
||||
ResolvedPolygon? hitPoly1 = null;
|
||||
Vector3 contact1 = Vector3.Zero;
|
||||
|
|
@ -2021,7 +2155,7 @@ public static class BSPQuery
|
|||
|
||||
if (hit0 || hitPoly0 is not null)
|
||||
{
|
||||
if (obj.State.HasFlag(ObjectInfoState.PathClipped))
|
||||
if ((obj.State & ObjectInfoState.PathClipped) != 0)
|
||||
{
|
||||
return CollideWithPt(root, resolved, transition,
|
||||
sphere0, localCurrCenter,
|
||||
|
|
@ -2090,7 +2224,7 @@ public static class BSPQuery
|
|||
return TransitionState.Adjusted;
|
||||
}
|
||||
|
||||
if (sphere1 is not null)
|
||||
if (hasSphere1)
|
||||
{
|
||||
ResolvedPolygon? hitPoly1 = null;
|
||||
Vector3 contact1 = Vector3.Zero;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,13 +1011,15 @@ 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();
|
||||
var transition = RentTransition();
|
||||
try
|
||||
{
|
||||
transition.ObjectInfo.StepUpHeight = stepUpHeight;
|
||||
transition.ObjectInfo.StepDownHeight = stepDownHeight;
|
||||
transition.ObjectInfo.StepDown = true;
|
||||
|
|
@ -1017,7 +1044,7 @@ public sealed class PhysicsEngine
|
|||
|
||||
// CPhysicsObj::get_object_info 0x00511CC0: Missile contributes
|
||||
// PathClipped only. PerfectClip is deliberately not inferred.
|
||||
if (transition.ObjectInfo.MoverPhysicsState.HasFlag(PhysicsStateFlags.Missile))
|
||||
if ((transition.ObjectInfo.MoverPhysicsState & PhysicsStateFlags.Missile) != 0)
|
||||
transition.ObjectInfo.State |= ObjectInfoState.PathClipped;
|
||||
|
||||
// frames_stationary_fall gate input: retail reads the mover's GRAVITY state bit
|
||||
|
|
@ -1063,7 +1090,7 @@ public sealed class PhysicsEngine
|
|||
// a wall collision at the end of one transition must project the next
|
||||
// frame's movement along the wall instead of hard-stopping again.
|
||||
if (body is not null
|
||||
&& body.TransientState.HasFlag(TransientStateFlags.Sliding)
|
||||
&& (body.TransientState & TransientStateFlags.Sliding) != 0
|
||||
&& body.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
|
||||
{
|
||||
transition.CollisionInfo.SetSlidingNormal(body.SlidingNormal);
|
||||
|
|
@ -1113,9 +1140,9 @@ public sealed class PhysicsEngine
|
|||
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;
|
||||
(body.TransientState & TransientStateFlags.StationaryStuck) != 0 ? 3 :
|
||||
(body.TransientState & TransientStateFlags.StationaryStop) != 0 ? 2 :
|
||||
(body.TransientState & TransientStateFlags.StationaryFall) != 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
bool ok = transition.FindTransitionalPosition(this);
|
||||
|
|
@ -1174,7 +1201,7 @@ public sealed class PhysicsEngine
|
|||
{
|
||||
body.WalkablePolygonValid = true;
|
||||
body.WalkablePlane = sp.LastWalkablePlane;
|
||||
body.WalkableVertices = (Vector3[])sp.LastWalkableVertices.Clone();
|
||||
body.SetWalkableVerticesExact(sp.LastWalkableVertices);
|
||||
body.WalkableUp = sp.LastWalkableUp;
|
||||
}
|
||||
else if (!isOnGround && !ci.ContactPlaneValid && !ci.LastKnownContactPlaneValid)
|
||||
|
|
@ -1320,7 +1347,7 @@ public sealed class PhysicsEngine
|
|||
inContact,
|
||||
ci.ContactPlane.Normal);
|
||||
bool onGround = inContact
|
||||
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
|
||||
|| (transition.ObjectInfo.State & ObjectInfoState.OnWalkable) != 0;
|
||||
|
||||
resolveResult = new ResolveResult(
|
||||
sp.CheckPos,
|
||||
|
|
@ -1346,7 +1373,7 @@ public sealed class PhysicsEngine
|
|||
// 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)
|
||||
|| (transition.ObjectInfo.State & ObjectInfoState.OnWalkable) != 0
|
||||
|| isOnGround;
|
||||
|
||||
uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId;
|
||||
|
|
@ -1394,6 +1421,11 @@ public sealed class PhysicsEngine
|
|||
|
||||
return resolveResult;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReturnTransition(transition);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs retail's radius-aware placement-ring search after the host has
|
||||
|
|
@ -1413,7 +1445,9 @@ public sealed class PhysicsEngine
|
|||
ObjectInfoState moverFlags = ObjectInfoState.None,
|
||||
uint movingEntityId = 0)
|
||||
{
|
||||
var transition = new Transition();
|
||||
var transition = RentTransition();
|
||||
try
|
||||
{
|
||||
transition.ObjectInfo.StepUpHeight = stepUpHeight;
|
||||
transition.ObjectInfo.StepDownHeight = stepDownHeight;
|
||||
transition.ObjectInfo.StepDown = true;
|
||||
|
|
@ -1431,7 +1465,7 @@ public sealed class PhysicsEngine
|
|||
inContact,
|
||||
ci.ContactPlane.Normal);
|
||||
bool onGround = inContact
|
||||
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
|
||||
|| (transition.ObjectInfo.State & ObjectInfoState.OnWalkable) != 0;
|
||||
|
||||
return new ResolveResult(
|
||||
sp.CurPos,
|
||||
|
|
@ -1447,4 +1481,9 @@ public sealed class PhysicsEngine
|
|||
ContactPlaneCellId: ci.ContactPlaneCellId,
|
||||
ContactPlaneIsWater: ci.ContactPlaneIsWater);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReturnTransition(transition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -418,19 +439,19 @@ public sealed class TerrainSurface
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
68
src/AcDream.Core/Physics/TransitionScratchArena.cs
Normal file
68
src/AcDream.Core/Physics/TransitionScratchArena.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -89,13 +89,13 @@ public sealed class ObjectInfo
|
|||
public bool MoverHasGravity;
|
||||
|
||||
// Convenience flag checks
|
||||
public bool Contact => State.HasFlag(ObjectInfoState.Contact);
|
||||
public bool OnWalkable => State.HasFlag(ObjectInfoState.OnWalkable);
|
||||
public bool IsViewer => State.HasFlag(ObjectInfoState.IsViewer);
|
||||
public bool IsPlayer => State.HasFlag(ObjectInfoState.IsPlayer);
|
||||
public bool EdgeSlide => State.HasFlag(ObjectInfoState.EdgeSlide);
|
||||
public bool PathClipped => State.HasFlag(ObjectInfoState.PathClipped);
|
||||
public bool FreeRotate => State.HasFlag(ObjectInfoState.FreeRotate);
|
||||
public bool Contact => (State & ObjectInfoState.Contact) != 0;
|
||||
public bool OnWalkable => (State & ObjectInfoState.OnWalkable) != 0;
|
||||
public bool IsViewer => (State & ObjectInfoState.IsViewer) != 0;
|
||||
public bool IsPlayer => (State & ObjectInfoState.IsPlayer) != 0;
|
||||
public bool EdgeSlide => (State & ObjectInfoState.EdgeSlide) != 0;
|
||||
public bool PathClipped => (State & ObjectInfoState.PathClipped) != 0;
|
||||
public bool FreeRotate => (State & ObjectInfoState.FreeRotate) != 0;
|
||||
|
||||
/// <summary>
|
||||
/// Verbatim decision tree from retail <c>OBJECTINFO::missile_ignore</c>
|
||||
|
|
@ -107,20 +107,20 @@ public sealed class ObjectInfo
|
|||
EntityCollisionFlags targetFlags)
|
||||
{
|
||||
var targetState = (PhysicsStateFlags)targetPhysicsState;
|
||||
if (targetState.HasFlag(PhysicsStateFlags.Missile))
|
||||
if ((targetState & PhysicsStateFlags.Missile) != 0)
|
||||
return true;
|
||||
if (!MoverPhysicsState.HasFlag(PhysicsStateFlags.Missile))
|
||||
if ((MoverPhysicsState & PhysicsStateFlags.Missile) == 0)
|
||||
return false;
|
||||
if (targetEntityId == TargetId)
|
||||
return false;
|
||||
|
||||
bool hasWeenie = targetFlags.HasFlag(EntityCollisionFlags.HasWeenie);
|
||||
if (targetState.HasFlag(PhysicsStateFlags.Ethereal) && hasWeenie)
|
||||
bool hasWeenie = (targetFlags & EntityCollisionFlags.HasWeenie) != 0;
|
||||
if ((targetState & PhysicsStateFlags.Ethereal) != 0 && hasWeenie)
|
||||
return true;
|
||||
|
||||
return TargetId != 0
|
||||
&& hasWeenie
|
||||
&& targetFlags.HasFlag(EntityCollisionFlags.IsCreature);
|
||||
&& (targetFlags & EntityCollisionFlags.IsCreature) != 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -162,6 +162,26 @@ public sealed class ObjectInfo
|
|||
/// </para>
|
||||
/// </summary>
|
||||
public void StopVelocity() { VelocityKilled = true; }
|
||||
|
||||
/// <summary>
|
||||
/// Restores the logical state of this reusable transition record.
|
||||
/// Retail performs the equivalent reset in <c>OBJECTINFO::init</c> while
|
||||
/// retaining the surrounding <c>CTransition</c> storage.
|
||||
/// </summary>
|
||||
public void ResetForReuse()
|
||||
{
|
||||
State = ObjectInfoState.None;
|
||||
StepUpHeight = PhysicsGlobals.DefaultStepHeight;
|
||||
StepDownHeight = 0.04f;
|
||||
Ethereal = false;
|
||||
StepDown = true;
|
||||
Scale = 1.0f;
|
||||
MoverPhysicsState = PhysicsStateFlags.None;
|
||||
TargetId = 0;
|
||||
SelfEntityId = 0;
|
||||
MoverHasGravity = false;
|
||||
VelocityKilled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -290,7 +310,7 @@ public sealed class CollisionInfo
|
|||
public int FramesStationaryFall;
|
||||
|
||||
public Vector3 AdjustOffset;
|
||||
public List<uint> CollideObjectGuids = new();
|
||||
public readonly List<uint> CollideObjectGuids = new();
|
||||
public uint? LastCollidedObjectGuid;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -350,12 +370,84 @@ public sealed class CollisionInfo
|
|||
CollisionNormalValid = true;
|
||||
CollisionNormal = normal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>COLLISIONINFO::init</c> reset for a retained transition
|
||||
/// record. Backing fields are written directly so diagnostic setters do
|
||||
/// not report initialization as gameplay state mutation.
|
||||
/// </summary>
|
||||
public void ResetForReuse()
|
||||
{
|
||||
_contactPlaneValid = false;
|
||||
_contactPlane = default;
|
||||
_contactPlaneCellId = 0;
|
||||
_contactPlaneIsWater = false;
|
||||
_lastKnownContactPlaneValid = false;
|
||||
_lastKnownContactPlane = default;
|
||||
_lastKnownContactPlaneCellId = 0;
|
||||
_lastKnownContactPlaneIsWater = false;
|
||||
|
||||
SlidingNormalValid = false;
|
||||
SlidingNormal = Vector3.Zero;
|
||||
CollisionNormalValid = false;
|
||||
CollisionNormal = Vector3.Zero;
|
||||
CollidedWithEnvironment = false;
|
||||
FramesStationaryFall = 0;
|
||||
AdjustOffset = Vector3.Zero;
|
||||
CollideObjectGuids.Clear();
|
||||
LastCollidedObjectGuid = null;
|
||||
ContactPlaneWriteCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Movement path descriptor — tracks sphere positions in multiple
|
||||
/// coordinate frames during collision resolution.
|
||||
/// ACE: SpherePath.
|
||||
/// Retained, stack-disciplined ordering buffers for reentrant neighboring-cell
|
||||
/// checks. A cell collision can enter another cell check before the outer scan
|
||||
/// resumes, so each active depth needs an independent list.
|
||||
/// </summary>
|
||||
internal sealed class CellOrderScratchArena
|
||||
{
|
||||
private readonly List<List<uint>> _records = new();
|
||||
private int _depth;
|
||||
|
||||
internal int ActiveDepth => _depth;
|
||||
internal int RetainedRecordCount => _records.Count;
|
||||
internal IReadOnlyList<List<uint>> RetainedRecords => _records;
|
||||
|
||||
internal List<uint> Rent()
|
||||
{
|
||||
if (_depth == _records.Count)
|
||||
_records.Add(new List<uint>());
|
||||
|
||||
List<uint> record = _records[_depth++];
|
||||
record.Clear();
|
||||
return record;
|
||||
}
|
||||
|
||||
internal void Return(List<uint> record)
|
||||
{
|
||||
int index = _depth - 1;
|
||||
if (index < 0 || !ReferenceEquals(_records[index], record))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Cell-order scratch must be returned in LIFO order.");
|
||||
}
|
||||
|
||||
record.Clear();
|
||||
_depth = index;
|
||||
}
|
||||
|
||||
internal void ResetForReuse()
|
||||
{
|
||||
foreach (List<uint> record in _records)
|
||||
record.Clear();
|
||||
_depth = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Movement path descriptor; tracks sphere positions in multiple coordinate
|
||||
/// frames during collision resolution. ACE: SpherePath.
|
||||
/// </summary>
|
||||
public sealed class SpherePath
|
||||
{
|
||||
|
|
@ -474,6 +566,18 @@ public sealed class SpherePath
|
|||
/// </summary>
|
||||
public bool HitsInteriorCell;
|
||||
|
||||
// The logical walkable references above are cleared by ResetForReuse.
|
||||
// These private exact-length buffers retain only storage identity, matching
|
||||
// retail's embedded transition scratch without exposing stale vertices to
|
||||
// polygon math.
|
||||
private Vector3[]? _walkableVertexStorage;
|
||||
private Vector3[]? _lastWalkableVertexStorage;
|
||||
internal readonly CellArray CellCandidates = new();
|
||||
internal readonly CellOrderScratchArena OrderedCellScratch = new();
|
||||
|
||||
internal Vector3[]? RetainedWalkableVertexStorage => _walkableVertexStorage;
|
||||
internal Vector3[]? RetainedLastWalkableVertexStorage => _lastWalkableVertexStorage;
|
||||
|
||||
public void SetCheckPos(Vector3 pos, uint cellId)
|
||||
{
|
||||
CheckPos = pos;
|
||||
|
|
@ -522,15 +626,75 @@ public sealed class SpherePath
|
|||
|
||||
public void SetWalkable(Plane plane, Vector3[] vertices, Vector3 up)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(vertices);
|
||||
|
||||
WalkableValid = true;
|
||||
WalkablePlane = plane;
|
||||
WalkableVertices = (Vector3[])vertices.Clone();
|
||||
WalkableVertices = CopyExact(vertices, ref _walkableVertexStorage);
|
||||
WalkableUp = up;
|
||||
WalkableAllowance = PhysicsGlobals.FloorZ;
|
||||
|
||||
LastWalkableValid = true;
|
||||
LastWalkablePlane = plane;
|
||||
LastWalkableVertices = (Vector3[])vertices.Clone();
|
||||
LastWalkableVertices = CopyExact(vertices, ref _lastWalkableVertexStorage);
|
||||
LastWalkableUp = up;
|
||||
}
|
||||
|
||||
internal void SetWalkable(
|
||||
Plane plane,
|
||||
in TerrainTriangleVertices vertices,
|
||||
Vector3 up)
|
||||
{
|
||||
Vector3[] walkable = RentExact(3, ref _walkableVertexStorage);
|
||||
Vector3[] last = RentExact(3, ref _lastWalkableVertexStorage);
|
||||
walkable[0] = last[0] = vertices.V0;
|
||||
walkable[1] = last[1] = vertices.V1;
|
||||
walkable[2] = last[2] = vertices.V2;
|
||||
|
||||
WalkableValid = true;
|
||||
WalkablePlane = plane;
|
||||
WalkableVertices = walkable;
|
||||
WalkableUp = up;
|
||||
WalkableAllowance = PhysicsGlobals.FloorZ;
|
||||
LastWalkableValid = true;
|
||||
LastWalkablePlane = plane;
|
||||
LastWalkableVertices = last;
|
||||
LastWalkableUp = up;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies local polygon vertices directly into the retained exact-length
|
||||
/// world-space buffers. This removes the former temporary transformed
|
||||
/// array while preserving the same multiplication and addition order.
|
||||
/// </summary>
|
||||
internal void SetWalkableTransformed(
|
||||
Plane plane,
|
||||
ReadOnlySpan<Vector3> localVertices,
|
||||
Quaternion localToWorld,
|
||||
float scale,
|
||||
Vector3 worldOrigin,
|
||||
Vector3 up)
|
||||
{
|
||||
Vector3[] walkable = RentExact(localVertices.Length, ref _walkableVertexStorage);
|
||||
Vector3[] last = RentExact(localVertices.Length, ref _lastWalkableVertexStorage);
|
||||
|
||||
for (int i = 0; i < localVertices.Length; i++)
|
||||
{
|
||||
Vector3 transformed =
|
||||
Vector3.Transform(localVertices[i] * scale, localToWorld) + worldOrigin;
|
||||
walkable[i] = transformed;
|
||||
last[i] = transformed;
|
||||
}
|
||||
|
||||
WalkableValid = true;
|
||||
WalkablePlane = plane;
|
||||
WalkableVertices = walkable;
|
||||
WalkableUp = up;
|
||||
WalkableAllowance = PhysicsGlobals.FloorZ;
|
||||
|
||||
LastWalkableValid = true;
|
||||
LastWalkablePlane = plane;
|
||||
LastWalkableVertices = last;
|
||||
LastWalkableUp = up;
|
||||
}
|
||||
|
||||
|
|
@ -547,11 +711,95 @@ public sealed class SpherePath
|
|||
|
||||
WalkableValid = true;
|
||||
WalkablePlane = LastWalkablePlane;
|
||||
WalkableVertices = (Vector3[])LastWalkableVertices.Clone();
|
||||
WalkableVertices = CopyExact(LastWalkableVertices, ref _walkableVertexStorage);
|
||||
WalkableUp = LastWalkableUp;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>SPHEREPATH::init</c> reset for a retained transition record.
|
||||
/// Every logical value is restored; only the two private exact-length
|
||||
/// vertex buffers and the fixed sphere object identities survive.
|
||||
/// </summary>
|
||||
public void ResetForReuse()
|
||||
{
|
||||
NumSphere = 1;
|
||||
ResetSphereArray(LocalSphere);
|
||||
ResetSphereArray(GlobalSphere);
|
||||
ResetSphereArray(GlobalCurrCenter);
|
||||
|
||||
BeginPos = Vector3.Zero;
|
||||
EndPos = Vector3.Zero;
|
||||
CurPos = Vector3.Zero;
|
||||
CheckPos = Vector3.Zero;
|
||||
BeginOrientation = Quaternion.Identity;
|
||||
EndOrientation = Quaternion.Identity;
|
||||
CurOrientation = Quaternion.Identity;
|
||||
CheckOrientation = Quaternion.Identity;
|
||||
CurCellId = 0;
|
||||
CheckCellId = 0;
|
||||
CarriedBlockOrigin = null;
|
||||
GlobalOffset = Vector3.Zero;
|
||||
StepUp = false;
|
||||
StepUpNormal = Vector3.Zero;
|
||||
Collide = false;
|
||||
StepDown = false;
|
||||
StepDownAmt = 0f;
|
||||
WalkInterp = 1.0f;
|
||||
WalkableValid = false;
|
||||
WalkablePlane = default;
|
||||
WalkableVertices = null;
|
||||
WalkableUp = Vector3.UnitZ;
|
||||
WalkableAllowance = PhysicsGlobals.FloorZ;
|
||||
LastWalkableValid = false;
|
||||
LastWalkablePlane = default;
|
||||
LastWalkableVertices = null;
|
||||
LastWalkableUp = Vector3.UnitZ;
|
||||
BackupCheckPos = Vector3.Zero;
|
||||
BackupCheckCellId = 0;
|
||||
NegPolyHit = false;
|
||||
NegStepUp = false;
|
||||
NegCollisionNormal = Vector3.Zero;
|
||||
CheckWalkable = false;
|
||||
InsertType = InsertType.Transition;
|
||||
PlacementAllowsSliding = true;
|
||||
ObstructionEthereal = false;
|
||||
BldgCheck = false;
|
||||
HitsInteriorCell = false;
|
||||
if (_walkableVertexStorage is not null)
|
||||
System.Array.Clear(_walkableVertexStorage);
|
||||
if (_lastWalkableVertexStorage is not null)
|
||||
System.Array.Clear(_lastWalkableVertexStorage);
|
||||
CellCandidates.Clear();
|
||||
OrderedCellScratch.ResetForReuse();
|
||||
}
|
||||
|
||||
private static Vector3[] CopyExact(
|
||||
ReadOnlySpan<Vector3> source,
|
||||
ref Vector3[]? storage)
|
||||
{
|
||||
Vector3[] destination = RentExact(source.Length, ref storage);
|
||||
source.CopyTo(destination);
|
||||
return destination;
|
||||
}
|
||||
|
||||
private static Vector3[] RentExact(int length, ref Vector3[]? storage)
|
||||
{
|
||||
if (storage is null || storage.Length != length)
|
||||
storage = new Vector3[length];
|
||||
|
||||
return storage;
|
||||
}
|
||||
|
||||
private static void ResetSphereArray(Sphere[] spheres)
|
||||
{
|
||||
for (int i = 0; i < spheres.Length; i++)
|
||||
{
|
||||
spheres[i].Origin = Vector3.Zero;
|
||||
spheres[i].Radius = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slide fallback when step-up fails. Clears the contact-plane state that
|
||||
/// caused the step-up attempt and runs the full sphere-slide computation
|
||||
|
|
@ -693,9 +941,22 @@ public static class PhysicsGlobals
|
|||
/// </summary>
|
||||
public sealed class Transition
|
||||
{
|
||||
public ObjectInfo ObjectInfo = new();
|
||||
public SpherePath SpherePath = new();
|
||||
public CollisionInfo CollisionInfo = new();
|
||||
public readonly ObjectInfo ObjectInfo = new();
|
||||
public readonly SpherePath SpherePath = new();
|
||||
public readonly CollisionInfo CollisionInfo = new();
|
||||
|
||||
/// <summary>
|
||||
/// Restores this retained record to the state of a freshly constructed
|
||||
/// transition. Retail <c>CTransition::makeTransition</c> calls
|
||||
/// <c>CTransition::init</c> on its stack-disciplined scratch record before
|
||||
/// each lease.
|
||||
/// </summary>
|
||||
public void ResetForReuse()
|
||||
{
|
||||
ObjectInfo.ResetForReuse();
|
||||
SpherePath.ResetForReuse();
|
||||
CollisionInfo.ResetForReuse();
|
||||
}
|
||||
|
||||
private static bool DumpEdgeSlideEnabled =>
|
||||
Environment.GetEnvironmentVariable("ACDREAM_DUMP_EDGE_SLIDE") == "1";
|
||||
|
|
@ -1087,7 +1348,7 @@ public sealed class Transition
|
|||
{
|
||||
// COLLISIONINFO::init at retail 0x0050B052. Placement probes are
|
||||
// independent; a failed compass sample must not bias the next one.
|
||||
CollisionInfo = new CollisionInfo();
|
||||
CollisionInfo.ResetForReuse();
|
||||
}
|
||||
|
||||
return transitionState;
|
||||
|
|
@ -1850,15 +2111,6 @@ public sealed class Transition
|
|||
|
||||
if (cellPhysics.BSP?.Root is null) return false;
|
||||
|
||||
// Build foot sphere in cell-local space. Caller passes localFootCenter
|
||||
// already transformed into cell-local space and the resolver's
|
||||
// foot-sphere radius.
|
||||
var localSphere = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
Origin = localFootCenter,
|
||||
Radius = sphereRadius,
|
||||
};
|
||||
|
||||
// Save/restore WalkableAllowance: CPolygon::walkable_hits_sphere reads
|
||||
// path.WalkableAllowance (acclient_2013_pseudo_c.txt:323010). For
|
||||
// "standing here, find my floor" we want the walkability slope
|
||||
|
|
@ -1880,7 +2132,8 @@ public sealed class Transition
|
|||
cellPhysics.BSP.Root,
|
||||
cellPhysics.Resolved,
|
||||
this,
|
||||
localSphere,
|
||||
localFootCenter,
|
||||
sphereRadius,
|
||||
INDOOR_WALKABLE_PROBE_DISTANCE,
|
||||
Vector3.UnitZ, // local Z is up for indoor cells (identity transform)
|
||||
out hitPoly,
|
||||
|
|
@ -1957,7 +2210,19 @@ public sealed class Transition
|
|||
|
||||
// Deterministic order for greppable probe logs. Skip the primary
|
||||
// cell — caller has already run its BSP.
|
||||
var ordered = new System.Collections.Generic.List<uint>(cellSet);
|
||||
List<uint> ordered = sp.OrderedCellScratch.Rent();
|
||||
try
|
||||
{
|
||||
if (cellSet is CellArray cellArray)
|
||||
{
|
||||
for (int i = 0; i < cellArray.Count; i++)
|
||||
ordered.Add(cellArray.OrderedIds[i]);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (uint id in cellSet)
|
||||
ordered.Add(id);
|
||||
}
|
||||
ordered.Sort();
|
||||
|
||||
foreach (uint cellId in ordered)
|
||||
|
|
@ -2043,20 +2308,13 @@ public sealed class Transition
|
|||
var localCenter = Vector3.Transform(footCenter, cell.InverseWorldTransform);
|
||||
var localCurrCenter = Vector3.Transform(sp.GlobalCurrCenter[0].Origin, cell.InverseWorldTransform);
|
||||
|
||||
var localSphere = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
Origin = localCenter,
|
||||
Radius = sphereRadius,
|
||||
};
|
||||
DatReaderWriter.Types.Sphere? localSphere1 = null;
|
||||
if (sp.NumSphere > 1)
|
||||
{
|
||||
localSphere1 = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
Origin = Vector3.Transform(sp.GlobalSphere[1].Origin, cell.InverseWorldTransform),
|
||||
Radius = sp.GlobalSphere[1].Radius,
|
||||
};
|
||||
}
|
||||
bool hasLocalSphere1 = sp.NumSphere > 1;
|
||||
Vector3 localSphere1Center = hasLocalSphere1
|
||||
? Vector3.Transform(sp.GlobalSphere[1].Origin, cell.InverseWorldTransform)
|
||||
: Vector3.Zero;
|
||||
float localSphere1Radius = hasLocalSphere1
|
||||
? sp.GlobalSphere[1].Radius
|
||||
: 0f;
|
||||
|
||||
System.Numerics.Quaternion cellRotation;
|
||||
Vector3 cellOrigin;
|
||||
|
|
@ -2074,7 +2332,12 @@ public sealed class Transition
|
|||
|
||||
var result = BSPQuery.FindCollisions(
|
||||
cell.BSP.Root, cell.Resolved, this,
|
||||
localSphere, localSphere1, localCurrCenter,
|
||||
localCenter,
|
||||
sphereRadius,
|
||||
hasLocalSphere1,
|
||||
localSphere1Center,
|
||||
localSphere1Radius,
|
||||
localCurrCenter,
|
||||
Vector3.UnitZ, 1.0f, cellRotation, engine,
|
||||
worldOrigin: cellOrigin);
|
||||
|
||||
|
|
@ -2123,6 +2386,11 @@ public sealed class Transition
|
|||
|
||||
return TransitionState.OK;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sp.OrderedCellScratch.Return(ordered);
|
||||
}
|
||||
}
|
||||
|
||||
private void LogIssue98CellSetSummary(
|
||||
PhysicsEngine engine,
|
||||
|
|
@ -2295,7 +2563,7 @@ public sealed class Transition
|
|||
{
|
||||
case TransitionState.Collided:
|
||||
case TransitionState.Adjusted:
|
||||
if (!ObjectInfo.State.HasFlag(ObjectInfoState.Contact))
|
||||
if ((ObjectInfo.State & ObjectInfoState.Contact) == 0)
|
||||
CollisionInfo.CollidedWithEnvironment = true;
|
||||
return true;
|
||||
case TransitionState.Slid:
|
||||
|
|
@ -2358,23 +2626,16 @@ public sealed class Transition
|
|||
var localCenter = Vector3.Transform(footCenter, cellPhysics.InverseWorldTransform);
|
||||
var localCurrCenter = Vector3.Transform(sp.GlobalCurrCenter[0].Origin, cellPhysics.InverseWorldTransform);
|
||||
|
||||
var localSphere = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
Origin = localCenter,
|
||||
Radius = sphereRadius,
|
||||
};
|
||||
|
||||
// Second sphere (head) in local space, if present.
|
||||
DatReaderWriter.Types.Sphere? localSphere1 = null;
|
||||
if (sp.NumSphere > 1)
|
||||
{
|
||||
var headCenter = sp.GlobalSphere[1].Origin;
|
||||
localSphere1 = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
Origin = Vector3.Transform(headCenter, cellPhysics.InverseWorldTransform),
|
||||
Radius = sp.GlobalSphere[1].Radius,
|
||||
};
|
||||
}
|
||||
bool hasLocalSphere1 = sp.NumSphere > 1;
|
||||
Vector3 localSphere1Center = hasLocalSphere1
|
||||
? Vector3.Transform(
|
||||
sp.GlobalSphere[1].Origin,
|
||||
cellPhysics.InverseWorldTransform)
|
||||
: Vector3.Zero;
|
||||
float localSphere1Radius = hasLocalSphere1
|
||||
? sp.GlobalSphere[1].Radius
|
||||
: 0f;
|
||||
|
||||
// Indoor walking Phase 1 (2026-05-19): clear the LastBspHitPoly
|
||||
// side-channel before the call so a missed write (no collision)
|
||||
|
|
@ -2420,8 +2681,11 @@ public sealed class Transition
|
|||
cellPhysics.BSP.Root,
|
||||
cellPhysics.Resolved,
|
||||
this,
|
||||
localSphere,
|
||||
localSphere1,
|
||||
localCenter,
|
||||
sphereRadius,
|
||||
hasLocalSphere1,
|
||||
localSphere1Center,
|
||||
localSphere1Radius,
|
||||
localCurrCenter,
|
||||
Vector3.UnitZ, // local space Z is up
|
||||
1.0f, // scale = 1.0 for cell geometry
|
||||
|
|
@ -2443,7 +2707,7 @@ public sealed class Transition
|
|||
|
||||
if (cellState != TransitionState.OK)
|
||||
{
|
||||
if (!ObjectInfo.State.HasFlag(ObjectInfoState.Contact))
|
||||
if ((ObjectInfo.State & ObjectInfoState.Contact) == 0)
|
||||
ci.CollidedWithEnvironment = true;
|
||||
return cellState;
|
||||
}
|
||||
|
|
@ -2553,8 +2817,13 @@ public sealed class Transition
|
|||
sp.HitsInteriorCell = false;
|
||||
|
||||
uint containingCellId = CellTransit.FindCellSet(
|
||||
engine.DataCache, sp.GlobalSphere, sp.NumSphere, sp.CheckCellId, out var cellSet,
|
||||
engine.DataCache,
|
||||
sp.GlobalSphere,
|
||||
sp.NumSphere,
|
||||
sp.CheckCellId,
|
||||
sp.CellCandidates,
|
||||
sp.CarriedBlockOrigin);
|
||||
CellArray cellSet = sp.CellCandidates;
|
||||
LogIssue98CellSetSummary(engine, containingCellId, cellSet, footCenter, sphereRadius);
|
||||
|
||||
if ((sp.CheckCellId & 0xFFFFu) >= 0x0100u
|
||||
|
|
@ -2564,8 +2833,9 @@ public sealed class Transition
|
|||
}
|
||||
else
|
||||
{
|
||||
foreach (uint id in cellSet)
|
||||
for (int i = 0; i < cellSet.Count; i++)
|
||||
{
|
||||
uint id = cellSet.OrderedIds[i];
|
||||
if ((id & 0xFFFFu) >= 0x0100u) { sp.HitsInteriorCell = true; break; }
|
||||
}
|
||||
}
|
||||
|
|
@ -2610,18 +2880,12 @@ public sealed class Transition
|
|||
private TransitionState ValidateWalkable(Vector3 sphereCenter, float sphereRadius,
|
||||
System.Numerics.Plane contactPlane,
|
||||
bool isWater, float waterDepth, uint cellId,
|
||||
Vector3[]? walkableVertices = null)
|
||||
TerrainTriangleVertices? walkableVertices = null)
|
||||
{
|
||||
var sp = SpherePath;
|
||||
var ci = CollisionInfo;
|
||||
var oi = ObjectInfo;
|
||||
|
||||
void CacheWalkableContext()
|
||||
{
|
||||
if (walkableVertices is not null && contactPlane.Normal.Z >= PhysicsGlobals.FloorZ)
|
||||
sp.SetWalkable(contactPlane, walkableVertices, Vector3.UnitZ);
|
||||
}
|
||||
|
||||
// Low point of the sphere.
|
||||
var lowPoint = sphereCenter - new Vector3(0f, 0f, sphereRadius);
|
||||
|
||||
|
|
@ -2646,7 +2910,7 @@ public sealed class Transition
|
|||
if (sp.StepDown || !oi.OnWalkable || walkableNormal)
|
||||
{
|
||||
ci.SetContactPlane(contactPlane, cellId, isWater);
|
||||
CacheWalkableContext();
|
||||
CacheWalkableContext(sp, contactPlane, walkableVertices);
|
||||
}
|
||||
|
||||
if (!oi.Contact && !sp.StepDown)
|
||||
|
|
@ -2669,7 +2933,7 @@ public sealed class Transition
|
|||
if (sp.StepDown || !oi.OnWalkable || walkable)
|
||||
{
|
||||
ci.SetContactPlane(contactPlane, cellId, isWater);
|
||||
CacheWalkableContext();
|
||||
CacheWalkableContext(sp, contactPlane, walkableVertices);
|
||||
|
||||
if (sp.StepDown)
|
||||
{
|
||||
|
|
@ -2693,6 +2957,18 @@ public sealed class Transition
|
|||
return TransitionState.Adjusted;
|
||||
}
|
||||
|
||||
private static void CacheWalkableContext(
|
||||
SpherePath spherePath,
|
||||
Plane contactPlane,
|
||||
TerrainTriangleVertices? walkableVertices)
|
||||
{
|
||||
if (walkableVertices is { } vertices
|
||||
&& contactPlane.Normal.Z >= PhysicsGlobals.FloorZ)
|
||||
{
|
||||
spherePath.SetWalkable(contactPlane, in vertices, Vector3.UnitZ);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Object collision — static BSP objects
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -2880,23 +3156,22 @@ public sealed class Transition
|
|||
var invRot = Quaternion.Inverse(obj.Rotation);
|
||||
float invScale = obj.Scale > 0 ? 1.0f / obj.Scale : 1.0f;
|
||||
|
||||
var localSphere0 = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
Origin = Vector3.Transform(sp.GlobalSphere[0].Origin - obj.Position, invRot) * invScale,
|
||||
Radius = sp.GlobalSphere[0].Radius * invScale,
|
||||
};
|
||||
Vector3 localSphere0Center =
|
||||
Vector3.Transform(sp.GlobalSphere[0].Origin - obj.Position, invRot)
|
||||
* invScale;
|
||||
float localSphere0Radius = sp.GlobalSphere[0].Radius * invScale;
|
||||
var localCurrCenter = Vector3.Transform(
|
||||
sp.GlobalCurrCenter[0].Origin - obj.Position, invRot) * invScale;
|
||||
|
||||
DatReaderWriter.Types.Sphere? localSphere1 = null;
|
||||
if (sp.NumSphere > 1)
|
||||
{
|
||||
localSphere1 = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
Origin = Vector3.Transform(sp.GlobalSphere[1].Origin - obj.Position, invRot) * invScale,
|
||||
Radius = sp.GlobalSphere[1].Radius * invScale,
|
||||
};
|
||||
}
|
||||
bool hasLocalSphere1 = sp.NumSphere > 1;
|
||||
Vector3 localSphere1Center = hasLocalSphere1
|
||||
? Vector3.Transform(
|
||||
sp.GlobalSphere[1].Origin - obj.Position,
|
||||
invRot) * invScale
|
||||
: Vector3.Zero;
|
||||
float localSphere1Radius = hasLocalSphere1
|
||||
? sp.GlobalSphere[1].Radius * invScale
|
||||
: 0f;
|
||||
|
||||
// Local-space Z (up direction rotated into object space).
|
||||
var localSpaceZ = Vector3.Transform(Vector3.UnitZ, invRot);
|
||||
|
|
@ -2908,8 +3183,11 @@ public sealed class Transition
|
|||
physics.BSP.Root,
|
||||
physics.Resolved,
|
||||
this,
|
||||
localSphere0,
|
||||
localSphere1,
|
||||
localSphere0Center,
|
||||
localSphere0Radius,
|
||||
hasLocalSphere1,
|
||||
localSphere1Center,
|
||||
localSphere1Radius,
|
||||
localCurrCenter,
|
||||
localSpaceZ,
|
||||
obj.Scale, // scale for local→world offsets
|
||||
|
|
@ -3212,20 +3490,16 @@ public sealed class Transition
|
|||
}
|
||||
|
||||
var invRot = Quaternion.Inverse(bldRotation);
|
||||
var localSphere0 = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
Origin = Vector3.Transform(sp.GlobalSphere[0].Origin - bldOrigin, invRot),
|
||||
Radius = sp.GlobalSphere[0].Radius,
|
||||
};
|
||||
DatReaderWriter.Types.Sphere? localSphere1 = null;
|
||||
if (sp.NumSphere > 1)
|
||||
{
|
||||
localSphere1 = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
Origin = Vector3.Transform(sp.GlobalSphere[1].Origin - bldOrigin, invRot),
|
||||
Radius = sp.GlobalSphere[1].Radius,
|
||||
};
|
||||
}
|
||||
Vector3 localSphere0Center =
|
||||
Vector3.Transform(sp.GlobalSphere[0].Origin - bldOrigin, invRot);
|
||||
float localSphere0Radius = sp.GlobalSphere[0].Radius;
|
||||
bool hasLocalSphere1 = sp.NumSphere > 1;
|
||||
Vector3 localSphere1Center = hasLocalSphere1
|
||||
? Vector3.Transform(sp.GlobalSphere[1].Origin - bldOrigin, invRot)
|
||||
: Vector3.Zero;
|
||||
float localSphere1Radius = hasLocalSphere1
|
||||
? sp.GlobalSphere[1].Radius
|
||||
: 0f;
|
||||
var localCurrCenter = Vector3.Transform(
|
||||
sp.GlobalCurrCenter[0].Origin - bldOrigin, invRot);
|
||||
var localSpaceZ = Vector3.Transform(Vector3.UnitZ, invRot);
|
||||
|
|
@ -3239,8 +3513,11 @@ public sealed class Transition
|
|||
physics.BSP.Root,
|
||||
physics.Resolved,
|
||||
this,
|
||||
localSphere0,
|
||||
localSphere1,
|
||||
localSphere0Center,
|
||||
localSphere0Radius,
|
||||
hasLocalSphere1,
|
||||
localSphere1Center,
|
||||
localSphere1Radius,
|
||||
localCurrCenter,
|
||||
localSpaceZ,
|
||||
1.0f, // buildings are unscaled
|
||||
|
|
@ -3513,7 +3790,7 @@ public sealed class Transition
|
|||
Vector3 gCenter = sp.GlobalCurrCenter[sphereNum].Origin;
|
||||
Vector3 globalOffset = gCenter - obj.Position;
|
||||
|
||||
if (!ObjectInfo.State.HasFlag(ObjectInfoState.PerfectClip))
|
||||
if ((ObjectInfo.State & ObjectInfoState.PerfectClip) == 0)
|
||||
{
|
||||
if (!NormalizeCheckSmall(ref globalOffset))
|
||||
CollisionInfo.SetCollisionNormal(globalOffset);
|
||||
|
|
@ -3946,7 +4223,7 @@ public sealed class Transition
|
|||
if (NormalizeCheckSmall(ref n))
|
||||
return TransitionState.Collided;
|
||||
|
||||
if (!ObjectInfo.State.HasFlag(ObjectInfoState.PerfectClip))
|
||||
if ((ObjectInfo.State & ObjectInfoState.PerfectClip) == 0)
|
||||
{
|
||||
CollisionInfo.SetCollisionNormal(n);
|
||||
return TransitionState.Collided;
|
||||
|
|
@ -4564,7 +4841,7 @@ public sealed class Transition
|
|||
Console.WriteLine(
|
||||
$"stepup: enter normal=({collisionNormal.X:F3},{collisionNormal.Y:F3},{collisionNormal.Z:F3}) " +
|
||||
$"|Z|={collisionNormal.Z:F3} vs FloorZ={floor:F3} → {verdict}, " +
|
||||
$"OnWalkable={oi.State.HasFlag(ObjectInfoState.OnWalkable)}, " +
|
||||
$"OnWalkable={(oi.State & ObjectInfoState.OnWalkable) != 0}, " +
|
||||
$"StepUpHeight={oi.StepUpHeight:F3}, " +
|
||||
$"CurPos=({sp.CurPos.X:F2},{sp.CurPos.Y:F2},{sp.CurPos.Z:F2})");
|
||||
}
|
||||
|
|
@ -4592,7 +4869,7 @@ public sealed class Transition
|
|||
float stepDownHeight = 0.04f;
|
||||
float zLandingValue = PhysicsGlobals.LandingZ;
|
||||
|
||||
if (oi.State.HasFlag(ObjectInfoState.OnWalkable))
|
||||
if ((oi.State & ObjectInfoState.OnWalkable) != 0)
|
||||
{
|
||||
zLandingValue = oi.GetWalkableZ();
|
||||
stepDownHeight = oi.StepUpHeight;
|
||||
|
|
@ -4673,7 +4950,7 @@ public sealed class Transition
|
|||
var sp = SpherePath;
|
||||
var oi = ObjectInfo;
|
||||
|
||||
if (!oi.State.HasFlag(ObjectInfoState.OnWalkable))
|
||||
if ((oi.State & ObjectInfoState.OnWalkable) == 0)
|
||||
return true;
|
||||
|
||||
// If the current walkable entry is still valid, skip the probe.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
417
tests/AcDream.Core.Tests/Physics/TransitionScratchResetTests.cs
Normal file
417
tests/AcDream.Core.Tests/Physics/TransitionScratchResetTests.cs
Normal 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));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue