feat(physics): add differential flat BSP traversal

Port every current containment, overlap, walkable, and six-path moving-collision query to immutable integer-indexed assets behind a graph-authoritative referee. Exact synthetic, installed-DAT, complete-resolver, and zero-allocation gates prove bit-identical behavior before connected dual publication.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-25 15:47:58 +02:00
parent 9cd42417a8
commit f7ff9f4eea
13 changed files with 3907 additions and 46 deletions

View file

@ -169,9 +169,16 @@ interface shares the render package's single mmap. Two complete
29,908,271,024-byte bakes produced 2,232,170 typed keys, zero failures, and the
same SHA-256 across 16 and 7 workers. Corruption, cancellation, installed
package, Release build, and 8,371-test / 5-skip gates pass.
Slice I4 is complete: every current containment, overlap, walkable, and
six-path moving-collision entry point has an integer-indexed flat shadow port.
The exact referee passes 13 focused tests, 7,500 installed-DAT comparisons,
1,200 complete resolver frames, and 10,000 warmed iterations at zero managed
bytes with no mismatch. Release build and 8,384 solution tests / 5 skips pass.
Production remains graph-authoritative until the connected shadow and cutover
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 I4 from
passes. Slice I is active at I5 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:
@ -179,7 +186,8 @@ the portal-warmup corrections. Evidence:
`docs/research/2026-07-25-slice-g5-production-profile.md` and
`docs/research/2026-07-25-slice-i1-transition-scratch.md` and
`docs/research/2026-07-25-slice-i2-flat-collision-schema.md` and
`docs/research/2026-07-25-slice-i3-prepared-collision-package.md`.
`docs/research/2026-07-25-slice-i3-prepared-collision-package.md` and
`docs/research/2026-07-25-slice-i4-flat-bsp-differential.md`.
**Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated

View file

@ -167,9 +167,16 @@ interface shares the render package's single mmap. Two complete
29,908,271,024-byte bakes produced 2,232,170 typed keys, zero failures, and the
same SHA-256 across 16 and 7 workers. Corruption, cancellation, installed
package, Release build, and 8,371-test / 5-skip gates pass.
Slice I4 is complete: every current containment, overlap, walkable, and
six-path moving-collision entry point has an integer-indexed flat shadow port.
The exact referee passes 13 focused tests, 7,500 installed-DAT comparisons,
1,200 complete resolver frames, and 10,000 warmed iterations at zero managed
bytes with no mismatch. Release build and 8,384 solution tests / 5 skips pass.
Production remains graph-authoritative until the connected shadow and cutover
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 I4 from
passes. Slice I is active at I5 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:
@ -177,7 +184,8 @@ the portal-warmup corrections. Evidence:
`docs/research/2026-07-25-slice-g5-production-profile.md` and
`docs/research/2026-07-25-slice-i1-transition-scratch.md` and
`docs/research/2026-07-25-slice-i2-flat-collision-schema.md` and
`docs/research/2026-07-25-slice-i3-prepared-collision-package.md`.
`docs/research/2026-07-25-slice-i3-prepared-collision-package.md` and
`docs/research/2026-07-25-slice-i4-flat-bsp-differential.md`.
**Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated

View file

@ -1654,13 +1654,20 @@ port in any phase — no separate listing here.
> defines deterministic immutable collision records and validates exact
> source identity over synthetic corruption cases and representative installed
> DATs; production traversal remains on the graph oracle until the later
> differential gate.
> differential gate. Slice I3 appends the complete prepared collision package
> to the existing single mmap. Slice I4 now ports every current containment,
> overlap, walkable, and six-path moving-collision entry point to the immutable
> integer-indexed assets. Its exact referee passes 13 focused tests, 7,500
> installed-DAT comparisons, 1,200 complete resolver frames, and 10,000 warmed
> iterations at zero managed bytes with no mismatch. Production remains
> graph-authoritative while I5 dual publication and the connected shadow gate
> are active. Release build and 8,384 solution tests / 5 skips pass.
> 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 I4 from
> connected lifecycle/reconnect gate. Slice I is active at I5 from
> [its detailed plan](2026-07-25-modern-runtime-slice-i.md).
**Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the
@ -1685,11 +1692,11 @@ hitch and follows.
| MP1a | `AcDream.Content` extraction (GL-free MeshExtractor + boundary records out of App) | ✅ SHIPPED 2026-07-05 — user-gated (renders identical, zero tripwires, perf-neutral); 8 commits `651d041e`..`b0758d77` |
| MP1b | Pak format + `acdream-bake` CLI + mmap `PakReader` + equivalence/full-scale gate | ✅ **SHIPPED 2026-07-24** — 729,888 EnvCell keys → 17,117 unique geometries + 712,771 aliases (42.6×), 751,141 total keys / 38,370 physical blobs, zero failures, 28,192.4 MiB, 81.4 s validated atomic publish; full-DAT gate also corrected a real collision in WB's legacy geometry hash. [Report](../research/2026-07-24-slice-b-full-bake-report.md) |
| **MP-Alloc (safe batch)** | Reuse per-frame buffers: anim pose, particle draw-list, interior partition, animatedIds/drawableCells sets | ✅ SHIPPED + USER-GATED 2026-07-05 — dense-town frame-time spikes 2087ms → 610ms, alloc spikes (3075MB single-frame) eliminated, gen2 GC 511/window → ~0; user confirms FPS steady. 4 commits `b8c05e2b`..`91afea24` |
| MP-Alloc (hard sites) | EnvCell settled-camera rebuild gate + physics `Transition` reuse | 🟡 Physics half SHIPPED 2026-07-25 — retail-shaped reset-complete transition/query scratch is bit-identical and 0 B/resolve across five profiles; immutable flat collision storage and its complete deterministic prepared package are shipped; traversal differential work continues in Slice I4. |
| 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; immutable flat collision storage, its deterministic prepared package, and the zero-mismatch/zero-allocation flat traversal referee are shipped; connected dual publication continues in Slice I5. |
| 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) | 🟡 ACTIVE — Slice I1 zero-allocation transition scratch, I2 immutable schema, and I3 complete deterministic collision package shipped; I4 flat traversal shadow implementation is active. |
| MP4 | Zero-alloc frame loop + flat physics data (residual, post-MP-Alloc) | 🟡 ACTIVE — Slice I1 zero-allocation transition scratch, I2 immutable schema, I3 deterministic collision package, and I4 exact flat traversal referee shipped; I5 dual publication and connected shadow verification are active. |
| MP5 | Job-system parallelism | ⚪ stretch, evidence-gated |
---

View file

@ -128,8 +128,14 @@ prepared-collision interface over the existing single mmap. Two complete
29,908,271,024-byte bakes contain 2,232,170 keys with zero failures and the
same SHA-256 across different worker counts; cancellation/corruption gates and
8,371 solution tests / 5 skips pass.
Slice I4 ports every current collision entry point to integer-indexed flat
assets behind a graph-authoritative referee. Thirteen focused tests, 10,000
randomized static/swept comparisons, 5,000 walkable comparisons, 7,500
installed-DAT comparisons, and 1,200 complete resolver frames have exact
state/float-bit equality; 10,000 warmed flat iterations allocate zero managed
bytes. Release build and 8,384 solution tests / 5 skips pass.
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 I4 under
and pooled/borrowed/direct network I/O pass. Slice I is active at I5 under
[`2026-07-25-modern-runtime-slice-i.md`](2026-07-25-modern-runtime-slice-i.md).
The historical exact G4 visual rollback remains
`git revert ef1d263337997bb030eadb7b8e71d73dc659907a`.

View file

@ -1,8 +1,8 @@
# Modern runtime Slice I — flat collision assets and zero-allocation physics
**Status:** I0I3 COMPLETE — retail oracle, zero-allocation transition
scratch, deterministic immutable collision records, and complete prepared
package fixed; I4 active
**Status:** I0-I4 COMPLETE - retail oracle, zero-allocation transition
scratch, deterministic immutable collision records, complete prepared
package, and exact flat-traversal differential gate fixed; I5 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
@ -213,6 +213,16 @@ tests / 5 skips pass. Production traversal remains graph-only. Evidence:
Gate: zero differential mismatch. Any mismatch blocks cutover; no tolerance
band is permitted for deterministic scalar results.
**PASSED 2026-07-25:** the integer-indexed shadow ports every current
containment, overlap, walkable, and six-path moving-collision entry point.
The 13-test referee completed 10,000 randomized static/swept comparisons,
5,000 walkable comparisons, 7,500 installed-DAT comparisons across three
representative cells, all six dispatcher paths, and 1,200 complete resolver
frames with exact state and float-bit equality. Ten thousand warmed flat
iterations allocated zero managed bytes. Production remains graph-authoritative
until I5/I6. Release build and 8,384 solution tests / 5 skips pass. Evidence:
[`../research/2026-07-25-slice-i4-flat-bsp-differential.md`](../research/2026-07-25-slice-i4-flat-bsp-differential.md).
### I5 — dual publication and connected shadow gate
1. Extend collision preparation so a `LandblockBuild` carries immutable flat

View file

@ -0,0 +1,87 @@
# Slice I4 - differential flat BSP traversal
**Date:** 2026-07-25
**Scope:** Modern Runtime Slice I4 only
**Behavioral boundary:** the new integer-indexed traversal is a shadow
implementation. The parsed-DAT graph remains production-authoritative until
the Slice I5 connected shadow gate and Slice I6 cutover.
## Result
`FlatBspQuery` now ports every collision entry point used by the current
runtime over immutable, integer-indexed nodes and polygon ranges:
- point-in-cell;
- sphere/cell overlap;
- static sphere/polygon overlap;
- swept sphere/polygon time-of-impact;
- walkable polygon selection;
- solid and solid-polygon traversal;
- the complete six-path moving-collision dispatcher.
The port preserves retail's traversal order, strict comparisons, early
returns, and mutable query state. The positive child remains first where
retail visits it first; the negative child observes every valid-sphere,
contact, path, slide, and candidate mutation made by the positive child.
Only `BSPQuery.PolygonHitsSpherePrecise`, which is independent of graph
storage, is shared with the existing graph oracle.
`CollisionTraversal` is the narrow temporary referee seam. Production starts
in `Graph` mode. Tests can run a cloned complete resolver in `Flat` mode, and
a missing flat asset fails explicitly instead of falling back to the graph.
Slice I6 removes this representation switch after connected acceptance.
## Retail oracle
The implementation follows the consolidated pseudocode and boundary table in
[`2026-07-25-slice-i-collision-layout-oracle.md`](2026-07-25-slice-i-collision-layout-oracle.md),
including:
- `BSPTREE::find_collisions` at `0x0053A440`;
- `BSPNODE::point_inside_cell_bsp` at `0x0053C1F0`;
- `BSPNODE::sphere_intersects_cell_bsp` at `0x0053C260`;
- `BSPNODE::sphere_intersects_poly` at `0x0053CA30`;
- `BSPNODE::sphere_intersects_solid` at `0x0053CAF0`;
- `BSPNODE::find_walkable` at `0x0053CC80`;
- `BSPNODE::sphere_intersects_solid_poly` at `0x0053CD50`;
- `BSPLEAF::find_walkable` at `0x0053D6F0`.
The same oracle records the cross-checks against ACE master
`65f092...` and Chorizite DatReaderWriter `c535987...`. Retail remains the
authority where those references differ.
## Differential evidence
The referee compares scalar results and all stateful outputs by exact float
bits. It covers selected polygons, vectors, planes, path state, collision and
object flags, contact-plane writes, final `ResolveResult`, persistent body
state, and full cell membership.
| Gate | Executions | Result |
|---|---:|---:|
| Null, on-plane, radius-equality, equal-candidate, multi-polygon, and deep-tree fixtures | 4 focused fixtures | 0 mismatches |
| Static and swept overlap randomized sweep | 10,000 paired cases | 0 mismatches |
| Walkable randomized sweep | 5,000 paired cases | 0 mismatches |
| Six dispatcher paths | 6 complete transition fixtures | 0 mismatches |
| Installed-DAT sweep across `0x8A02016E`, `0x8A02017A`, and `0xA9B4013F` | 7,500 randomized cases | 0 mismatches |
| Complete graph/flat resolver sequences | 1,200 frames | 0 mismatches |
| Warmed flat traversal | 10,000 iterations | 0 managed bytes |
The installed-DAT gate randomizes points, spheres, movement, insert paths,
orientations, scale, and boundary values. No tolerance band is used.
## Verification
- focused differential tests: 13 passed;
- complete Core suite: 3,276 passed / 2 skipped;
- `git diff --check`: no whitespace errors;
- full Release build: passed;
- complete Release suite: 8,384 passed / 5 skipped.
No divergence-register row changes are required. This slice changes the
storage representation exercised by the referee, not production gameplay
behavior. Slice I5 next publishes the prepared flat assets alongside the
current graph under one landblock generation and runs the connected shadow
gate.

View file

@ -96,7 +96,7 @@ public static class BSPQuery
///
/// <para>ACE: Polygon.cs polygon_hits_sphere_precise.</para>
/// </summary>
private static bool PolygonHitsSpherePrecise(
internal static bool PolygonHitsSpherePrecise(
in Plane polyPlane,
ReadOnlySpan<Vector3> verts,
Vector3 sphereCenter,
@ -2339,6 +2339,31 @@ public static class BSPQuery
ref hitPolyId, ref hitNormal);
}
/// <summary>
/// Allocation-free graph-oracle seam used by the Slice I flat traversal
/// differential harness.
/// </summary>
internal static bool SphereIntersectsPoly(
PhysicsBSPNode? node,
Dictionary<ushort, ResolvedPolygon> resolved,
Vector3 sphereCenter,
float sphereRadius,
out ushort hitPolyId,
out Vector3 hitNormal)
{
hitPolyId = 0;
hitNormal = Vector3.Zero;
if (node is null) return false;
return SphereIntersectsPolyStaticRecurse(
node,
resolved,
sphereCenter,
sphereRadius,
ref hitPolyId,
ref hitNormal);
}
private static bool SphereIntersectsPolyStaticRecurse(
PhysicsBSPNode? node,
Dictionary<ushort, ResolvedPolygon> resolved,
@ -2431,6 +2456,38 @@ public static class BSPQuery
return hitTime < float.MaxValue;
}
/// <summary>
/// Allocation-free graph-oracle seam used by the Slice I flat traversal
/// differential harness.
/// </summary>
internal static bool SphereIntersectsPolyWithTime(
PhysicsBSPNode? node,
Dictionary<ushort, ResolvedPolygon> resolved,
Vector3 sphereCenter,
float sphereRadius,
Vector3 movement,
out ushort hitPolyId,
out Vector3 hitNormal,
out float hitTime)
{
hitPolyId = 0;
hitNormal = Vector3.Zero;
hitTime = float.MaxValue;
if (node is null) return false;
SphereIntersectsPolyWithTimeRecurse(
node,
resolved,
sphereCenter,
sphereRadius,
movement,
ref hitPolyId,
ref hitNormal,
ref hitTime);
return hitTime < float.MaxValue;
}
private static void SphereIntersectsPolyWithTimeRecurse(
PhysicsBSPNode? node,
Dictionary<ushort, ResolvedPolygon> resolved,

View file

@ -159,15 +159,19 @@ public static class CellTransit
// neighbour cell whether the sphere intersects its CellBSP.
// The portal-plane side test is only the unloaded-cell load hint.
var otherCell = cache.GetCellStruct(otherId);
if (otherCell?.CellBSP?.Root is not null)
if (otherCell is not null &&
CollisionTraversal.HasCellContainment(cache, otherCell))
{
for (int i = 0; i < sphereCount; i++)
{
var sphere = worldSpheres[i];
var otherLocalCenter = Vector3.Transform(
sphere.Origin, otherCell.InverseWorldTransform);
bool hit = BSPQuery.SphereIntersectsCellBsp(
otherCell.CellBSP.Root, otherLocalCenter, sphere.Radius);
bool hit = CollisionTraversal.SphereIntersectsCell(
cache,
otherCell,
otherLocalCenter,
sphere.Radius);
if (hit)
{
candidates.Add(otherId);
@ -372,7 +376,8 @@ public static class CellTransit
continue;
var otherCell = cache.GetCellStruct(portal.OtherCellId);
if (otherCell?.CellBSP?.Root is null)
if (otherCell is null ||
!CollisionTraversal.HasCellContainment(cache, otherCell))
{
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
{
@ -398,8 +403,11 @@ public static class CellTransit
{
var sphere = worldSpheres[i];
var localCenter = Vector3.Transform(sphere.Origin, otherCell.InverseWorldTransform);
inside = BSPQuery.SphereIntersectsCellBsp(
otherCell.CellBSP.Root, localCenter, sphere.Radius);
inside = CollisionTraversal.SphereIntersectsCell(
cache,
otherCell,
localCenter,
sphere.Radius);
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
{
@ -599,19 +607,25 @@ public static class CellTransit
if (start is null) return 0u;
// this->point_in_cell(point) → return this (:311402-311405)
if (PointInCell(start, worldPoint)) return startCellId;
if (PointInCell(cache, start, worldPoint)) return startCellId;
if (useStabList)
{
// arg3 != 0 → iterate stab_list, GetVisible + point_in_cell (:311444-311465)
foreach (uint id in start.VisibleCellIds)
if (PointInCell(cache.GetCellStruct(id), worldPoint)) return id;
if (PointInCell(cache, cache.GetCellStruct(id), worldPoint)) return id;
}
else
{
// arg3 == 0 → iterate direct portals, GetOtherCell + point_in_cell (:311411-311434)
foreach (var portal in start.Portals)
if (PointInCell(cache.GetCellStruct(portal.OtherCellId), worldPoint)) return portal.OtherCellId;
if (PointInCell(
cache,
cache.GetCellStruct(portal.OtherCellId),
worldPoint))
{
return portal.OtherCellId;
}
}
return 0u;
@ -623,11 +637,19 @@ public static class CellTransit
/// A cell with no hydrated <see cref="CellPhysics.CellBSP"/> returns false (see
/// <see cref="FindVisibleChildCell"/>'s adaptation note).
/// </summary>
private static bool PointInCell(CellPhysics? cell, Vector3 worldPoint)
private static bool PointInCell(
PhysicsDataCache cache,
CellPhysics? cell,
Vector3 worldPoint)
{
if (cell?.CellBSP?.Root is null) return false;
if (cell is null ||
!CollisionTraversal.HasCellContainment(cache, cell))
{
return false;
}
var local = Vector3.Transform(worldPoint, cell.InverseWorldTransform);
return BSPQuery.PointInsideCellBsp(cell.CellBSP.Root, local);
return CollisionTraversal.PointInsideCell(cache, cell, local);
}
/// <summary>
@ -901,9 +923,14 @@ public static class CellTransit
{
// Interior candidate — point_in_cell via the cell BSP (vtable[0x84]).
var cand = cache.GetCellStruct(candId);
if (cand?.CellBSP?.Root is null) continue;
if (cand is null ||
!CollisionTraversal.HasCellContainment(cache, cand))
{
continue;
}
var local = Vector3.Transform(worldSphereCenter, cand.InverseWorldTransform);
if (BSPQuery.PointInsideCellBsp(cand.CellBSP.Root, local))
if (CollisionTraversal.PointInsideCell(cache, cand, local))
return candId; // interior-wins, stop (pseudo_c:308819)
}
else if (outdoorResult == 0u && containingOutdoorId != 0u && outdoorPickAllowed)
@ -950,10 +977,15 @@ public static class CellTransit
if (currentLow >= 0x0100u)
{
var cur = cache.GetCellStruct(currentCellId);
if (cur?.CellBSP?.Root is not null)
if (cur is not null &&
CollisionTraversal.HasCellContainment(cache, cur))
{
var curLocal = Vector3.Transform(worldSphereCenter, cur.InverseWorldTransform);
if (!BSPQuery.SphereIntersectsCellBsp(cur.CellBSP.Root, curLocal, sphereRadius))
if (!CollisionTraversal.SphereIntersectsCell(
cache,
cur,
curLocal,
sphereRadius))
{
uint recovered = FindVisibleChildCell(
cache, currentCellId, worldSphereCenter, useStabList: true);

View file

@ -0,0 +1,226 @@
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Temporary Slice I referee mode. Graph remains production-authoritative;
/// Flat is enabled only by differential tests until dual publication and the
/// connected shadow gate complete.
/// </summary>
internal enum CollisionTraversalMode
{
Graph,
Flat,
}
/// <summary>
/// Narrow representation switch used while the graph and flat collision
/// worlds coexist. I6 removes the graph branch after acceptance.
/// </summary>
internal static class CollisionTraversal
{
internal static bool HasCellContainment(
PhysicsDataCache cache,
CellPhysics cell)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
{
FlatCellContainmentBsp flat = cell.FlatContainmentBsp ??
throw MissingFlat("cell containment");
return flat.RootIndex >= 0;
}
return cell.CellBSP?.Root is not null;
}
internal static bool HasPhysics(
PhysicsDataCache cache,
CellPhysics cell)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
{
FlatPhysicsBsp flat = cell.FlatPhysicsBsp ??
throw MissingFlat("cell physics");
return flat.RootIndex >= 0;
}
return cell.BSP?.Root is not null;
}
internal static bool HasPhysics(
PhysicsDataCache cache,
GfxObjPhysics gfxObject)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
{
FlatPhysicsBsp flat = gfxObject.FlatPhysicsBsp ??
throw MissingFlat("GfxObj physics");
return flat.RootIndex >= 0;
}
return gfxObject.BSP.Root is not null;
}
internal static FlatCollisionSphere RootBoundingSphere(
PhysicsDataCache cache,
CellPhysics cell)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
{
FlatPhysicsBsp flat = cell.FlatPhysicsBsp ??
throw MissingFlat("cell physics");
return flat.Nodes[flat.RootIndex].BoundingSphere;
}
DatReaderWriter.Types.Sphere sphere = cell.BSP!.Root!.BoundingSphere;
return new FlatCollisionSphere(sphere.Origin, sphere.Radius);
}
internal static bool PointInsideCell(
PhysicsDataCache cache,
CellPhysics cell,
Vector3 localPoint)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
{
FlatCellContainmentBsp flat = cell.FlatContainmentBsp ??
throw MissingFlat("cell containment");
return FlatBspQuery.PointInsideCellBsp(flat, localPoint);
}
return BSPQuery.PointInsideCellBsp(cell.CellBSP?.Root, localPoint);
}
internal static bool SphereIntersectsCell(
PhysicsDataCache cache,
CellPhysics cell,
Vector3 localCenter,
float radius)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
{
FlatCellContainmentBsp flat = cell.FlatContainmentBsp ??
throw MissingFlat("cell containment");
return FlatBspQuery.SphereIntersectsCellBsp(
flat,
localCenter,
radius);
}
return BSPQuery.SphereIntersectsCellBsp(
cell.CellBSP?.Root,
localCenter,
radius);
}
internal static TransitionState FindCollisions(
PhysicsDataCache cache,
CellPhysics cell,
Transition transition,
Vector3 localSphereCenter,
float localSphereRadius,
bool hasLocalSphere1,
Vector3 localSphere1Center,
float localSphere1Radius,
Vector3 localCurrentCenter,
Vector3 localSpaceZ,
float scale,
Quaternion localToWorld,
PhysicsEngine? engine,
Vector3 worldOrigin)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
{
FlatPhysicsBsp flat = cell.FlatPhysicsBsp ??
throw MissingFlat("cell physics");
return FlatBspQuery.FindCollisions(
flat,
transition,
localSphereCenter,
localSphereRadius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrentCenter,
localSpaceZ,
scale,
localToWorld,
engine,
worldOrigin);
}
return BSPQuery.FindCollisions(
cell.BSP?.Root,
cell.Resolved,
transition,
localSphereCenter,
localSphereRadius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrentCenter,
localSpaceZ,
scale,
localToWorld,
engine,
worldOrigin);
}
internal static TransitionState FindCollisions(
PhysicsDataCache cache,
GfxObjPhysics gfxObject,
Transition transition,
Vector3 localSphereCenter,
float localSphereRadius,
bool hasLocalSphere1,
Vector3 localSphere1Center,
float localSphere1Radius,
Vector3 localCurrentCenter,
Vector3 localSpaceZ,
float scale,
Quaternion localToWorld,
PhysicsEngine? engine,
Vector3 worldOrigin)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
{
FlatPhysicsBsp flat = gfxObject.FlatPhysicsBsp ??
throw MissingFlat("GfxObj physics");
return FlatBspQuery.FindCollisions(
flat,
transition,
localSphereCenter,
localSphereRadius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrentCenter,
localSpaceZ,
scale,
localToWorld,
engine,
worldOrigin);
}
return BSPQuery.FindCollisions(
gfxObject.BSP.Root,
gfxObject.Resolved,
transition,
localSphereCenter,
localSphereRadius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrentCenter,
localSpaceZ,
scale,
localToWorld,
engine,
worldOrigin);
}
private static InvalidOperationException MissingFlat(string kind) =>
new(
$"Flat collision traversal requires a prepared {kind} asset. " +
"Gameplay must not fall back silently.");
}

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,14 @@ public sealed class PhysicsDataCache
private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new();
private readonly ConcurrentDictionary<uint, CellPhysics> _cellStruct = new();
/// <summary>
/// Slice I graph/flat differential selector. Production remains on the
/// graph oracle until I6; tests use the flat value to run the same complete
/// resolver from cloned inputs.
/// </summary>
internal CollisionTraversalMode CollisionTraversalMode { get; set; } =
CollisionTraversalMode.Graph;
// ── Phase 2: building portal cache for outdoor→indoor entry ───────────
private readonly ConcurrentDictionary<uint, BuildingPhysics> _buildings = new();
@ -541,6 +549,12 @@ public sealed class GfxObjPhysics
/// Populated once at cache time so BSP queries don't pay per-test lookup cost.
/// </summary>
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
/// <summary>
/// Prepared integer-indexed shadow representation. Optional until Slice I5
/// dual publication is complete.
/// </summary>
public FlatPhysicsBsp? FlatPhysicsBsp { get; init; }
}
/// <summary>Cached collision shape data for a Setup (character/creature capsule).</summary>
@ -578,6 +592,11 @@ public sealed class CellPhysics
/// </summary>
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
/// <summary>
/// Prepared integer-indexed physics BSP shadow. Optional until Slice I5.
/// </summary>
public FlatPhysicsBsp? FlatPhysicsBsp { get; init; }
// ── Indoor walking Phase 2 (2026-05-19): portal-graph fields ───────
/// <summary>
@ -590,6 +609,12 @@ public sealed class CellPhysics
/// </summary>
public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; }
/// <summary>
/// Prepared integer-indexed cell-containment BSP shadow. Optional until
/// Slice I5.
/// </summary>
public FlatCellContainmentBsp? FlatContainmentBsp { get; init; }
/// <summary>
/// Portal connections to neighbouring cells, in cell-local space.
/// Default: empty list. Source: <c>envCell.CellPortals</c>.

View file

@ -2298,7 +2298,11 @@ public sealed class Transition
var cell = engine.DataCache.GetCellStruct(cellId);
// R2 guard: stale CellPhysics loaded for render but not physics.
if (cell?.BSP?.Root is null) continue;
if (cell is null ||
!CollisionTraversal.HasPhysics(engine.DataCache, cell))
{
continue;
}
// Transform sphere into THIS cell's local space. Mirrors the
// primary-cell pattern at TransitionTypes.cs (FindEnvCollisions,
@ -2330,8 +2334,8 @@ public sealed class Transition
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
PhysicsDiagnostics.LastBspHitPoly = null;
var result = BSPQuery.FindCollisions(
cell.BSP.Root, cell.Resolved, this,
var result = CollisionTraversal.FindCollisions(
engine.DataCache!, cell, this,
localCenter,
sphereRadius,
hasLocalSphere1,
@ -2348,7 +2352,10 @@ public sealed class Transition
? "poly=n/a"
: System.FormattableString.Invariant(
$"poly=0x{hit.Id:X4} n=({hit.Plane.Normal.X:F3},{hit.Plane.Normal.Y:F3},{hit.Plane.Normal.Z:F3}) d={hit.Plane.D:F3} sides={hit.SidesType}");
var bs = cell.BSP.Root.BoundingSphere;
FlatCollisionSphere bs =
CollisionTraversal.RootBoundingSphere(
engine.DataCache,
cell);
string bsDesc = System.FormattableString.Invariant(
$"bs=({bs.Origin.X:F3},{bs.Origin.Y:F3},{bs.Origin.Z:F3}) br={bs.Radius:F3}");
Console.WriteLine(System.FormattableString.Invariant(
@ -2620,7 +2627,8 @@ public sealed class Transition
if (cellLow >= 0x0100 && engine.DataCache is not null)
{
var cellPhysics = engine.DataCache.GetCellStruct(sp.CheckCellId);
if (cellPhysics?.BSP?.Root is not null)
if (cellPhysics is not null &&
CollisionTraversal.HasPhysics(engine.DataCache!, cellPhysics))
{
// Transform player sphere to cell-local space.
var localCenter = Vector3.Transform(footCenter, cellPhysics.InverseWorldTransform);
@ -2677,9 +2685,9 @@ public sealed class Transition
cellOrigin = cellPhysics.WorldTransform.Translation;
}
var cellState = BSPQuery.FindCollisions(
cellPhysics.BSP.Root,
cellPhysics.Resolved,
var cellState = CollisionTraversal.FindCollisions(
engine.DataCache!,
cellPhysics,
this,
localCenter,
sphereRadius,
@ -3134,12 +3142,14 @@ public sealed class Transition
{
Vector3 dxy = obj.Position - sp.GlobalCurrCenter[0].Origin;
float distXY = MathF.Sqrt(dxy.X * dxy.X + dxy.Y * dxy.Y);
bool cacheHit = physics?.BSP?.Root is not null;
bool cacheHit = physics is not null &&
CollisionTraversal.HasPhysics(engine.DataCache!, physics);
Console.WriteLine(System.FormattableString.Invariant(
$"[bsp-test] obj=0x{obj.EntityId:X8} gfx=0x{obj.GfxObjId:X8} state=0x{obj.State:X8} radius={obj.Radius:F3} pos=({obj.Position.X:F2},{obj.Position.Y:F2},{obj.Position.Z:F2}) distXY={distXY:F3} cacheHit={cacheHit}"));
}
if (physics?.BSP?.Root is null)
if (physics is null ||
!CollisionTraversal.HasPhysics(engine.DataCache!, physics))
{
// Clear obstruction_ethereal before skipping — retail's per-object
// clear (pc:276989) fires after shape tests; we clear early here to
@ -3179,9 +3189,9 @@ public sealed class Transition
// Use the retail 6-path dispatcher with pre-resolved polygons.
// Pass the object's scale so collision response offsets (in
// unscaled local space) are multiplied back to world space.
result = BSPQuery.FindCollisions(
physics.BSP.Root,
physics.Resolved,
result = CollisionTraversal.FindCollisions(
engine.DataCache!,
physics,
this,
localSphere0Center,
localSphere0Radius,
@ -3476,7 +3486,11 @@ public sealed class Transition
// CacheBuilding site resolves 0x02 Setup models to their first part
// — retail tests part_array->parts[0] only, 0x006b5320).
var physics = engine.DataCache.GetGfxObj(building.ModelId);
if (physics?.BSP?.Root is null) return TransitionState.OK;
if (physics is null ||
!CollisionTraversal.HasPhysics(engine.DataCache, physics))
{
return TransitionState.OK;
}
var sp = SpherePath;
var ci = CollisionInfo;
@ -3509,9 +3523,9 @@ public sealed class Transition
TransitionState result;
try
{
result = BSPQuery.FindCollisions(
physics.BSP.Root,
physics.Resolved,
result = CollisionTraversal.FindCollisions(
engine.DataCache!,
physics,
this,
localSphere0Center,
localSphere0Radius,

File diff suppressed because it is too large Load diff