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:
parent
059b8883ab
commit
5a2792d689
7 changed files with 908 additions and 11 deletions
|
|
@ -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.
|
||||
// ---------------------------------------------------------------
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue