acdream/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs
Erik 707d2803a4 feat(render): S2 chunk 2 - render statics borrow the registry's retail cell array
WalkProductionWorldData no longer floods; its indoor and outdoor static
sweeps read ShadowObjectRegistry.TryGetRetailCellArray (retail's
calc_cross_cells_static @0x00515160 -> AddPartsShadow @0x00517e40
CELLARRAY, computed once at registration). The App-owned render flood
(ResolveStaticRenderCells, its fingerprint cache, the primitive-Setup
special case) and Core's ComputeStaticRenderCells are deleted. The one
remaining fallback, an entity the physics publisher has not registered yet
while the projection journal already published it, buckets to the authored
parent cell and is counted per frame (UnregisteredStaticRenderFallbackCount)
for chunk 5 to judge on the connected route.

The Facility stair pin now registers at the projection's own entity id
(the old pure-function test never carried identity) and reads the retail
array; the installed-DAT comparator compares retail against collision.

Gates (run in the implementer's isolated worktree at identical content):
Release build 0/0; Core Physics 2,202/2,202; App hermetic 6,760/6,760 (the
two added WalkProductionWorldData tests); installed-DAT walk/flood/stair
family 24/24; Runtime 1,884/1,884.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:31:46 +02:00

363 lines
17 KiB
C#

using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Campaign OVERHAUL S2 chunk 1
/// (<c>docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md</c>
/// §3): the retail CELLARRAY and per-cell part-entry SIDE PRODUCT
/// <see cref="ShadowObjectRegistry"/> now computes from an optional whole
/// part array, beside the existing collision flood. Retail anchors:
/// <c>CPhysicsObj::calc_cross_cells_static</c> 0x00515160 (Contract A branch
/// table), <c>CPhysicsObj::find_bbox_cell_list</c> 0x00510fc0,
/// <c>CPartArray::AddPartsShadow</c> 0x00517e40 (Contract B),
/// <c>CPhysicsObj::remove_shadows_from_cells</c> 0x00511230,
/// <c>CEnvCell::find_transit_cells</c> 0x0052cae0 — all re-verified
/// 2026-09-01 through the live Ghidra bridge against <c>patchmem.gpr</c>
/// (<c>docs/research/2026-09-01-overhaul/oh1-construction-landscape-contract.md</c>).
///
/// <para>
/// A bare <see cref="ShadowObjectRegistry"/> (no installed DAT) floods
/// against an empty <c>PhysicsDataCache</c>: an outdoor seed still produces
/// the real overlapped landcells from pure LandDefs math (the same fixture
/// style <see cref="BuildShadowCellSetTests"/> and
/// <c>ShadowObjectRegistryMultiPartTests</c> already use), so every fixture
/// here stays synthetic — no installed-DAT dependency.
/// </para>
/// </summary>
public class ShadowObjectRegistryRetailCellArrayTests
{
private const uint LbId = 0xA9B40000u;
private const float OffX = 0f;
private const float OffY = 0f;
// World (12, 12, 50) -> local cell (cx=0, cy=0) -> id = LbId | 1.
private const uint CellA = LbId | 1u;
// World (42, 12, 50), 30 m away in X -> local cell (cx=1, cy=0) -> id = LbId | 9.
private const uint CellB = LbId | 9u;
private static readonly Vector3 Pos = new(12f, 12f, 50f);
private static ShadowShape Bsp(uint gfxObjId, Vector3 localPosition = default, float radius = 1f)
=> ShadowShape.Bsp(
gfxObjId,
localPosition,
Quaternion.Identity,
scale: 1f,
localGeometry: ShadowPartGeometry.Create(
new FlatCollisionSphere(Vector3.Zero, radius), null));
private static ShadowShape Cyl(uint gfxObjId, Vector3 localPosition = default, float radius = 1f)
=> ShadowShape.Cylinder(
gfxObjId, localPosition, Quaternion.Identity, scale: 1f,
radius: radius, cylHeight: 1f);
// -------------------------------------------------------------------
// Item E: existing callers (no partArray) are byte-for-byte unaffected.
// -------------------------------------------------------------------
[Fact]
public void RegisterMultiPart_WithoutPartArray_NoRetailProduct()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x10u;
var shapes = new[] { Bsp(0x0100_0001u) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, shapes,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId);
Assert.False(reg.TryGetRetailCellArray(entityId, out var cells));
Assert.Empty(cells);
Assert.Equal(RetailCellArrayRoute.None, reg.GetRetailCellArrayRoute(entityId));
foreach (uint cellId in reg.GetOwnerCells(entityId))
Assert.Empty(reg.GetRetailPartEntriesInCell(cellId));
}
[Fact]
public void Register_SingleShapeWithoutPartArray_NoRetailProduct()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x11u;
reg.Register(entityId, 0x0100_0002u, Pos, Quaternion.Identity, 1f, OffX, OffY, LbId);
Assert.False(reg.TryGetRetailCellArray(entityId, out var cells));
Assert.Empty(cells);
Assert.Equal(RetailCellArrayRoute.None, reg.GetRetailCellArrayRoute(entityId));
}
// -------------------------------------------------------------------
// The bbox route (find_bbox_cell_list 0x00510fc0) is the SAME primitive
// the collision flood uses for a single BSP-shape part array fed as both
// `shapes` and `partArray`, and supplying a part array never touches the
// existing collision product.
// -------------------------------------------------------------------
[Fact]
public void RegisterMultiPart_WithPartArray_BoundingBoxRouteMatchesCollisionFloodForTheSamePartArray()
{
var withPartArray = new ShadowObjectRegistry();
var withoutPartArray = new ShadowObjectRegistry();
const uint entityId = 0x12u;
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0044u, radius: 2f) };
withPartArray.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true, partArray: parts);
withoutPartArray.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true);
Assert.Equal(RetailCellArrayRoute.BoundingBox, withPartArray.GetRetailCellArrayRoute(entityId));
Assert.True(withPartArray.TryGetRetailCellArray(entityId, out var retailCells));
Assert.NotEmpty(retailCells); // the fixture must actually exercise a flood
// Retail's bbox route and RegisterMultiPart's own BSP collision
// dispatch share one primitive (CPhysicsObj::find_bbox_cell_list
// 0x00510fc0) when fed the identical single-part array as both
// `shapes` and `partArray`, so the two products agree exactly. This
// is the independent ground truth now that ComputeStaticRenderCells
// (Campaign OVERHAUL S2 chunk 2) is deleted along with its only
// production consumer.
Assert.Equal(withPartArray.GetOwnerCells(entityId), retailCells);
// Supplying a part array must not perturb the pre-existing collision
// product — same registration, same result either way.
Assert.Equal(withoutPartArray.GetOwnerCells(entityId), withPartArray.GetOwnerCells(entityId));
}
// -------------------------------------------------------------------
// Contract A branch table: (state & 0x10000) == 0 && has a Cylinder
// shape -> cylsphere route; otherwise (including the same part array
// with the state bit set) -> bbox route.
// -------------------------------------------------------------------
[Fact]
public void RegisterMultiPart_PartArrayRoute_DispatchesOnStateBitAndCylinderPresence()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x13u;
IReadOnlyList<ShadowShape> mixed = new[]
{
Cyl(0x3001u), // part 0: at the entity origin (CellA)
Bsp(0x3002u, new Vector3(30f, 0f, 0f)), // part 1: 30 m away in X (CellB)
};
// State bit clear + a Cylinder present -> cylsphere route
// (CObjCell::find_cell_list 0x0052b9f0), flooded from the cylinder
// only — the bsp part 30 m away must not pull in CellB.
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, mixed,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: mixed);
Assert.Equal(RetailCellArrayRoute.Cylsphere, reg.GetRetailCellArrayRoute(entityId));
Assert.True(reg.TryGetRetailCellArray(entityId, out var cylCells));
Assert.Equal(new[] { CellA }, cylCells);
var cylEntries = reg.GetRetailPartEntriesInCell(CellA)
.Where(e => e.EntityId == entityId)
.OrderBy(e => e.PartIndex)
.ToList();
// AddPartsShadow registers EVERY part per cell, not just the ones
// that contributed to crossing it (Contract B).
Assert.Equal(2, cylEntries.Count);
Assert.Equal(0, cylEntries[0].PartIndex);
Assert.Equal(0x3001u, cylEntries[0].GfxObjId);
Assert.Equal(1, cylEntries[1].PartIndex);
Assert.Equal(0x3002u, cylEntries[1].GfxObjId);
Assert.False(cylEntries[0].ClipPlanesRequired); // single-cell CELLARRAY
Assert.False(cylEntries[1].ClipPlanesRequired);
// Re-register the SAME part array with the HAS_PHYSICS_BSP_PS state
// bit set -> bbox route (CPhysicsObj::find_bbox_cell_list 0x00510fc0)
// regardless of the cylinder's presence, flooded from the surviving
// bsp part -> pulls in CellB too.
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, mixed,
state: 0x10000u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: mixed);
Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId));
Assert.True(reg.TryGetRetailCellArray(entityId, out var bboxCells));
Assert.Equal(new[] { CellA, CellB }, bboxCells);
foreach (uint cellId in bboxCells)
{
var entries = reg.GetRetailPartEntriesInCell(cellId)
.Where(e => e.EntityId == entityId)
.OrderBy(e => e.PartIndex)
.ToList();
Assert.Equal(2, entries.Count);
Assert.Equal(0, entries[0].PartIndex);
Assert.Equal(1, entries[1].PartIndex);
// More than one CELLARRAY member -> clip planes required (Contract B).
Assert.True(entries[0].ClipPlanesRequired);
Assert.True(entries[1].ClipPlanesRequired);
}
}
[Fact]
public void RegisterMultiPart_RouteIsDecidedByCollisionCylspheres_NotByTheVisualPartArray()
{
// Contract A: GetNumCylsphere() reads the Setup's authored CylSpheres,
// which acdream carries as the Cylinder shapes of the COLLISION
// dispatch. A visual part array (chunk 1b passes visual parts, which
// are never cylinders) must not change the route.
var reg = new ShadowObjectRegistry();
const uint entityId = 0x14u;
IReadOnlyList<ShadowShape> collision = new[] { Cyl(0x3001u) };
IReadOnlyList<ShadowShape> visualParts = new[]
{
Bsp(0x3002u, new Vector3(30f, 0f, 0f)), // a visual part 30 m away (CellB)
};
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, collision,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: visualParts);
// Cylsphere route: flooded from the authored cylsphere at the origin,
// not from the far visual part.
Assert.Equal(RetailCellArrayRoute.Cylsphere, reg.GetRetailCellArrayRoute(entityId));
Assert.True(reg.TryGetRetailCellArray(entityId, out var cells));
Assert.Equal(new[] { CellA }, cells);
// ...but the entries are the VISUAL parts (AddPartsShadow walks the part array).
var entries = reg.GetRetailPartEntriesInCell(CellA).Where(e => e.EntityId == entityId).ToList();
Assert.Single(entries);
Assert.Equal(0x3002u, entries[0].GfxObjId);
// No cylsphere in the collision dispatch -> bbox route over the visual parts.
IReadOnlyList<ShadowShape> bspCollision = new[] { Bsp(0x3001u, Vector3.Zero) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, bspCollision,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: visualParts);
Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId));
}
// -------------------------------------------------------------------
// Deregister is the exact inverse transaction.
// -------------------------------------------------------------------
[Fact]
public void Deregister_ClearsRetailCellArrayAndEveryPerCellEntry()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x14u;
IReadOnlyList<ShadowShape> parts = new[]
{
Cyl(0x4001u),
Bsp(0x4002u, new Vector3(30f, 0f, 0f)),
};
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
state: 0x10000u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: parts);
Assert.True(reg.TryGetRetailCellArray(entityId, out var cellsBeforeRemoval));
Assert.NotEmpty(cellsBeforeRemoval);
var trackedCells = cellsBeforeRemoval.ToArray();
reg.Deregister(entityId);
Assert.False(reg.TryGetRetailCellArray(entityId, out var cellsAfterRemoval));
Assert.Empty(cellsAfterRemoval);
Assert.Equal(RetailCellArrayRoute.None, reg.GetRetailCellArrayRoute(entityId));
foreach (uint cellId in trackedCells)
Assert.Empty(reg.GetRetailPartEntriesInCell(cellId));
}
// -------------------------------------------------------------------
// UpdatePosition / CommitSetPosition recompute the retained product at
// the new seed/position — the same flood rules, run again.
// -------------------------------------------------------------------
[Fact]
public void UpdatePosition_WithRetainedPartArray_RecomputesRetailCellArray()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x15u;
IReadOnlyList<ShadowShape> parts = new[] { Cyl(0x5001u) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: parts);
Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
Assert.Equal(new[] { CellA }, before);
var moved = new Vector3(42f, 12f, 50f); // 30 m away in X -> CellB
reg.UpdatePosition(entityId, moved, Quaternion.Identity, OffX, OffY, LbId);
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
Assert.Equal(new[] { CellB }, after);
var movedEntries = reg.GetRetailPartEntriesInCell(CellB)
.Where(e => e.EntityId == entityId).ToList();
Assert.Single(movedEntries);
Assert.Equal(0x5001u, movedEntries[0].GfxObjId);
Assert.DoesNotContain(
reg.GetRetailPartEntriesInCell(CellA),
e => e.EntityId == entityId);
}
[Fact]
public void CommitSetPosition_WithRetainedPartArray_RecomputesRetailCellArray()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x16u;
IReadOnlyList<ShadowShape> parts = new[] { Cyl(0x6001u) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
isStatic: false, partArray: parts);
Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
Assert.Equal(new[] { CellA }, before);
var moved = new Vector3(42f, 12f, 50f); // -> CellB
reg.CommitSetPosition(
entityId,
moved,
Quaternion.Identity,
seedCellId: CellB,
worldOffsetX: OffX,
worldOffsetY: OffY,
action: PhysicsShadowCommitAction.None,
crossCellIds: ImmutableArray<uint>.Empty);
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
Assert.Equal(new[] { CellB }, after);
}
// -------------------------------------------------------------------
// ReplaceMultiPartPayload is retail's SetPart: it swaps which parts
// occupy the CURRENT CELLARRAY without re-flooding.
// -------------------------------------------------------------------
[Fact]
public void ReplaceMultiPartPayload_SwapsPartArrayWithoutReflooding()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x17u;
IReadOnlyList<ShadowShape> initial = new[] { Cyl(0x7001u) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, initial,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: initial);
Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
Assert.Equal(new[] { CellA }, before);
Assert.Equal(RetailCellArrayRoute.Cylsphere, reg.GetRetailCellArrayRoute(entityId));
IReadOnlyList<ShadowShape> replacement = new[] { Cyl(0x7002u), Cyl(0x7003u) };
reg.ReplaceMultiPartPayload(entityId, Pos, Quaternion.Identity, replacement,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: replacement);
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
// No reflood: the CELLARRAY is unchanged (still CellA only).
Assert.Equal(before, after);
// The route stays whatever the LAST recompute (at registration)
// decided; ReplaceMultiPartPayload does not re-run the dispatch.
Assert.Equal(RetailCellArrayRoute.Cylsphere, reg.GetRetailCellArrayRoute(entityId));
var entries = reg.GetRetailPartEntriesInCell(CellA)
.Where(e => e.EntityId == entityId)
.OrderBy(e => e.PartIndex)
.ToList();
Assert.Equal(2, entries.Count);
Assert.Equal(0x7002u, entries[0].GfxObjId);
Assert.Equal(0x7003u, entries[1].GfxObjId);
Assert.DoesNotContain(entries, e => e.GfxObjId == 0x7001u);
}
}