Campaign OVERHAUL S2 chunk 1 (Core half). ShadowObjectRegistry accepts the object's whole visual part array as an optional registration input and computes retail's CELLARRAY from it as a retained side product: - route per CPhysicsObj::calc_cross_cells_static @0x00515160: cylsphere (CObjCell::find_cell_list @0x0052b9f0 over the Setup's authored cylspheres, carried as the Cylinder shapes of the COLLISION dispatch) when (state & 0x10000) == 0 and a cylsphere exists, else the bbox route (find_bbox_cell_list @0x00510fc0 via CellTransit.BuildShadowCellSetFromParts over every visual part: physics sphere else drawing sphere, part box); - per-cell part entries in array-then-part order for every part (CPartArray::AddPartsShadow @0x00517e40), clip planes required iff the array has more than one cell; - Deregister clears everything (remove_shadows_from_cells @0x00511230); UpdatePosition/CommitSetPosition recompute; ReplaceMultiPartPayload swaps the part array without re-flooding. _entityToCells and _cells are untouched; no production caller passes the part array yet (chunk 1b), so this is a zero-behavior change. The lead corrected the implementer's route decision, which had inspected the visual part array for cylinders; retail's GetNumCylsphere reads Setup collision data. Core Physics 2,202/2,202. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
356 lines
16 KiB
C#
356 lines
16 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 is ComputeStaticRenderCells's own primitive, and
|
|
// supplying a part array never touches the existing collision product.
|
|
// -------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void RegisterMultiPart_WithPartArray_BoundingBoxRouteMatchesComputeStaticRenderCells()
|
|
{
|
|
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));
|
|
|
|
IReadOnlyList<uint> expected =
|
|
withPartArray.ComputeStaticRenderCells(CellA, Pos, Quaternion.Identity, parts);
|
|
Assert.Equal(expected, retailCells);
|
|
Assert.NotEmpty(expected); // the fixture must actually exercise a flood
|
|
|
|
// 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);
|
|
}
|
|
}
|