acdream/tests/AcDream.App.Tests/Physics/RetailCellArrayComparatorInstalledDatTests.cs
Erik 5a2792d689 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>
2026-09-02 21:52:35 +02:00

603 lines
27 KiB
C#

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.
// ---------------------------------------------------------------
}