diff --git a/docs/plans/2026-07-25-modern-runtime-slice-i.md b/docs/plans/2026-07-25-modern-runtime-slice-i.md new file mode 100644 index 00000000..a72df5ad --- /dev/null +++ b/docs/plans/2026-07-25-modern-runtime-slice-i.md @@ -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 ` command are +added to this plan and `claude-memory/project_physics_collision_digest.md` +immediately when step 7 lands, before any visual/correctness gate. diff --git a/docs/research/2026-07-25-slice-i-collision-layout-oracle.md b/docs/research/2026-07-25-slice-i-collision-layout-oracle.md new file mode 100644 index 00000000..16f05b8f --- /dev/null +++ b/docs/research/2026-07-25-slice-i-collision-layout-oracle.md @@ -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. diff --git a/tests/AcDream.Core.Tests/Physics/TransitionAllocationBaselineTests.cs b/tests/AcDream.Core.Tests/Physics/TransitionAllocationBaselineTests.cs new file mode 100644 index 00000000..88e8c09f --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/TransitionAllocationBaselineTests.cs @@ -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; + +/// +/// Slice I0 evidence harness for the four production +/// call shapes. This is not a +/// budget assertion: I1 deliberately changes the expected allocation from the +/// recorded graph-path baseline to zero steady transition/query-scratch bytes. +/// The harness stays in-tree so the before/after figure is reproducible. +/// +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 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 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(), + Array.Empty(), + 0f, + 0f); + + const uint gfxObjId = 0x0100F100u; + var cache = new PhysicsDataCache(); + cache.RegisterGfxObjForTest(gfxObjId, new GfxObjPhysics + { + BSP = new PhysicsBSPTree { Root = root }, + PhysicsPolygons = new Dictionary(), + Vertices = new VertexArray(), + Resolved = resolved, + BoundingSphere = new Sphere + { + Origin = new Vector3(0f, 0f, 2.5f), + Radius = 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(); + } +}