docs(physics): pin Slice I collision oracle

This commit is contained in:
Erik 2026-07-25 06:27:09 +02:00
parent f1a8d36682
commit 624e1119ca
3 changed files with 827 additions and 0 deletions

View file

@ -0,0 +1,276 @@
# 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; I1 reusable scratch is next
**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
order.
## 1. Non-negotiable boundaries
1. Retail collision math is frozen. This slice changes storage and scratch
ownership only.
2. Every float read from DAT is copied bit-for-bit. The bake performs no
normalization, quantization, coordinate conversion, `double` round-trip, or
plane reconstruction.
3. Positive/negative child order, polygon order within every leaf, early
returns, epsilon comparisons, and contact-selection tie behavior remain
exact.
4. The current object-graph traversal remains an executable oracle until the
flat traversal passes mass differential and connected gates.
5. Physics `Transition` reuse lands only after reset-completeness,
cross-mover-identity, failure, and reentrancy tests prove that no state can
leak between resolves.
6. No GL/App dependency enters Core. Flat collision records live in
`AcDream.Core`; serialization and package access live in `AcDream.Content`;
the bake tool produces them offline.
7. The active G4 render cutover is independent. Its exact visual rollback
remains:
```text
git revert ef1d263337997bb030eadb7b8e71d73dc659907a
```
## 2. Target data architecture
The runtime collision representation is immutable, array-backed, and addressed
by integer index:
```text
FlatCollisionAsset
├── FlatPhysicsBsp
│ ├── FlatBspNode[] child indices; -1 = no child
│ ├── int[] leaf polygon-index stream
│ ├── FlatPolygon[] plane, cull mode, id, vertex range
│ └── Vector3[] verbatim polygon vertices
├── FlatCellContainmentBsp split planes + positive/negative indices
├── FlatSetupCollision cyl/sphere records + verbatim dimensions
└── EnvCellTopology portals, visible cells, seen-outside
```
Node order is deterministic pre-order. The flattener records the original
positive child before the negative child and retains each source leaf's
polygon list order. Traversal follows indices using the same recursive
control flow as the current retail port; “flat” does not mean a new collision
algorithm.
Per-EnvCell world transform remains publication state, not baked geometry.
CellStruct collision geometry can therefore alias by exact source identity;
cell-specific portals, visibility, `seen_outside`, and placement remain a
separate small payload/publication record.
## 3. Execution slices
### I0 — oracle inventory and fixed evidence
1. Grep named retail before touching traversal:
- `BSPTREE::find_collisions`
- `BSPNODE::find_walkable`
- `BSPLEAF::find_walkable`
- `BSPNODE::sphere_intersects_*`
- `BSPNODE::point_inside_cell_bsp`
- `CTransition::find_transitional_position`
- `CTransition::init_path`
- `CPhysicsObj::transition`
2. Cross-reference ACE and the existing acdream pseudocode/trajectory notes.
3. Write one collision-layout pseudocode note that separates:
source storage, traversal order, mutable query scratch, and returned
side-effects.
4. Inventory every production call into `BSPQuery`, every field read from
`PhysicsBSPNode`, `CellBSPNode`, `ResolvedPolygon`, `CellPhysics`,
`GfxObjPhysics`, and `SetupPhysics`, and every raw DAT graph retained by
`LandblockBuild`/`PhysicsDatBundle`.
5. Capture representative installed-DAT fixtures:
- outdoor/no-BSP;
- Facility Hub and cottage indoor cells;
- staircase/ramp and cellar-lip cases;
- a multipart door/building shell;
- projectile/thin-wall geometry;
- cells with asymmetric/null BSP children;
- leaves with multiple polygons and equal-distance candidates.
6. Record allocation baselines for player, remote, projectile, and camera
resolve loops.
Gate: source inventory is complete, fixtures reproduce through the current
graph path, and no behavior code changed.
### I1 — reusable transition and query scratch
1. Add explicit `ResetForReuse` methods to `ObjectInfo`, `CollisionInfo`,
`SpherePath`, and `Transition`.
2. Reset every scalar, nullable, property backing field, diagnostic counter,
collision GUID list, sphere element, walkable reference, backup value, and
transient flag. Retain only storage identity whose contents are completely
reset.
3. Give each `PhysicsEngine` one owned transition lease. The normal
update-thread path rents, initializes, resolves, snapshots the value-only
result, and returns in `finally`.
4. Make reentrancy explicit and tested. Never hand one mutable transition to
two concurrent/nested resolves and never silently share session scratch.
5. Reuse walkable vertex arrays only when their exact logical length matches;
allocate only on a true size change. No larger-capacity array may leak stale
vertices into polygon math.
6. Convert short-lived `BSPQuery.CollisionSphere` reference objects to
value/ref scratch while preserving each mutation/copy boundary.
7. Add structural reflection tests that poison every resettable member, reset,
and compare its complete value graph with a fresh instance while asserting
retained buffer identities.
8. Differential-run fresh-transition and reused-transition engines across
success, collision, failure, placement, grounded, airborne, sliding,
step-up/down, two-sphere, projectile, and camera cases.
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.
### I2 — immutable flat collision schema and flattener
1. Add Core-only flat record types with explicit index/range contracts.
2. Flatten physics and containment BSPs iteratively during preparation while
emitting deterministic pre-order node arrays.
3. Copy planes, spheres, vertices, setup dimensions, and collision metadata
verbatim. Tests compare every float via `SingleToInt32Bits`.
4. Resolve each leaf polygon ID to a direct polygon index at preparation time.
Missing IDs are corruption, not a runtime fallback.
5. Validate:
- child indices/ranges;
- acyclic/reachable tree shape;
- polygon and vertex ranges;
- source-vs-flat node, child, polygon, and float identity;
- deterministic output independent of dictionary enumeration.
6. Keep source graph and flat asset together only in test/shadow fixtures.
Gate: round-trip structural equality over synthetic edge cases and installed
DAT samples; no traversal cutover yet.
### I3 — package serialization, bake, and prepared collision source
1. Append new `PakAssetType` values; never renumber the existing mesh values.
2. Bump `CurrentBakeToolVersion` because a complete production package now
includes collision assets.
3. Add strict little-endian serializers with checked counts/ranges, exact float
bits, cancellation, CRC coverage, corruption rejection, and trailing-byte
rejection.
4. Add a typed `IPreparedCollisionSource` over the existing memory-mapped pak.
It shares package/catalog lifetime but does not overload the render-only
`ObjectMeshData` API.
5. Bake:
- GfxObj physics BSP + polygons + visual bounds;
- Setup cylinder/sphere/dimension records;
- CellStruct physics BSP + containment BSP + physics/portal polygons;
- EnvCell topology records.
6. Alias only byte-identical immutable collision payloads. Cell-specific
topology and world placement cannot be aliased merely because geometry is.
7. Extend full-bake determinism, two-thread equivalence, corruption,
cancellation, publish-transaction, and catalog-completeness tests.
Gate: two independent bakes are byte-identical; every collision key required
by the canonical route is present; a failed/cancelled bake cannot replace the
last good pak.
### I4 — flat traversal shadow implementation
1. Port each current entry point line-for-line against integer-index nodes:
- point-in-cell;
- sphere/cell overlap;
- walkable search;
- six-path moving collision dispatcher;
- static sphere/polygon overlap;
- time-of-impact overlap.
2. Share only polygon-level math that is storage-independent. Do not
“simplify” recursion, branch ordering, or early returns.
3. Preserve mutation semantics for valid-position spheres, hit polygon, path,
contact plane, slide normal, walk interpolation, and transition state.
4. Add a differential harness that executes old and flat queries from cloned
transition/body inputs and compares:
- bool/enum result;
- all output vectors/planes by float bits;
- selected polygon ID;
- path/collision/object side effects;
- cell membership and final `ResolveResult`.
5. Sweep a large installed-DAT sample with randomized points, spheres,
movement vectors, insert modes, orientations, scales, and boundary values.
6. Require explicit fixtures for null children, on-plane equality, radius
equality, equal candidate distances, multi-polygon leaf order, and deep
trees.
Gate: zero differential mismatch. Any mismatch blocks cutover; no tolerance
band is permitted for deterministic scalar results.
### I5 — dual publication and connected shadow gate
1. Extend collision preparation so a `LandblockBuild` carries immutable flat
assets and small placement/topology records rather than reparsing them on
the update thread.
2. During the shadow phase, publish both current and flat views under one
landblock generation/receipt.
3. Keep the current graph path authoritative. Run sampled flat queries from
cloned inputs and emit a deterministic mismatch artifact without affecting
gameplay.
4. Verify load, pending-to-loaded, demotion, rehydrate, same-location revisit,
removal, cancellation, and session reset release both views exactly once.
5. Run connected login, indoor/outdoor portal churn, doors, ramps, jumping,
projectiles, camera collision, and graceful reconnect.
Gate: zero shadow mismatch and no retained collision owner after landblock or
session teardown.
### I6 — production cutover and graph removal
1. Flip canonical `PhysicsDataCache` records and every `BSPQuery` production
call to flat assets in one bisectable commit.
2. Keep the graph route as an automated referee for the first cutover gate,
with an exact revert command recorded in this plan and project memory.
3. After automated and user correctness acceptance, remove:
- production `PhysicsBSPTree`/`CellBSPTree` retention;
- production polygon dictionaries and vertex DBObj graphs;
- raw collision graphs from streaming build/publication payloads;
- obsolete graph adapters and referee code.
4. Live-DAT tooling may parse source graphs only to produce/compare flat
assets; gameplay production never falls back from a missing/corrupt prepared
collision asset.
Gate: trajectories and collision fixtures remain bit-identical, package-only
startup succeeds, and retained-memory accounting shows no parsed collision
DBObj graph after stable world reveal.
### I7 — allocation, soak, and documentation closeout
1. Measure capped and uncapped resolve allocation separately for player,
remotes, projectiles, and camera.
2. Run the canonical connected lifecycle/reconnect route and the nine-stop R6
portal soak.
3. Repeat indoor door/ramp/cellar, jump/fall, combat/projectile, and camera-wall
gates.
4. Under RDP, accept correctness and lifecycle evidence only; defer absolute
GPU/frame-time acceptance to a physical-display run.
5. Update architecture, WorldBuilder inventory, milestones, roadmap, issue
ledger, divergence register, and both physics/render memory digests.
Final gate:
- full Release build and solution tests;
- zero deterministic old-vs-flat mismatch;
- zero steady transition/query-scratch allocation;
- no raw collision graph in production stable state;
- connected portal/reconnect/interaction routes pass;
- exact cutover rollback recorded before user verification.
## 4. Commit order
Each unit is independently buildable and reversible:
1. `docs(physics): pin Slice I collision oracle and fixtures`
2. `perf(physics): reuse reset-complete transition scratch`
3. `feat(physics): define deterministic flat collision assets`
4. `feat(content): bake and read flat collision assets`
5. `feat(physics): add differential flat BSP traversal`
6. `feat(streaming): shadow-publish flat collision assets`
7. `perf(physics): cut production traversal to flat assets`
8. `refactor(physics): remove parsed collision graphs`
9. `docs(physics): close Slice I evidence and roadmap`
The exact production-cutover commit and its `git revert <sha>` command are
added to this plan and `claude-memory/project_physics_collision_digest.md`
immediately when step 7 lands, before any visual/correctness gate.

View file

@ -0,0 +1,345 @@
# Slice I collision layout and scratch oracle
**Status:** I0 fixed evidence
**Scope:** storage layout, traversal order, scratch lifetime, production
retention, and allocation baseline for
[`2026-07-25-modern-runtime-slice-i.md`](../plans/2026-07-25-modern-runtime-slice-i.md).
This note does not authorize a collision-math change.
## 1. Sources and authority
Named retail is the behavior oracle:
| Mechanism | Retail symbol | Address | Named pseudo-C |
|---|---|---:|---:|
| scratch acquisition | `CTransition::makeTransition` | `0x0050B150` | 272873 |
| scratch release | `CTransition::cleanupTransition` | `0x00509DC0` | 271938 |
| transition reset | `CTransition::init` | `0x00509DD0` | 271946 |
| collision reset | `COLLISIONINFO::init` | `0x00509D60` | 271910 |
| path reset | `SPHEREPATH::init` | `0x0050C330` | 273903 |
| path initialization | `SPHEREPATH::init_path` | `0x0050CE20` | 274359 |
| movement loop | `CTransition::find_transitional_position` | `0x0050BDF0` | 273613 |
| object entry point | `CPhysicsObj::transition` | `0x00512DC0` | 280904 |
| six-path BSP dispatch | `BSPTREE::find_collisions` | `0x0053A440` | 323725 |
| cell point predicate | `BSPNODE::point_inside_cell_bsp` | `0x0053C1F0` | 325508 |
| cell sphere predicate | `BSPNODE::sphere_intersects_cell_bsp` | `0x0053C260` | 325546 |
| walkable internal node | `BSPNODE::find_walkable` | `0x0053CC80` | 326211 |
| walkable leaf | `BSPLEAF::find_walkable` | `0x0053D6F0` | 326793 |
| polygon overlap | `BSPNODE::sphere_intersects_poly` | `0x0053CA30` | 326091 |
| solid overlap | `BSPNODE::sphere_intersects_solid` | `0x0053CAF0` | 326130 |
| solid/polygon overlap | `BSPNODE::sphere_intersects_solid_poly` | `0x0053CD50` | 326246 |
Interpretation aids:
- ACE `master` commit
`65f092dd02505c04532b701c48d11af117cbc815`:
`Physics/Transition.cs`, `SpherePath.cs`, `ObjectInfo.cs`,
`Collision/CollisionInfo.cs`, and `Physics/BSP/{BSPTree,BSPNode,BSPLeaf}.cs`.
- Chorizite DatReaderWriter `master` commit
`c5359870963fecb55c9b90e8143a92fc7fe88e11`; acdream consumes package
`2.1.7`. Its `PhysicsBSPNode` and `CellBSPNode` unpackers establish the
source child-presence and stream order.
- Existing acdream translations:
[`transition_pseudocode.md`](transition_pseudocode.md),
[`2026-06-24-obstruction-ethereal-pseudocode.md`](2026-06-24-obstruction-ethereal-pseudocode.md),
[`2026-07-05-ccylsphere-collision-family-pseudocode.md`](2026-07-05-ccylsphere-collision-family-pseudocode.md),
[`2026-07-07-csphere-collision-family-pseudocode.md`](2026-07-07-csphere-collision-family-pseudocode.md),
and the physics digest.
Retail wins if an interpretation aid disagrees. Slice I keeps the current graph
implementation executable until every flat query is compared against it. A
pre-existing graph-vs-retail mismatch, if found, is adjudicated separately; it
must not be hidden inside the layout cutover.
## 2. Retail scratch lifetime
`CTransition::makeTransition` is not a heap constructor per movement call:
```text
on first use:
construct static CTransition transit[10]
level = transition_level
if level >= 10:
return null
transition = &transit[level]
CTransition::init(transition)
transition_level++
return transition
```
`CTransition::cleanupTransition` only decrements `transition_level`. This is a
ten-deep, stack-disciplined scratch arena. Nested transitions receive distinct
records and release in LIFO order. The object graph and embedded arrays retain
their storage identity; `init` resets the logical state before each lease.
Retail reset pseudocode:
```text
CTransition::init:
object_info.object = null
object_info.state = 0
object_info.targetID = 0
SPHEREPATH::init(sphere_path)
collision_info.last_known_contact_plane_valid = false
collision_info.contact_plane_valid = false
collision_info.sliding_normal_valid = false
collision_info.collision_normal_valid = false
collision_info.num_collide_object = 0
clear last_collided_object and adjacent collision result fields
collision_info.contact_plane_cell_id = 0
SPHEREPATH::init:
num_sphere = 0
begin_cell/begin_pos/curr_cell/check_cell = null
insert_type = TRANSITION
step_down/step_up/collide = false
hits_interior_cell/bldg_check/obstruction_ethereal = false
backup_cell = null
walkable_allowance = 0
walkable = null
check_walkable/cell_array_valid/neg_step_up/neg_poly_hit = false
placement_allows_sliding = true
```
The acdream reset must cover additional modern representations of the same
logical state: cell IDs instead of pointers, orientations, carried block
origin, retained vertex buffers, diagnostic counters, GUID lists, target and
self IDs, physics-state flags, water bits, and value-type planes/vectors.
Retained storage may survive only if its logical contents are reset completely.
## 3. Immutable source storage
### Physics BSP
The source graph fields consumed by collision are:
```text
PhysicsBSPNode:
Type
SplittingPlane
PosNode, NegNode
LeafIndex
Solid
BoundingSphere (origin + radius)
Polygons[] (ordered ushort ids)
```
DatReaderWriter decodes child presence from `BSPNodeType`. When both children
exist, positive is read before negative. A flat representation therefore uses
explicit signed child indices (`-1` = absent) and preserves source preorder:
node, positive subtree, negative subtree.
### Cell-containment BSP
```text
CellBSPNode:
Type
SplittingPlane
PosNode, NegNode
LeafIndex
```
Containment trees do not carry physics leaf polygons or bounding spheres. They
must remain a separate schema from the physics BSP rather than wasting fields
or accidentally applying physics-node traversal rules.
### Resolved polygons
Runtime physics reads:
```text
ResolvedPolygon:
Id
Plane (normal + D)
SidesType
NumPoints
Vertices[] in source order
```
The flat asset stores one polygon table and one contiguous vertex stream.
Every polygon has an explicit `(vertexStart, vertexCount)` and every leaf has an
explicit `(polygonIndexStart, polygonIndexCount)`. Polygon IDs are resolved to
indices during preparation. Missing IDs are corrupt prepared content, never a
runtime dictionary fallback.
Every float is copied through its `SingleToInt32Bits` representation. Planes
are copied, not reconstructed from vertices.
### Setup and cell payload
`SetupPhysics` consumes ordered `CylSpheres`, ordered `Spheres`, `Height`,
`Radius`, `StepUpHeight`, and `StepDownHeight`.
`CellPhysics` currently combines immutable geometry with publication state:
physics BSP, resolved physics polygons, cell-containment BSP, world and inverse
world transforms, ordered portals, resolved portal polygons, visible-cell IDs,
and `SeenOutside`. The transforms and topology are per EnvCell placement.
Immutable CellStruct collision geometry may alias only when its bytes and
source identity agree; placement/topology may not be aliased merely because
geometry matches.
## 4. Load-bearing traversal order
### Walkable search
```text
find_walkable(node, path, mutableSphere, hitPoly, movement, up, changed):
if node.boundingSphere does not intersect mutableSphere:
return
distance = signedDistance(node.splittingPlane, mutableSphere.center)
threshold = mutableSphere.radius - 0.0002
if distance >= threshold:
visit positive only
else if distance <= -threshold:
visit negative only
else:
visit positive
visit negative
find_walkable(leaf, ...):
if no polygons or leaf bounds miss:
return
for polygon in leaf.polygons IN STORED ORDER:
if polygon.walkable_hits_sphere(...)
and polygon.adjust_sphere_to_plane(...):
changed = true
hitPoly = polygon
```
The second child sees the sphere position mutated by the first child. A later
successful polygon replaces `hitPoly`. Consequently child order, leaf order,
and reference-style sphere mutation are behavior, not implementation detail.
### Six-path collision dispatch
`BSPTREE::find_collisions` evaluates in this order:
1. placement or obstruction-ethereal: solid test, primary sphere then secondary;
2. `check_walkable`;
3. `step_down`;
4. pending `collide`: walkable search and adjustment;
5. existing contact: primary collision and step-up, then secondary slide/negative hit;
6. airborne/default: primary collision/set-collide, then secondary hard collision.
The dispatcher observes and mutates `SpherePath`, `ObjectInfo`, and
`CollisionInfo`. The flat port must preserve every early return and the
primary-before-secondary order.
### Cell containment
Point and radius-aware containment are distinct entry points.
`sphere_intersects_cell_bsp` expands the radius by exactly `0.01`. Null-child,
on-plane, and near-plane behavior is pinned by the current conformance tests and
must be compared bit-for-bit during I4. No generic “BSP traversal helper” may
merge containment, walkable, solid, and collision traversal; their null-child
and return semantics differ.
## 5. Mutable query state and returned side effects
The query scratch is:
- `ObjectInfo`: state, step heights, scale, ethereal/step-down state, mover
physics state, target ID, self ID, gravity state, and velocity-killed result.
- `CollisionInfo`: live and last-known contact planes including cell/water
metadata, sliding and collision normals, environment hit, stationary-fall
count, adjustment offset, collision GUID list, last collision GUID, and
diagnostic write count.
- `SpherePath`: all local/global/current spheres, four positions and
orientations, current/check/backup cells, carried block origin, movement
offset, step-up/down state, mutable current/last walkable state, negative-hit
state, insertion mode, placement policy, and building/interior/ethereal flags.
- per-query `CollisionSphere` values used as reference-mutated valid positions.
Returned behavior includes more than `ResolveResult`: body contact and
last-contact state, walkable polygon, stationary-fall/transient flags, sliding
normal, killed velocity, selected hit polygon, collision GUIDs, and swept cell
membership. Differential tests must compare these side effects as well as the
boolean/enum return and final position.
## 6. Production graph-call inventory
| Entry point | Production consumers |
|---|---|
| `PointInsideCellBsp` | `World/Cells/EnvCell`; `CellTransit` point/portal membership; `Transition.CheckOtherCells` diagnostics and admission |
| `SphereIntersectsCellBsp` | `CellTransit` candidate, current-cell, and transit membership; `Transition.CheckOtherCells` |
| `FindCrossedEdge` | precipice slide and nearest-inside-edge handling in `SpherePath`/`Transition` |
| `FindWalkableSphere` | indoor walkable-plane probe in `Transition.TryFindIndoorWalkablePlane` |
| `FindCollisions` | EnvCell physics BSP, static/multipart GfxObj BSP, and building-shell BSP in `Transition` |
The legacy dictionary/`VertexArray` overloads and static
`SphereIntersectsPoly*` entry points currently serve tests and diagnostics, not
the production movement call graph. I6 removes them from production ownership
only after their required tooling users are explicit.
## 7. Parsed graph retention inventory
The current worker `LandblockBuildFactory` creates a `PhysicsDatBundle` holding
raw `LandBlockInfo`, `EnvCell`, `Environment`, `Setup`, and `GfxObj` DBObjs in
per-build dictionaries. It also opportunistically populates
`PhysicsDataCache` while gathering entity and scenery bounds.
On the update thread, `LandblockPhysicsPublisher`:
1. resolves EnvCell → Environment → CellStruct from that raw bundle;
2. calls `CacheCellStruct`, retaining physics BSP, containment BSP, raw polygon
dictionaries/vertex arrays, resolved polygons, and topology;
3. calls `CacheGfxObj`, retaining GfxObj physics BSP, raw polygon/vertex graphs,
resolved polygons, and bounds;
4. reads Setup graphs for collision shapes, buildings, and multipart statics.
`LandblockStaticPresentationPublisher` also reads raw Setup light definitions.
`LandblockStreamResultCost` currently counts bundle dictionary entries but not
the complete transitive DBObj graph. I3 must replace collision publication with
prepared immutable payloads without removing the separately required render or
lighting data.
## 8. Fixed fixture ledger
| Required shape | Existing fixed evidence |
|---|---|
| outdoor/no BSP | `PhysicsEngineTests`, `CellMarchLandblockPreservationTests`, projectile terrain fixtures |
| Facility Hub indoor | `Issue137CorridorSeamReplayTests` (`0x8A02016E↔0x8A02017A`), `Issue180CorridorSweepHysteresisReplayTests` (`0x8A020164`) |
| Holtburg cottage indoor | `ThresholdPortalCrossingReplayTests`, `CameraCornerSealReplayTests`, `Fixtures/flap-doorway/*` |
| staircase/ramp | `BSPStepUpFixtures`, `Issue185OutdoorStairsSeamReplayTests` |
| cellar lip | `CellarLipWedgeTests`, `CellarUpTrajectoryReplayTests`, `Fixtures/cellar-lip/*`, `Fixtures/issue98/*` |
| multipart door/building shell | `DoorCollisionApparatusTests`, `DoorBugTrajectoryReplayTests`, GfxObj fixture `0x01000A2B` |
| projectile/thin wall | `ProjectilePhysicsStepperTests.RegisterThinBspWall` |
| asymmetric/null children | `SphereIntersectsCellBspTests`, `PhysicsEngineAdjustPositionTests`, real DAT containment fixtures |
| multi-polygon/equal candidate order | `BSPQueryTests`, `IndoorWalkablePlaneTests`, `BSPStepUpFixtures`; I2 adds explicit flat-order structural cases before flattening gates |
I0 graph-path fixture command:
```powershell
dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj -c Release --no-build `
--filter "FullyQualifiedName~BSPQueryTests|FullyQualifiedName~SphereIntersectsCellBspTests|FullyQualifiedName~CellarLipWedgeTests|FullyQualifiedName~CellarUpTrajectoryReplayTests|FullyQualifiedName~CameraCornerSealReplayTests|FullyQualifiedName~ProjectilePhysicsStepperTests|FullyQualifiedName~DoorCollisionApparatusTests|FullyQualifiedName~Issue137CorridorSeamReplayTests|FullyQualifiedName~ThresholdPortalCrossingReplayTests|FullyQualifiedName~Issue185OutdoorStairsSeamReplayTests|FullyQualifiedName~CylSphereFamilyTests|FullyQualifiedName~SphereCollisionFamilyTests|FullyQualifiedName~TransitionAllocationBaselineTests"
```
Result on 2026-07-25: **111 passed, 0 skipped, 0 failed** against the current
object-graph route and installed retail DATs.
## 9. Allocation baseline
`TransitionAllocationBaselineTests` warms 256 calls, measures 4,096 Release
resolves on one thread, and exercises a fixed BSP wall through the four
production mover call shapes:
| Mover | Current graph path |
|---|---:|
| player, two spheres | 4,448 B/resolve |
| remote, two spheres | 4,448 B/resolve |
| projectile, one 5 cm sphere | 6,848 B/resolve |
| camera/viewer, one 30 cm sphere | 2,512 B/resolve |
The projectile crosses the same distance using a much smaller sphere, causing
more retail substeps and therefore more per-step query-scratch allocation.
This is why allocation must be measured by mover family rather than quoting one
average.
I1's gate is zero steady allocation attributable to transition construction,
walkable buffer cloning, and `CollisionSphere` query scratch after warmup.
It does not claim that every optional diagnostic/capture path is allocation
free.

View file

@ -0,0 +1,206 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.Types;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Slice I0 evidence harness 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.
/// </summary>
public sealed class TransitionAllocationBaselineTests
{
private const int WarmupIterations = 256;
private const int MeasuredIterations = 4_096;
private const uint CellId = 0xA9B40001u;
private readonly ITestOutputHelper _output;
public TransitionAllocationBaselineTests(ITestOutputHelper output)
=> _output = output;
[Fact]
public void GraphPath_RecordsPerResolveAllocationForEveryMoverFamily()
{
var (root, resolved) = BSPStepUpFixtures.TallWall();
var engine = BuildEngine(root, resolved);
long player = Measure(() => ResolvePlayer(engine));
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):");
_output.WriteLine($" player: {player,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."));
}
private static long Measure(Func<ResolveResult> resolve)
{
ResolveResult sink = default;
for (int i = 0; i < WarmupIterations; i++)
sink = resolve();
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < MeasuredIterations; i++)
sink = resolve();
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
GC.KeepAlive(sink);
return allocated / MeasuredIterations;
}
private static ResolveResult ResolvePlayer(PhysicsEngine engine)
{
var body = Bodies.Player;
ResetBody(body, PhysicsStateFlags.Gravity);
return engine.ResolveWithTransition(
currentPos: new Vector3(0.10f, 0f, 0.20f),
targetPos: new Vector3(0.36f, 0f, 0.20f),
cellId: CellId,
sphereRadius: BSPStepUpFixtures.SphereRadius,
sphereHeight: 1.20f,
stepUpHeight: 0.60f,
stepDownHeight: 1.50f,
isOnGround: true,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0x5000000Au);
}
private static ResolveResult ResolveRemote(PhysicsEngine engine)
{
var body = Bodies.Remote;
ResetBody(body, PhysicsStateFlags.Gravity);
return engine.ResolveWithTransition(
currentPos: new Vector3(0.10f, 0f, 0.20f),
targetPos: new Vector3(0.36f, 0f, 0.20f),
cellId: CellId,
sphereRadius: BSPStepUpFixtures.SphereRadius,
sphereHeight: 1.20f,
stepUpHeight: 0.60f,
stepDownHeight: 1.50f,
isOnGround: true,
body: body,
moverFlags: ObjectInfoState.EdgeSlide,
movingEntityId: 0x80000001u);
}
private static ResolveResult ResolveProjectile(PhysicsEngine engine)
{
var body = Bodies.Projectile;
ResetBody(body, PhysicsStateFlags.Missile);
return engine.ResolveWithTransition(
currentPos: new Vector3(0.10f, 0f, 2.00f),
targetPos: new Vector3(0.36f, 0f, 2.00f),
cellId: CellId,
sphereRadius: 0.05f,
sphereHeight: 0f,
stepUpHeight: 0f,
stepDownHeight: 0f,
isOnGround: false,
body: body,
movingEntityId: 0x80000002u);
}
private static ResolveResult ResolveCamera(PhysicsEngine engine)
=> engine.ResolveWithTransition(
currentPos: new Vector3(0.10f, 0f, 2.00f),
targetPos: new Vector3(0.36f, 0f, 2.00f),
cellId: CellId,
sphereRadius: 0.30f,
sphereHeight: 0f,
stepUpHeight: 0f,
stepDownHeight: 0f,
isOnGround: false,
body: null,
moverFlags: ObjectInfoState.IsViewer
| ObjectInfoState.PathClipped
| ObjectInfoState.FreeRotate
| ObjectInfoState.PerfectClip);
private static void ResetBody(PhysicsBody body, PhysicsStateFlags state)
{
body.State = state;
body.TransientState = TransientStateFlags.Active;
body.ContactPlaneValid = false;
body.WalkablePolygonValid = false;
body.WalkableVertices = null;
body.SlidingNormal = Vector3.Zero;
body.FramesStationaryFall = 0;
}
private static PhysicsEngine BuildEngine(
PhysicsBSPNode root,
Dictionary<ushort, ResolvedPolygon> resolved)
{
var heights = new byte[81];
var heightTable = new float[256];
Array.Fill(heightTable, -50f);
var engine = new PhysicsEngine();
engine.AddLandblock(
0xA9B4FFFFu,
new TerrainSurface(heights, heightTable),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
0f,
0f);
const uint gfxObjId = 0x0100F100u;
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 = 10f,
},
});
engine.DataCache = cache;
engine.ShadowObjects.Register(
entityId: gfxObjId,
gfxObjId: gfxObjId,
worldPos: Vector3.Zero,
rotation: Quaternion.Identity,
radius: 10f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: 0xA9B4FFFFu,
collisionType: ShadowCollisionType.BSP,
scale: 1f);
return engine;
}
private static class Bodies
{
internal static readonly PhysicsBody Player = new();
internal static readonly PhysicsBody Remote = new();
internal static readonly PhysicsBody Projectile = new();
}
}