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>
603 lines
27 KiB
C#
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.
|
|
// ---------------------------------------------------------------
|
|
}
|