feat(physics): S2 chunk 1b - callers supply the visual part array; installed-DAT comparator

Every production registration now hands ShadowObjectRegistry the object's
whole visual part array beside its collision dispatch: static publication
(App LandblockPhysicsPublisher and the headless Content twin) through
ShadowShapeBuilder.FromStaticRenderParts, live entities (Runtime
LiveEntityCollisionBuilder) through the new FromSetupRenderParts, which walks
every Setup part with the same physics-sphere-else-drawing-sphere and
part-box rule from the PhysicsDataCache Runtime already reaches. Nothing
consumes the retail products yet; the App hermetic lane still passes
6,758/6,758.

The Lane=InstalledDat comparator registers five real fixtures through the
real publication inputs and prints retail CELLARRAY, collision cells, and
the old render cells side by side: Facility Hub stair Setup 0x02000623
(7 cells incl. 0x8A02015F/015E), cathedral ramp 0x020009A2 (3 cells, the
genuine multi-part case), the #334 Neftet formation (25 cells), and a
landblock-edge crosser (6 cells, 2 in the neighbor block). All three
answers agree for BSP-bearing objects, as the shared primitive predicts;
the divergence chunk 3 expects appears only for decorative non-BSP parts.

Core Physics 2,202/2,202; Runtime 1,884/1,884; App hermetic 6,758/6,758;
comparator + stair pin 5/5; solution Release build 0 warnings / 0 errors.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-02 21:52:35 +02:00
parent 059b8883ab
commit 5a2792d689
7 changed files with 908 additions and 11 deletions

View file

@ -638,7 +638,7 @@ Update immediately when a slice changes state. Chat is not the ledger.
| v1 OH1 | SUPERSEDED | research contracts committed at `5cd4fd2c`/`5d907ae9` (kept, binding) | T0T3 evidence grammar parked on `quarantine/oh1-evidence-grammar-2026-09-02` | reviews found the grammar a false oracle three times | n/a | n/a | see §15 |
| S1 Geometry | **CLOSED — G1 PASSED 2026-09-02** | `oh2-cellstruct-surface-contract.md` (§3.5 arbitrated on the binary) | chunk A `acf17246`; chunk B `b681717c`; hygiene `0840d5fb`; review fixes `e2543d0e`; G1 regression fix `8c6563ca` (segment/batch pairing) + its pin test (next commit) | lead verified both chunks against the named pseudo-C and the paired binary; two Opus lens reviews, 8 findings all verified by the lead and fixed; the review round's own upload-order change desynchronized cell-shell index segments from batches (first G1 launch: magenta/stretched/missing walls in every dungeon) and was root-caused and fixed by the lead before the re-gate | build 0/0; Content 213/213 (incl. PreparedPackage on the recipe-8 pak); Core 4,948/4,948 (one load-sensitive allocation test outside S1 passes alone); Bake 21/21; Launcher.Core 365/365; Runtime 1,884/1,884; App hermetic 6,757/6,757 | **G1 PASS**: Facility Hub stairs/doorways, Holtburg house interior, ordinary dungeon, town portal network all whole with correct materials on `8c6563ca` + recipe 8; cathedral shows the ORIGINAL symptom family (neighbor-cell walls drawn through portals over the room), explicitly outside G1 and carried to S3/S4 | dev pak recipe 8 (597,369,112 B; `acdream.recipe7.pak` kept); TOC delta explained; corpus scan old-only 3,197 (all untextured), new-only 0, unexplained 0 |
| Capture | READY | §7 + `tools/walk-oracle/oh/README.md` | scripts committed `ebaa41df` + `14d8fe64`; lead resolved DBObj::m_DID=+0x28 and the cdecl alpha-function conventions; recon attach re-confirms | — | recon dt/x/uf dumps at session start | owner session ~1 h | before S3 |
| S2 World graph | IN PROGRESS (planning: ownership map research running) | `oh1-construction-landscape-contract.md` | — | — | — | G2 | seeds in `b3b7d922` |
| S2 World graph | IN PROGRESS | `s2-membership-ownership-map.md` (plan packet, `e7ad25a7`) + `oh1-construction-landscape-contract.md` Contracts A/B | chunk 1a `059b8883` (Core: retail CELLARRAY + per-part entries beside the collision flood, zero behavior change); chunk 1b (App/Runtime callers + installed-DAT comparator) in progress | lead corrected 1a's route decision (cylsphere route reads the Setup's collision cylspheres, not the visual part array) before landing | Core Physics 2,202/2,202 after 1a | G2 pending | seeds in `b3b7d922` preserved; topology triplication deferred to S3 |
| S3 Walk | PLANNED | built-mesh/world contracts + captures | — | — | — | folded into G3 | |
| S4 Depth + alpha | PLANNED | depth/alpha contracts + captures | — | — | — | G3 | retires AP-34 |
| S5 Consumers + closeout | PLANNED | landscape contract; AP-232 | — | — | — | G4 | retires AP-117/AP-232; deletes probes |

View file

@ -111,3 +111,116 @@ FacilityStairAssembly_RegisterAcross015FTo015EWithoutCollisionRows`, the
and `ShadowShapeBuilder*`, `LiveEntityCollisionBuilderTests`,
`LandblockPhysicsPublisherTests`, `Issue334*`, the flood replay fixtures, and
the Runtime placement/physics suites (full list in the 2026-09-02 trace).
## 5. Chunk 1 evidence (2026-09-02, chunk 1b)
Chunk 1b threaded the whole visual part array through every production
`RegisterMultiPart`/`ReplaceMultiPartPayload` caller as a zero-pixel-change
side product:
- `LandblockPhysicsPublisher.PublishStaticEntity` — builds
`ShadowShapeBuilder.FromStaticRenderParts(entity.MeshRefs,
publication.StagingCache.GetGfxObj, publication.StagingCache.GetVisualBounds,
out _)` once per static and passes it as `partArray:` to BOTH the
BSP-branch and the Setup cylinder/sphere fallback-branch `RegisterMultiPart`
calls. The visual-bounds source is `publication.StagingCache`, the SAME
`PhysicsDataCache` `WalkProductionWorldData.ResolveStaticRenderCells`
already reads (`cache.GetGfxObj`/`cache.GetVisualBounds`) — no new source
was threaded, none was needed.
- `LandblockPhysicsContentBuilder.PublishStaticCollision` (the no-window/
Headless twin, driven from `HeadlessSessionWorldProjection`) — same
treatment, same `cache.GetGfxObj`/`cache.GetVisualBounds` pair.
- `LiveEntityCollisionBuilder` — added `ShadowShapeBuilder.FromSetupRenderParts`
(Core, mirrors `FromStaticRenderParts`'s per-part dispatch but walks a
Setup's parts directly instead of `MeshRef`s, since a live entity has no
meshRef list). `LiveEntityCollisionRegistration` gained a `RenderParts`
field; `Build()` computes it via two new resolvers
(`Func<uint, GfxObjPhysics?> _getGfxObj`, `Func<uint, GfxObjVisualBounds?>
_getVisualBounds`) that the public constructor wires to
`PhysicsDataCache.GetGfxObj`/`GetVisualBounds` — the identical resolver
SHAPE `FromStaticRenderParts` takes, so a static and a live Setup sharing a
GfxObj id read the same render geometry. Both new constructor parameters
default to a no-op resolver, so every existing 2-arg test fixture keeps its
exact prior behavior (`RenderParts` resolves to an empty list, entering
`RecomputeRetailCellArray`'s `partArray.Count == 0` early return — a no-op
on every dictionary chunk 1a introduced). `Register`/`ReconcileAppearance`
pass `partArray: registration.RenderParts` into `RegisterMultiPart`/
`ReplaceMultiPartPayload`.
- No other production caller exists: a full-source grep for
`.RegisterMultiPart(`/`.ReplaceMultiPartPayload(` after this change finds
exactly these four call sites (App publisher ×2 branches, Content builder
×2 branches, Runtime builder ×2 methods) plus the four in this list — every
one now supplies `partArray:`.
- Zero-behavior-change proof: App.Tests filtered to the non-InstalledDat
lanes still passes exactly 6,758/6,758 (unchanged count), Core.Tests
Physics 2,202/2,202, Runtime.Tests 1,884/1,884 — chunk 1b changed no
observable production output.
### Installed-DAT comparator results
`tests/AcDream.App.Tests/Physics/RetailCellArrayComparatorInstalledDatTests.cs`
registers each fixture through the real production dispatch
(`ShadowShapeBuilder.FromLandblockBspParts` first, else the Setup
CylSphere/Sphere fallback — `LandblockPhysicsPublisher.PublishStaticEntity`'s
exact branch order — plus the `FromStaticRenderParts` part array) against a
registry backed by the real `PhysicsDataCache`, then reads back retail's
CELLARRAY (`TryGetRetailCellArray`), today's COLLISION cells
(`GetOwnerCells`), and today's OLD RENDER cells (`ComputeStaticRenderCells`
over the identical part array and seed).
| Fixture | Route | Parts | Retail cells | Collision cells | Old-render cells | Retail == old-render |
|---|---|---|---|---|---|---|
| 1: Facility Hub stair Setup `0x02000623` (parent `0x8A02015F`) | BoundingBox | 1 (single wide part, NOT multi-part — see below) | 7: `0x8A02015F,0x8A0201C1,0x8A02015E,0x8A0201C0,0x8A0201C7,0x8A020141,0x8A020163` | identical | identical | yes (exact set) |
| 2: Cathedral ramp Setup `0x020009A2` (resolved parent `0xF4180112`) | BoundingBox | 7 (`0x01001FE8` + six `0x01001FE6` slabs) | 3: `0xF4180112,0xF4180113,0xF4180009` | identical | identical | yes (exact set) |
| 3: #334 Neftet formation GfxObj `0x010046D8` (landblock `0x8764`) | BoundingBox | 1 | 25 (5×5 rectangle about landcell (2,2), entirely within `0x8764`) | identical | identical | yes (exact set) |
| 4: outdoor landblock-edge crosser, GfxObj `0x010046EB` (landblock `0x8764`, origin (133.38, 183.52)) | BoundingBox | 1 | 6: `0x87640030,0x8764002F,0x87650029,0x87640037,0x87640038,0x87650031` — 2 of 6 in neighbor landblock `0x8765` | identical | identical | yes (exact set) |
| 5: multi-part Setup, parts in different cells | — | — | — see note | — | — | — |
Every fixture reports retail == collision == old-render exactly (same
ordered set), zero mismatches. This is a structural consequence of chunk 1a's
route logic, not incidental: every one of these statics is BSP-bearing
(`hasPhysicsBsp=True`), so `RecomputeRetailCellArray`'s dispatch always takes
the `BoundingBox` branch (`CPhysicsObj::find_bbox_cell_list` over the SAME
part array, same seed, same `PhysicsDataCache` — the identical primitive
`ComputeStaticRenderCells` already calls). A retail-vs-collision difference
never appeared for these five fixtures either — the collision dispatch for a
BSP-bearing object also flows through the box-walk over the same shapes, so
there is no decorative-vs-collision part split to observe here (that
divergence needs a fixture with a non-colliding decorative part alongside a
colliding one — none of the fixtures required by the plan packet happen to
be that shape; it remains open evidence for chunk 3, not a gap in this
chunk's coverage).
Per-cell part-entry counts (`GetRetailPartEntriesInCell`) confirm retail's
CELLARRAY-then-part-array insertion order: fixture 1's single part appears
once in each of its 7 cells (7 entries total); fixture 2's 7 parts each
appear in each of its 3 cells (21 entries total — every part registers in
every cell of the entity's CELLARRAY, not a per-part cell subset, matching
`CPartArray::AddPartsShadow`'s exact semantics); fixtures 3 and 4's single
part appears once per cell.
**Fixture-1 correction against the plan packet's assumption:** Facility Hub
stair Setup `0x02000623` resolves via `SetupMesh.Flatten` to exactly ONE
visual part, not several — its own wide vertex-array box spans all 7 crossed
cells. It is a wide SINGLE-part static. The genuinely multi-part case (item
5, "parts in different cells") is fixture 2, the Cathedral ramp: 7 real
parts, whose combined CELLARRAY spans 3 cells, with every part entered in
every one of those 3 cells per retail's `AddPartsShadow` semantics — "parts
in different cells" is best read as "the entity's CELLARRAY spans multiple
cells," not "each part is confined to its own cell" (retail's render index
has no such per-part cell subsetting; S3 owns per-part draw admission
separately). No installed-DAT fixture with genuinely per-part-confined
membership was found among the test files this chunk was pointed at.
**Fixture-4 note:** the first candidate a bounding-SPHERE-based edge scan
picked was fixture 3's own Neftet formation GfxObj `0x010046D8` (69 m sphere
radius crosses the landblock's 0 m edge at its 63.78 m origin) — but the
outdoor flood's governing geometry is the vertex-array BOX
(`CLandCell::add_all_outside_cells` divides the BOX by `square_length`, #334),
and that object's real 96 m box (48 m half-width) does not reach the edge
from its centered position. The scan was corrected to key on
`GfxObjPhysics.VisualBounds` (the box) instead, and to explicitly exclude
`0x010046D8` so fixture 4 stays independent of fixture 3; it now resolves to
a different Neftet-landblock GfxObj (`0x010046EB`) genuinely near the block's
Y=192 edge, producing 2 of 6 retail cells in the neighboring landblock
`0x8765`.

View file

@ -953,6 +953,20 @@ public sealed class LandblockPhysicsPublisher
entity.MeshRefs,
entity.IsBuildingShell,
publication.StagingCache.GetGfxObj);
// Campaign OVERHAUL S2 chunk 1b: retail's WHOLE visual part array —
// every visual part of this static, colliding or not — beside the
// BSP-only/primitive-only COLLISION dispatch above and below. The
// SAME resolver pair WalkProductionWorldData.ResolveStaticRenderCells
// already reads off this landblock's PhysicsDataCache. A side
// product only: no consumer reads it yet.
IReadOnlyList<ShadowShape> partArray =
ShadowShapeBuilder.FromStaticRenderParts(
entity.MeshRefs,
publication.StagingCache.GetGfxObj,
publication.StagingCache.GetVisualBounds,
out _);
entityBspCount = bspShapes.Count;
if (entityBspCount > 0)
{
@ -967,7 +981,8 @@ public sealed class LandblockPhysicsPublisher
publication.Origin.Y,
landblock.LandblockId,
seedCellId: entity.ParentCellId ?? 0u,
isStatic: true);
isStatic: true,
partArray: partArray);
LogMultipartRegistration(landblock, entity, bspShapes);
}
@ -1060,7 +1075,8 @@ public sealed class LandblockPhysicsPublisher
publication.Origin.Y,
landblock.LandblockId,
seedCellId: entity.ParentCellId ?? 0u,
isStatic: true);
isStatic: true,
partArray: partArray);
LogSetupRegistration(landblock, entity, setupShapes);
entityCylinderCount = setupShapes.Count;
}

View file

@ -614,6 +614,18 @@ public static class LandblockPhysicsContentBuilder
entity.MeshRefs,
entity.IsBuildingShell,
cache.GetGfxObj);
// Campaign OVERHAUL S2 chunk 1b: retail's WHOLE visual part
// array, mirroring LandblockPhysicsPublisher.PublishStaticEntity
// exactly — same PhysicsDataCache resolver pair, same
// FromStaticRenderParts primitive. A side product only.
IReadOnlyList<ShadowShape> partArray =
ShadowShapeBuilder.FromStaticRenderParts(
entity.MeshRefs,
cache.GetGfxObj,
cache.GetVisualBounds,
out _);
if (bspShapes.Count > 0)
{
engine.ShadowObjects.RegisterMultiPart(
@ -627,7 +639,8 @@ public static class LandblockPhysicsContentBuilder
worldOffsetY: origin.Y,
landblockId: landblock.LandblockId,
seedCellId: entity.ParentCellId ?? 0u,
isStatic: true);
isStatic: true,
partArray: partArray);
bspOwners++;
continue;
}
@ -711,7 +724,8 @@ public static class LandblockPhysicsContentBuilder
worldOffsetY: origin.Y,
landblockId: landblock.LandblockId,
seedCellId: entity.ParentCellId ?? 0u,
isStatic: true);
isStatic: true,
partArray: partArray);
setupOwners++;
}

View file

@ -429,6 +429,110 @@ public static class ShadowShapeBuilder
return parts;
}
/// <summary>
/// Resolves EVERY part of a live entity's <see cref="Setup"/> into the
/// paired sphere + vertex-box geometry used by retail's render-shadow
/// cell registration — the live-entity analog of
/// <see cref="FromStaticRenderParts"/>. Unlike <see cref="FromSetup"/>'s
/// step 3 (which emits a BSP shape only for a part whose GfxObj carries a
/// physics BSP), this walks every Setup part unconditionally:
/// <c>CEnvCell::init_static_objects</c>/<c>CPartArray::AddPartsShadow</c>
/// registers every visual part in each crossed cell even when that part
/// cannot collide, and a live Setup is no exception.
///
/// <para>The returned values are geometry carriers only. Callers must not
/// publish them to the collision registry.</para>
/// </summary>
/// <param name="setup">The live entity's Setup.</param>
/// <param name="entScale">The entity's spawn scale, applied uniformly —
/// same composition as <see cref="FromSetup"/>.</param>
/// <param name="effectivePartGfxObjIds">Current part identities after
/// retail <c>AnimPartChanged</c> processing (see <see cref="FromSetup"/>).
/// Null or short lists fall back to the Setup identity.</param>
/// <param name="partPoseOverride">Pose priority 1: the motion-table
/// default-state pose (#175). Falls back to the Setup's own placement
/// frame, then identity — the same priority <see cref="FromSetup"/>'s
/// step 3 uses.</param>
/// <param name="getGfxObj">Resolves a GfxObj id to its cached physics
/// (BSP + bounding sphere + visual bounds). Production:
/// <c>id =&gt; physicsData.GetGfxObj(id)</c> — the SAME resolver shape
/// <see cref="FromStaticRenderParts"/> takes, so a static and a live
/// Setup sharing a GfxObj id can never disagree about its render
/// geometry.</param>
/// <param name="getVisualBounds">Fallback drawing-sphere/AABB source for
/// a part whose GfxObj carries no physics BSP. Production:
/// <c>id =&gt; physicsData.GetVisualBounds(id)</c>.</param>
public static List<ShadowShape> FromSetupRenderParts(
Setup setup,
float entScale,
IReadOnlyList<uint>? effectivePartGfxObjIds,
IReadOnlyList<Frame>? partPoseOverride,
Func<uint, GfxObjPhysics?> getGfxObj,
Func<uint, GfxObjVisualBounds?> getVisualBounds)
{
ArgumentNullException.ThrowIfNull(setup);
ArgumentNullException.ThrowIfNull(getGfxObj);
ArgumentNullException.ThrowIfNull(getVisualBounds);
var parts = new List<ShadowShape>(setup.Parts.Count);
AnimationFrame? placementFrame = ResolvePlacementFrame(setup);
for (int i = 0; i < setup.Parts.Count; i++)
{
uint gfxId = EffectivePartGfxObjId(setup, effectivePartGfxObjIds, i);
GfxObjPhysics? physics = getGfxObj(gfxId);
bool partHasPhysicsBsp = physics?.FlatPhysicsBsp is { RootIndex: >= 0 }
|| physics?.BSP?.Root is not null;
FlatGfxObjVisualBounds? flatBounds = physics?.VisualBounds;
if (flatBounds is null && getVisualBounds(gfxId) is { } visual)
{
flatBounds = new FlatGfxObjVisualBounds(
visual.Min,
visual.Max,
visual.Center,
visual.Radius,
visual.HalfExtents);
}
if (flatBounds is not { } bounds)
continue;
Frame partFrame;
if (partPoseOverride is not null && i < partPoseOverride.Count)
partFrame = partPoseOverride[i];
else if (placementFrame is not null && i < placementFrame.Frames.Count)
partFrame = placementFrame.Frames[i];
else
partFrame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
FlatCollisionSphere sphere;
if (partHasPhysicsBsp)
{
FlatPhysicsBsp? flat = physics!.FlatPhysicsBsp;
sphere = flat is { RootIndex: >= 0 }
? flat.Nodes[flat.RootIndex].BoundingSphere
: new FlatCollisionSphere(
physics.BoundingSphere?.Origin ?? bounds.Center,
physics.BoundingSphere?.Radius ?? bounds.Radius);
}
else
{
// Retail falls back from physics_sphere to the GfxObj drawing
// sphere — same fallback as FromStaticRenderParts.
sphere = new FlatCollisionSphere(bounds.Center, bounds.Radius);
}
parts.Add(ShadowShape.Bsp(
gfxId,
new Vector3(partFrame.Origin.X, partFrame.Origin.Y, partFrame.Origin.Z) * entScale,
partFrame.Orientation,
entScale,
ShadowPartGeometry.Create(sphere, bounds)));
}
return parts;
}
/// <summary>
/// The collision identity of part <paramref name="index"/>: the installed
/// <c>AnimPartChanged</c> replacement when one was supplied, else the

View file

@ -24,7 +24,14 @@ internal sealed record LiveEntityCollisionRegistration(
float WorldOffsetX,
float WorldOffsetY,
uint LandblockId,
uint SeedCellId);
uint SeedCellId,
// Campaign OVERHAUL S2 chunk 1b: retail's WHOLE visual part array for
// this live entity's Setup (ShadowShapeBuilder.FromSetupRenderParts) —
// every part, colliding or not, physics sphere else drawing sphere,
// exact part box. Distinct from Shapes, which stays the BSP-exclusive
// COLLISION dispatch (AP-152). A side product only: no consumer reads
// it yet.
IReadOnlyList<ShadowShape> RenderParts);
/// <summary>
/// Ports the live-object collision-shape policy used by
@ -72,6 +79,21 @@ internal sealed class LiveEntityCollisionBuilder
private readonly Func<uint, bool> _hasPhysicsBsp;
private readonly LiveEntityDefaultPoseResolver _defaultPose;
/// <summary>
/// Campaign OVERHAUL S2 chunk 1b: the SAME resolver shape
/// <c>WalkProductionWorldData.ResolveStaticRenderCells</c> passes to
/// <see cref="ShadowShapeBuilder.FromStaticRenderParts"/> — a static and a
/// live Setup sharing a GfxObj id can never disagree about its render
/// geometry. Production: <c>PhysicsDataCache.GetGfxObj</c>.
/// </summary>
private readonly Func<uint, GfxObjPhysics?> _getGfxObj;
/// <summary>Fallback drawing-sphere/AABB source, paired with
/// <see cref="_getGfxObj"/> exactly as <see cref="ShadowShapeBuilder.FromStaticRenderParts"/>
/// pairs its own two resolvers. Production:
/// <c>PhysicsDataCache.GetVisualBounds</c>.</summary>
private readonly Func<uint, GfxObjVisualBounds?> _getVisualBounds;
public LiveEntityCollisionBuilder(
PhysicsDataCache physicsData,
LiveEntityDefaultPoseResolver defaultPose)
@ -86,7 +108,9 @@ internal sealed class LiveEntityCollisionBuilder
asset!.VisualBounds)
: (ShadowPartGeometry?)null;
},
defaultPose)
defaultPose,
id => physicsData.GetGfxObj(id),
id => physicsData.GetVisualBounds(id))
{
ArgumentNullException.ThrowIfNull(physicsData);
}
@ -101,13 +125,21 @@ internal sealed class LiveEntityCollisionBuilder
/// walk cannot be left without a box (#334).</param>
internal LiveEntityCollisionBuilder(
Func<uint, ShadowPartGeometry?> physicsBspBounds,
LiveEntityDefaultPoseResolver defaultPose)
LiveEntityDefaultPoseResolver defaultPose,
Func<uint, GfxObjPhysics?>? getGfxObj = null,
Func<uint, GfxObjVisualBounds?>? getVisualBounds = null)
{
_physicsBspBounds = physicsBspBounds
?? throw new ArgumentNullException(nameof(physicsBspBounds));
_hasPhysicsBsp = id => _physicsBspBounds(id) is not null;
_defaultPose = defaultPose
?? throw new ArgumentNullException(nameof(defaultPose));
// Campaign OVERHAUL S2 chunk 1b: absent in every existing fixture
// call site (both positional-arg tests and production before this
// chunk) — resolves to "no render geometry for any part", so
// Build() below retains its exact prior Shapes/registration output.
_getGfxObj = getGfxObj ?? (_ => null);
_getVisualBounds = getVisualBounds ?? (_ => null);
}
/// <summary>
@ -197,6 +229,18 @@ internal sealed class LiveEntityCollisionBuilder
if (spawn.ItemType == (uint)ItemType.Creature)
flags |= EntityCollisionFlags.IsCreature;
// Campaign OVERHAUL S2 chunk 1b: retail's WHOLE visual part array —
// every Setup part, colliding or not — beside the BSP-exclusive
// `shapes` collision dispatch above. A side product only; nothing
// consumes it yet.
IReadOnlyList<ShadowShape> renderParts = ShadowShapeBuilder.FromSetupRenderParts(
setup,
scale,
effectivePartGfxObjIds,
defaultPose,
_getGfxObj,
_getVisualBounds);
return new LiveEntityCollisionRegistration(
entity.Id,
entity.SourceGfxObjOrSetupId,
@ -208,7 +252,8 @@ internal sealed class LiveEntityCollisionBuilder
worldOrigin.X,
worldOrigin.Y,
position.LandblockId,
position.LandblockId);
position.LandblockId,
renderParts);
}
public static void Register(
@ -228,7 +273,8 @@ internal sealed class LiveEntityCollisionBuilder
registration.WorldOffsetY,
registration.LandblockId,
registration.SeedCellId,
isStatic: false);
isStatic: false,
partArray: registration.RenderParts);
if (!PhysicsDiagnostics.ProbeBuildingEnabled)
return;
@ -276,6 +322,7 @@ internal sealed class LiveEntityCollisionBuilder
registration.LandblockId,
registration.SeedCellId,
isStatic: false,
suspendIfNew);
suspendIfNew,
partArray: registration.RenderParts);
}
}

View file

@ -0,0 +1,603 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using AcDream.Core.Meshing;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
using Xunit;
using Xunit.Abstractions;
using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
using DatGfxObj = DatReaderWriter.DBObjs.GfxObj;
using DatSetup = DatReaderWriter.DBObjs.Setup;
namespace AcDream.App.Tests.Physics;
/// <summary>
/// Campaign OVERHAUL S2 chunk 1 installed-DAT comparator
/// (<c>docs/plans/2026-09-01-campaign-overhaul-world-solidity.md</c> §9 S2
/// chunk 1; <c>docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md</c>
/// §3). Chunk 1b threaded the whole visual part array through every
/// production <c>RegisterMultiPart</c> caller
/// (<see cref="AcDream.App.Streaming.LandblockPhysicsPublisher.PublishStaticEntity"/>,
/// its <c>AcDream.Content.LandblockPhysicsContentBuilder.PublishStaticCollision</c>
/// no-window twin, and <c>AcDream.Runtime.Physics.LiveEntityCollisionBuilder</c>)
/// as a zero-pixel-change side product. This suite registers real installed-DAT
/// statics through that SAME dispatch — <see cref="ShadowShapeBuilder.FromLandblockBspParts"/>
/// first, else the Setup CylSphere/Sphere fallback exactly as
/// <c>PublishStaticEntity</c> branches, plus the
/// <see cref="ShadowShapeBuilder.FromStaticRenderParts"/> part-array input —
/// against a registry backed by the real <see cref="PhysicsDataCache"/>, then
/// prints and compares three answers per fixture: retail's CELLARRAY
/// (<see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>), today's
/// COLLISION cells (<see cref="ShadowObjectRegistry.GetOwnerCells"/>), and
/// today's OLD RENDER cells
/// (<see cref="ShadowObjectRegistry.ComputeStaticRenderCells"/> over the
/// SAME part array and seed). Chunk 1's ownership-map claim is that retail
/// and old-render share one primitive for a BSP-routed static, so this
/// asserts that equality; a retail-vs-collision difference is EXPECTED
/// evidence for chunk 3 (decorative, non-colliding parts) and is printed,
/// never asserted.
/// </summary>
[Trait("Lane", "InstalledDat")]
public sealed class RetailCellArrayComparatorInstalledDatTests
{
private const string SkipMessage =
"Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.";
private readonly ITestOutputHelper _out;
public RetailCellArrayComparatorInstalledDatTests(ITestOutputHelper output) => _out = output;
private static string? ResolveDatDir()
{
var fromEnv = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) return fromEnv;
var def = Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(def) ? def : null;
}
// ---------------------------------------------------------------
// Shared comparator machinery
// ---------------------------------------------------------------
private static PhysicsEngine BuildOutdoorEngine(uint landblockId)
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
var heights = new byte[81];
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
engine.AddLandblock(landblockId, new TerrainSurface(heights, heightTable),
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(), 0f, 0f);
return engine;
}
/// <summary>
/// Caches every resolvable EnvCell CellStruct in [<paramref name="lowStart"/>,
/// <paramref name="lowEnd"/>] for <paramref name="landblockId"/> — the
/// same pattern <c>Issue177StairDescentCameraFloodTests.BuildHubEngine</c>
/// uses, generalized to any landblock/range so <see cref="CellTransit"/>
/// can traverse real portal topology for an indoor flood.
/// </summary>
private static PhysicsEngine BuildIndoorEngine(
DatCollection dats, uint landblockId, uint lowStart, uint lowEnd)
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
for (uint low = lowStart; low <= lowEnd; low++)
{
uint id = landblockId | low;
var datCell = dats.Get<DatEnvCell>(id);
if (datCell is null) continue;
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(
0x0D000000u | datCell.EnvironmentId);
if (environment is null) continue;
if (!environment.Cells.TryGetValue(datCell.CellStructure, out var cellStruct)
|| cellStruct is null)
{
continue;
}
var world = Matrix4x4.CreateFromQuaternion(datCell.Position.Orientation)
* Matrix4x4.CreateTranslation(datCell.Position.Origin);
cache.CacheCellStruct(id, datCell, cellStruct, world);
}
var heights = new byte[81];
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
engine.AddLandblock(landblockId, new TerrainSurface(heights, heightTable),
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(), 0f, 0f);
return engine;
}
/// <summary>
/// Verbatim copy of <c>ShadowObjectRegistry.DeriveOutdoorSeed</c> (private
/// there) — needed here so the SAME resolved seed feeds both
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> and the
/// independent <see cref="ShadowObjectRegistry.ComputeStaticRenderCells"/>
/// recompute below (that method requires a non-zero seed directly; it
/// does not derive one).
/// </summary>
private static uint DeriveOutdoorSeedForTest(
Vector3 worldPos, float worldOffsetX, float worldOffsetY, uint landblockId)
{
if (landblockId == 0u) return 0u;
float localX = worldPos.X - worldOffsetX;
float localY = worldPos.Y - worldOffsetY;
int cx = (int)Math.Clamp(localX / 24f, 0f, 7f);
int cy = (int)Math.Clamp(localY / 24f, 0f, 7f);
uint lbPrefix = landblockId & 0xFFFF0000u;
return lbPrefix | (uint)(cx * 8 + cy + 1);
}
private readonly record struct ComparatorOutcome(
string Label,
uint EntityId,
IReadOnlyList<uint> RetailCells,
IReadOnlyList<uint> CollisionCells,
IReadOnlyList<uint> OldRenderCells,
RetailCellArrayRoute Route,
int PartCount,
int BspShapeCount,
bool CollisionRegistered);
/// <summary>
/// Registers one static through the REAL production dispatch —
/// <see cref="ShadowShapeBuilder.FromLandblockBspParts"/> first, else the
/// Setup CylSphere/Sphere fallback, exactly the branch order
/// <c>LandblockPhysicsPublisher.PublishStaticEntity</c> uses — plus the
/// chunk-1b whole-part-array side product
/// (<see cref="ShadowShapeBuilder.FromStaticRenderParts"/>), then reads
/// back and prints all three comparator answers.
/// </summary>
private ComparatorOutcome RegisterAndCompare(
string label,
PhysicsEngine engine,
PhysicsDataCache cache,
uint entityId,
IReadOnlyList<MeshRef> meshRefs,
DatSetup? setupFallback,
Vector3 worldPos,
Quaternion worldRot,
float worldOffsetX,
float worldOffsetY,
uint landblockId,
uint seedCellId)
{
IReadOnlyList<ShadowShape> bspShapes = ShadowShapeBuilder.FromLandblockBspParts(
meshRefs, isBuildingShell: false, cache.GetGfxObj);
IReadOnlyList<ShadowShape> partArray = ShadowShapeBuilder.FromStaticRenderParts(
meshRefs, cache.GetGfxObj, cache.GetVisualBounds, out bool hasPhysicsBsp);
uint resolvedSeed = seedCellId != 0u
? seedCellId
: DeriveOutdoorSeedForTest(worldPos, worldOffsetX, worldOffsetY, landblockId);
bool registered = false;
if (bspShapes.Count > 0)
{
engine.ShadowObjects.RegisterMultiPart(
entityId, worldPos, worldRot, bspShapes, 0u, EntityCollisionFlags.None,
worldOffsetX, worldOffsetY, landblockId,
seedCellId: resolvedSeed, isStatic: true, partArray: partArray);
registered = true;
}
else if (setupFallback is not null)
{
FlatSetupCollision flatSetup = FlatCollisionAssetBuilder.FlattenSetup(setupFallback);
const float scale = 1f;
var setupShapes = new List<ShadowShape>();
for (int i = 0; i < flatSetup.Cylinders.Length; i++)
{
FlatCollisionCylinder cyl = flatSetup.Cylinders[i];
float radius = cyl.Radius * scale;
float baseHeight = cyl.Height > 0f ? cyl.Height : cyl.Radius * 4f;
if (radius <= 0f) continue;
setupShapes.Add(ShadowShape.Cylinder(
gfxObjId: 0u,
localPosition: cyl.Origin * scale,
localRotation: Quaternion.Identity,
scale: scale,
radius: radius,
cylHeight: baseHeight * scale));
}
if (flatSetup.Cylinders.Length == 0)
{
for (int i = 0; i < flatSetup.Spheres.Length; i++)
{
FlatCollisionSphere sph = flatSetup.Spheres[i];
if (sph.Radius <= 0f) continue;
setupShapes.Add(ShadowShape.Sphere(
gfxObjId: 0u,
localPosition: sph.Origin * scale,
localRotation: Quaternion.Identity,
scale: scale,
radius: sph.Radius * scale));
}
}
if (setupShapes.Count > 0)
{
engine.ShadowObjects.RegisterMultiPart(
entityId, worldPos, worldRot, setupShapes, 0u, EntityCollisionFlags.None,
worldOffsetX, worldOffsetY, landblockId,
seedCellId: resolvedSeed, isStatic: true, partArray: partArray);
registered = true;
}
}
IReadOnlyList<uint> oldRenderCells = resolvedSeed != 0u && partArray.Count > 0
? engine.ShadowObjects.ComputeStaticRenderCells(resolvedSeed, worldPos, worldRot, partArray)
: Array.Empty<uint>();
engine.ShadowObjects.TryGetRetailCellArray(entityId, out IReadOnlyList<uint> retailCells);
IReadOnlyList<uint> collisionCells = engine.ShadowObjects.GetOwnerCells(entityId);
RetailCellArrayRoute route = engine.ShadowObjects.GetRetailCellArrayRoute(entityId);
PrintOutcome(
label, entityId, retailCells, collisionCells, oldRenderCells, route,
partArray.Count, bspShapes.Count, hasPhysicsBsp, registered, resolvedSeed);
return new ComparatorOutcome(
label, entityId, retailCells, collisionCells, oldRenderCells, route,
partArray.Count, bspShapes.Count, registered);
}
private void PrintOutcome(
string label,
uint entityId,
IReadOnlyList<uint> retailCells,
IReadOnlyList<uint> collisionCells,
IReadOnlyList<uint> oldRenderCells,
RetailCellArrayRoute route,
int partCount,
int bspShapeCount,
bool hasPhysicsBsp,
bool collisionRegistered,
uint seedCellId)
{
static string CellSet(IEnumerable<uint> ids) =>
"[" + string.Join(",", ids.Select(id => FormattableString.Invariant($"0x{id:X8}"))) + "]";
_out.WriteLine(FormattableString.Invariant(
$"--- {label} (entity=0x{entityId:X8} seed=0x{seedCellId:X8}) ---"));
_out.WriteLine(FormattableString.Invariant(
$" parts={partCount} bspShapes={bspShapeCount} hasPhysicsBsp={hasPhysicsBsp} collisionRegistered={collisionRegistered} route={route}"));
_out.WriteLine($" retail n={retailCells.Count} {CellSet(retailCells)}");
_out.WriteLine($" collision n={collisionCells.Count} {CellSet(collisionCells)}");
_out.WriteLine($" oldRender n={oldRenderCells.Count} {CellSet(oldRenderCells)}");
foreach (uint cellId in retailCells)
{
int entries = 0;
foreach (RetailPartEntry entry in _lastEngine!.ShadowObjects.GetRetailPartEntriesInCell(cellId))
if (entry.EntityId == entityId) entries++;
_out.WriteLine(FormattableString.Invariant(
$" cell 0x{cellId:X8}: {entries} retail part entries"));
}
IEnumerable<uint> retailOnly = retailCells.Except(collisionCells);
IEnumerable<uint> collisionOnly = collisionCells.Except(retailCells);
if (retailOnly.Any() || collisionOnly.Any())
{
_out.WriteLine(
$" retail-vs-collision difference (EXPECTED evidence, not asserted): "
+ $"retailOnly={CellSet(retailOnly)} collisionOnly={CellSet(collisionOnly)}");
}
}
// PrintOutcome needs the engine to look up per-cell entries; stash it for
// the duration of one fixture's call rather than widen every signature.
private PhysicsEngine? _lastEngine;
private ComparatorOutcome RunFixture(
string label,
PhysicsEngine engine,
PhysicsDataCache cache,
uint entityId,
IReadOnlyList<MeshRef> meshRefs,
DatSetup? setupFallback,
Vector3 worldPos,
Quaternion worldRot,
float worldOffsetX,
float worldOffsetY,
uint landblockId,
uint seedCellId)
{
_lastEngine = engine;
return RegisterAndCompare(
label, engine, cache, entityId, meshRefs, setupFallback,
worldPos, worldRot, worldOffsetX, worldOffsetY, landblockId, seedCellId);
}
private static void AssertSameCells(IReadOnlyList<uint> retail, IReadOnlyList<uint> oldRender)
{
Assert.Equal(
oldRender.OrderBy(id => id).ToArray(),
retail.OrderBy(id => id).ToArray());
}
// ---------------------------------------------------------------
// Fixture 1 — Facility Hub stair Setup 0x02000623 (0x8A02015F/0x8A02015E)
// ---------------------------------------------------------------
[Fact]
public void FacilityHubStair_RetailCellArrayMatchesOldRenderCells()
{
string? datDir = ResolveDatDir();
if (datDir is null) Assert.Fail(SkipMessage);
using var dats = new DatCollection(datDir, DatAccessType.Read);
const uint FacilityHub = 0x8A020000u;
const uint setupId = 0x02000623u;
PhysicsEngine engine = BuildIndoorEngine(dats, FacilityHub, 0x0100u, 0x01FFu);
var cache = (PhysicsDataCache)engine.DataCache!;
DatSetup setup = Assert.IsType<DatSetup>(dats.Get<DatSetup>(setupId));
IReadOnlyList<MeshRef> meshRefs = SetupMesh.Flatten(setup);
foreach (MeshRef meshRef in meshRefs)
{
DatGfxObj gfxObj = Assert.IsType<DatGfxObj>(dats.Get<DatGfxObj>(meshRef.GfxObjId));
cache.CacheGfxObj(meshRef.GfxObjId, gfxObj);
}
DatEnvCell parent = Assert.IsType<DatEnvCell>(
dats.Get<DatEnvCell>(FacilityHub | 0x015Fu));
var stair = Assert.Single(parent.StaticObjects, s => s.Id == setupId);
Vector3 worldPos = new(
stair.Frame.Origin.X, stair.Frame.Origin.Y, stair.Frame.Origin.Z);
Quaternion worldRot = stair.Frame.Orientation;
const uint entityId = 0x7F000001u;
ComparatorOutcome outcome = RunFixture(
"1: Facility Hub stair Setup 0x02000623",
engine, cache, entityId, meshRefs, setup,
worldPos, worldRot,
worldOffsetX: 0f, worldOffsetY: 0f,
landblockId: FacilityHub,
seedCellId: FacilityHub | 0x015Fu);
Assert.Contains(FacilityHub | 0x015Fu, outcome.RetailCells);
Assert.Contains(FacilityHub | 0x015Eu, outcome.RetailCells);
AssertSameCells(outcome.RetailCells, outcome.OldRenderCells);
}
// ---------------------------------------------------------------
// Fixture 2 — Cathedral ramp Setup 0x020009A2 (landblock 0xF418)
// ---------------------------------------------------------------
private static (uint ParentCellId, Stab Stab)? FindStaticParent(
DatCollection dats, uint landblockId, uint staticId, uint lowStart, uint lowEnd)
{
for (uint low = lowStart; low <= lowEnd; low++)
{
uint id = landblockId | low;
DatEnvCell? cell = dats.Get<DatEnvCell>(id);
if (cell?.StaticObjects is null) continue;
foreach (Stab stab in cell.StaticObjects)
if (stab.Id == staticId)
return (id, stab);
}
return null;
}
[Fact]
public void CathedralRamp_RetailCellArrayMatchesOldRenderCells()
{
string? datDir = ResolveDatDir();
if (datDir is null) Assert.Fail(SkipMessage);
using var dats = new DatCollection(datDir, DatAccessType.Read);
const uint Cathedral = 0xF4180000u;
const uint setupId = 0x020009A2u;
// docs/research has no named cell/position for this static; the two
// render buckets it crosses (RetailPViewRenderer's cathedralFlood
// probe: 0xF4180107 / 0xF4180112) are both in the 0x0100-0x01FF
// range — the same convention Facility Hub uses — so the parent scan
// is bounded there first.
(uint ParentCellId, Stab Stab)? found =
FindStaticParent(dats, Cathedral, setupId, 0x0100u, 0x01FFu)
?? FindStaticParent(dats, Cathedral, setupId, 0x0200u, 0x03FFu);
Assert.True(found is not null, "Cathedral ramp 0x020009A2 parent EnvCell not found in 0x0100-0x03FF.");
(uint parentCellId, Stab stab) = found!.Value;
_out.WriteLine(FormattableString.Invariant(
$" resolved cathedral ramp parent cell = 0x{parentCellId:X8}"));
PhysicsEngine engine = BuildIndoorEngine(dats, Cathedral, 0x0100u, 0x03FFu);
var cache = (PhysicsDataCache)engine.DataCache!;
DatSetup setup = Assert.IsType<DatSetup>(dats.Get<DatSetup>(setupId));
IReadOnlyList<MeshRef> meshRefs = SetupMesh.Flatten(setup);
foreach (MeshRef meshRef in meshRefs)
{
DatGfxObj gfxObj = Assert.IsType<DatGfxObj>(dats.Get<DatGfxObj>(meshRef.GfxObjId));
cache.CacheGfxObj(meshRef.GfxObjId, gfxObj);
}
Vector3 worldPos = new(stab.Frame.Origin.X, stab.Frame.Origin.Y, stab.Frame.Origin.Z);
Quaternion worldRot = stab.Frame.Orientation;
const uint entityId = 0x7F000002u;
ComparatorOutcome outcome = RunFixture(
"2: Cathedral ramp Setup 0x020009A2",
engine, cache, entityId, meshRefs, setup,
worldPos, worldRot,
worldOffsetX: 0f, worldOffsetY: 0f,
landblockId: Cathedral,
seedCellId: parentCellId);
AssertSameCells(outcome.RetailCells, outcome.OldRenderCells);
}
// ---------------------------------------------------------------
// Fixture 3 — #334 Neftet formation GfxObj 0x010046D8 (landblock 0x8764)
// ---------------------------------------------------------------
[Fact]
public void NeftetFormation_RetailCellArrayMatchesOldRenderCells()
{
string? datDir = ResolveDatDir();
if (datDir is null) Assert.Fail(SkipMessage);
using var dats = new DatCollection(datDir, DatAccessType.Read);
const uint NeftetLandblock = 0x87640000u;
const uint NeftetLandblockInfo = 0x8764FFFEu;
const uint FormationGfxObj = 0x010046D8u;
LandBlockInfo? info = dats.Get<LandBlockInfo>(NeftetLandblockInfo);
Assert.NotNull(info);
Stab formation = info!.Objects.First(o => o.Id == FormationGfxObj);
DatGfxObj gfx = Assert.IsType<DatGfxObj>(dats.Get<DatGfxObj>(FormationGfxObj));
PhysicsEngine engine = BuildOutdoorEngine(NeftetLandblock);
var cache = (PhysicsDataCache)engine.DataCache!;
cache.CacheGfxObj(FormationGfxObj, gfx);
var meshRefs = new List<MeshRef> { new(FormationGfxObj, Matrix4x4.Identity) };
Vector3 worldPos = formation.Frame.Origin;
Quaternion worldRot = formation.Frame.Orientation;
const uint entityId = 0x7F000003u;
ComparatorOutcome outcome = RunFixture(
"3: #334 Neftet formation GfxObj 0x010046D8",
engine, cache, entityId, meshRefs, setupFallback: null,
worldPos, worldRot,
worldOffsetX: 0f, worldOffsetY: 0f,
landblockId: NeftetLandblock,
seedCellId: 0u);
AssertSameCells(outcome.RetailCells, outcome.OldRenderCells);
}
// ---------------------------------------------------------------
// Fixture 4 — one outdoor static crossing a landblock edge. Neither
// LandblockPhysicsPublisherTests nor WalkProductionWorldDataTests carries
// a real installed-DAT edge-crossing static (their outdoor fixtures are
// synthetic cell-id lists, no DAT resolution) — this scans a real
// landblock's Objects for a GfxObj-class stab whose cached authored
// vertex-array BOX (CGfxObj::gfx_bound_box) overlaps its own landblock's
// 0/192 m boundary. The box, not the root bounding sphere, is what
// governs here: CLandCell::add_all_outside_cells divides the BOX by
// square_length (#334) — the sphere is typically much looser (a first
// pass keyed on BoundingSphere.Radius picked the #334 Neftet formation
// itself, whose 69 m sphere radius crosses 0 m at its 63.78 m origin but
// whose real 96 m box does not — see NeftetFormation_RetailCellArrayMatchesOldRenderCells,
// which already covers that object; this scan explicitly excludes it so
// fixture 4 stays independent of fixture 3).
// ---------------------------------------------------------------
private static (uint Id, Stab Stab)? FindEdgeCrossingGfxObjStatic(
DatCollection dats, PhysicsDataCache scanCache, uint landblockInfoId, uint excludeGfxObjId)
{
LandBlockInfo? info = dats.Get<LandBlockInfo>(landblockInfoId);
if (info is null) return null;
foreach (Stab stab in info.Objects)
{
if ((stab.Id & 0xFF000000u) != 0x01000000u) continue; // GfxObj-class only.
if (stab.Id == excludeGfxObjId) continue;
DatGfxObj? gfx = dats.Get<DatGfxObj>(stab.Id);
if (gfx is null) continue;
scanCache.CacheGfxObj(stab.Id, gfx);
GfxObjPhysics? physics = scanCache.GetGfxObj(stab.Id);
if (physics?.BSP?.Root is null) continue; // must take the BSP route.
if (physics.VisualBounds is not { } box) continue;
float x = stab.Frame.Origin.X;
float y = stab.Frame.Origin.Y;
bool crosses = x + box.Min.X < 0f || x + box.Max.X > 192f
|| y + box.Min.Y < 0f || y + box.Max.Y > 192f;
if (crosses)
return (stab.Id, stab);
}
return null;
}
[Fact]
public void OutdoorLandblockEdgeCrosser_RetailCellArrayMatchesOldRenderCells()
{
string? datDir = ResolveDatDir();
if (datDir is null) Assert.Fail(SkipMessage);
using var dats = new DatCollection(datDir, DatAccessType.Read);
const uint ArwicLandblock = 0xC6A90000u;
const uint ArwicLandblockInfo = 0xC6A9FFFEu;
const uint NeftetLandblock = 0x87640000u;
const uint NeftetLandblockInfo = 0x8764FFFEu;
const uint NeftetFormationGfxObj = 0x010046D8u; // fixture 3's own object — excluded here.
var scanCache = new PhysicsDataCache();
(uint Id, Stab Stab)? candidate =
FindEdgeCrossingGfxObjStatic(dats, scanCache, ArwicLandblockInfo, NeftetFormationGfxObj);
uint landblockId = ArwicLandblock;
if (candidate is null)
{
candidate = FindEdgeCrossingGfxObjStatic(
dats, scanCache, NeftetLandblockInfo, NeftetFormationGfxObj);
landblockId = NeftetLandblock;
}
Assert.True(
candidate is not null,
"No BSP-bearing edge-crossing GfxObj static found scanning Arwic/Neftet Objects.");
(uint gfxObjId, Stab stab) = candidate!.Value;
_out.WriteLine(FormattableString.Invariant(
$" resolved edge crosser: landblock=0x{landblockId:X8} gfxObj=0x{gfxObjId:X8} origin=({stab.Frame.Origin.X:F2},{stab.Frame.Origin.Y:F2})"));
DatGfxObj gfx = Assert.IsType<DatGfxObj>(dats.Get<DatGfxObj>(gfxObjId));
PhysicsEngine engine = BuildOutdoorEngine(landblockId);
var cache = (PhysicsDataCache)engine.DataCache!;
cache.CacheGfxObj(gfxObjId, gfx);
var meshRefs = new List<MeshRef> { new(gfxObjId, Matrix4x4.Identity) };
Vector3 worldPos = stab.Frame.Origin;
Quaternion worldRot = stab.Frame.Orientation;
const uint entityId = 0x7F000004u;
ComparatorOutcome outcome = RunFixture(
"4: outdoor landblock-edge crosser",
engine, cache, entityId, meshRefs, setupFallback: null,
worldPos, worldRot,
worldOffsetX: 0f, worldOffsetY: 0f,
landblockId: landblockId,
seedCellId: 0u);
IEnumerable<uint> foreignLandblockCells = outcome.RetailCells
.Where(id => (id & 0xFFFF0000u) != landblockId);
_out.WriteLine(
$" cells in a NEIGHBOR landblock (edge-crossing evidence): "
+ $"[{string.Join(",", foreignLandblockCells.Select(id => FormattableString.Invariant($"0x{id:X8}")))}]");
AssertSameCells(outcome.RetailCells, outcome.OldRenderCells);
}
// ---------------------------------------------------------------
// Fixture 5 — multi-part Setup with parts crossing different cells.
// CORRECTED against the actual installed DAT (not the plan packet's
// assumption): Facility Hub stair Setup 0x02000623 (fixture 1) resolves
// to exactly ONE visual part (SetupMesh.Flatten count = 1) whose own
// wide bounding box spans all 7 crossed cells — it is a wide SINGLE-part
// static, not a multi-part one. Cathedral ramp Setup 0x020009A2
// (fixture 2) genuinely IS the multi-part case: parts=7 (the code
// comment at WbDrawDispatcher.WalkClassify.cs:599 names them —
// 0x01001FE8 + six 0x01001FE6 slabs). retail's per-cell part entries for
// fixture 2 show all 7 parts in all 3 crossed cells (0xF4180112,
// 0xF4180113, 0xF4180009) — retail's CPartArray::AddPartsShadow registers
// EVERY part of the entity's part array into EVERY cell of the entity's
// (union) CELLARRAY, not a per-part cell subset; "parts in different
// cells" is therefore the ENTITY's CELLARRAY spanning multiple cells
// (which fixture 2 does, 3 of them), not individual parts each confined
// to their own cell. No installed-DAT fixture with genuinely
// per-part-confined membership was found among the existing test files
// this chunk was pointed at (Issue177StairDescentCameraFloodTests,
// LandblockPhysicsPublisherTests, Issue334NeftetFormationCellMembershipTests,
// WalkProductionWorldDataTests) — fixture 2's own result is the evidence
// for this row.
// ---------------------------------------------------------------
}