Chunk 5 (consumer cutover): WalkProductionWorldData's per-cell views are borrowed from ShadowObjectRegistry.GetRetailPartEntriesInCell and resolved through RenderSceneQuery.TryGetByLocalEntityId; every render-side sweep, bucket, parent-cell and root-position fallback is deleted (AD-116 for the one-frame registry→scene window, counted in UnregisteredRenderMembershipCount). A live entity with visual parts but no collision geometry registers render-only (LiveEntityCollisionBuilder computes the part array before the empty-shapes gate). Closeout fixes found while landing it: - RefloodOwnerForLandblock forwards the retained part array — a reflood is retail's recalc_cross_cells over the SAME CPartArray; without it every owner touched by a landblock replacement commit lost its render membership. - Non-colliding DAT statics register render-only from BOTH publishers (LandblockPhysicsPublisher.PublishStaticEntity, LandblockPhysicsContentBuilder.RegisterRenderOnlyStatic). The G2 self-gate pixel diff caught them vanishing (Facility Hub wall panels): retail floods every object regardless of collision (CEnvCell::init_static_objects 0x0052c350, add_shadows_to_cells 0x00514ae0). - S2 dual review fix batch (arch + retail lens, lead-verified): Suspend clears the retail product (remove_shadows_from_cells 0x00511230 is one transaction); AttachChild/DetachChild advance the mutation revision so a prepared SetPosition cannot clobber a child's rows; an attached child never floods on its own re-registration; RemoveLandblock and the non-rooted RetireOwnerFromLandblock prune retail rows (render-only statics end with their landblock); a render-only owner's no-cell-array commit republishes at its destination cell (AD-117); an empty non-null part array is treated as null; per-move closures/LINQ replaced by index loops; EnvCell shells stay out of the scene's LocalEntityId index (payload-less records); the index predicate compares the id; the dead per-cell scene indices are deleted. Register: AD-116 (chunk 5), AD-117 (four residual Contract A/B readings). Evidence: s2-membership-ownership-map.md §8 (chunk 5) and §9 (closeout). Gates (Release): Core 4,984/4,984; Content 214/214; Runtime 1,884/1,884; App hermetic lane 6,760/6,760; App InstalledDat lane 217 pass / 1 skip / 2 pre-existing #383 layout-fixture failures; App Windows lane 1/1. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1254 lines
58 KiB
C#
1254 lines
58 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);
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Campaign OVERHAUL S2 chunk 4: "one array, two products" on the move
|
|
// path too. Retail's SetPositionInternal (pc:283399) republishes BOTH
|
|
// the collision CShadowObj list and the retail render product from the
|
|
// SAME transition cell_array (pc:283536-283537) — never a second,
|
|
// independently-flooded array. The retail array must therefore equal
|
|
// GetOwnerCells after every CommitSetPosition variant.
|
|
// -------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void CommitSetPosition_NoneAction_PublishesRetailProductFromTheRetainedCells()
|
|
{
|
|
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);
|
|
|
|
// A .None commit carries no transition cell_array of its own (it
|
|
// maps to retail's "cell did not change" branch, e.g. ForceIntoCell
|
|
// with changedCell == false) — CommitSetPosition's None action
|
|
// republishes at the RETAINED cells (CellA), not a fresh flood from
|
|
// the new world position, even though (42,12,50) would geometrically
|
|
// resolve to CellB. Both products must agree either way.
|
|
var moved = new Vector3(42f, 12f, 50f);
|
|
reg.CommitSetPosition(
|
|
entityId,
|
|
moved,
|
|
Quaternion.Identity,
|
|
seedCellId: CellB,
|
|
worldOffsetX: OffX,
|
|
worldOffsetY: OffY,
|
|
action: PhysicsShadowCommitAction.None,
|
|
crossCellIds: ImmutableArray<uint>.Empty);
|
|
|
|
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
|
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
|
|
Assert.Equal(reg.GetOwnerCells(entityId), after);
|
|
}
|
|
|
|
[Fact]
|
|
public void CommitSetPosition_ReplaceAction_PublishesRetailProductFromTheTransitionCellArray()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint entityId = 0x18u;
|
|
IReadOnlyList<ShadowShape> parts = new[] { Cyl(0x6101u) };
|
|
|
|
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);
|
|
|
|
// .Replace supplies the transition's own crossCellIds — retail's
|
|
// arg2->cell_array. Both products move to it directly; there is no
|
|
// second, independently-computed cell array to disagree with it.
|
|
var moved = new Vector3(42f, 12f, 50f);
|
|
reg.CommitSetPosition(
|
|
entityId,
|
|
moved,
|
|
Quaternion.Identity,
|
|
seedCellId: CellB,
|
|
worldOffsetX: OffX,
|
|
worldOffsetY: OffY,
|
|
action: PhysicsShadowCommitAction.Replace,
|
|
crossCellIds: [CellB]);
|
|
|
|
Assert.Equal(new[] { CellB }, reg.GetOwnerCells(entityId));
|
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
|
|
Assert.Equal(reg.GetOwnerCells(entityId), after);
|
|
}
|
|
|
|
[Fact]
|
|
public void CommitSetPosition_KeepWhenEmpty_LeavesBothProductsUntouched()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint entityId = 0x19u;
|
|
IReadOnlyList<ShadowShape> parts = new[] { Cyl(0x6201u) };
|
|
|
|
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);
|
|
|
|
// Retail's num_cells > 0 gate (pc:283534/pc:283540): an EMPTY
|
|
// transition cell_array leaves the previous shadows exactly as they
|
|
// were for BOTH products — no republish of either.
|
|
reg.CommitSetPosition(
|
|
entityId,
|
|
Pos,
|
|
Quaternion.Identity,
|
|
seedCellId: CellA,
|
|
worldOffsetX: OffX,
|
|
worldOffsetY: OffY,
|
|
action: PhysicsShadowCommitAction.Preserve,
|
|
crossCellIds: ImmutableArray<uint>.Empty);
|
|
|
|
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
|
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
|
|
Assert.Equal(new[] { CellA }, 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);
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Campaign OVERHAUL S2 chunk 3: one flood per registration. Both
|
|
// _entityToCells/_cells (collision) and the retail render product now
|
|
// come from the SAME CELLARRAY when a caller supplies partArray — a
|
|
// decorative non-BSP-colliding visual part widens COLLISION membership
|
|
// too, not just the retail product. The BSP test still decides contact
|
|
// at query time; only membership (which cells hold a row at all) widens.
|
|
// -------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void RegisterMultiPart_DecorativePartArrayEntry_WidensCollisionRowsToo()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint entityId = 0x20u;
|
|
const uint collidingGfxObj = 0xAAAA0001u;
|
|
const uint decorativeGfxObj = 0xBBBB0001u;
|
|
|
|
// The entity's actual COLLISION geometry is a single BSP part at the
|
|
// entity's own origin (CellA only, on its own — AP-152's exclusive
|
|
// collision dispatch).
|
|
IReadOnlyList<ShadowShape> collisionShapes = new[] { Bsp(collidingGfxObj) };
|
|
// Its WHOLE VISUAL part array (chunk 1b's partArray input) additionally
|
|
// carries a decorative part 30 m away with no collision of its own.
|
|
// ShadowShapeBuilder.FromStaticRenderParts/FromSetupRenderParts tag
|
|
// EVERY visual part CollisionType.BSP as a pure geometry carrier
|
|
// regardless of whether its GfxObj has a real physics BSP, so this
|
|
// mirrors production exactly.
|
|
IReadOnlyList<ShadowShape> partArray = new[]
|
|
{
|
|
Bsp(collidingGfxObj),
|
|
Bsp(decorativeGfxObj, new Vector3(30f, 0f, 0f)),
|
|
};
|
|
|
|
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, collisionShapes,
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
partArray: partArray);
|
|
|
|
// Retail CELLARRAY reaches both cells — the decorative part's box
|
|
// pulls in CellB even though it cannot collide.
|
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var retailCells));
|
|
Assert.Equal(new[] { CellA, CellB }, retailCells);
|
|
Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId));
|
|
|
|
// Chunk 3: the SAME array now drives collision. Retail == collision
|
|
// by construction, not coincidence.
|
|
Assert.Equal(retailCells, reg.GetOwnerCells(entityId));
|
|
|
|
foreach (uint cellId in new[] { CellA, CellB })
|
|
{
|
|
var collisionRows = reg.GetObjectsInCell(cellId)
|
|
.Where(e => e.EntityId == entityId)
|
|
.ToList();
|
|
Assert.Single(collisionRows);
|
|
Assert.Equal(collidingGfxObj, collisionRows[0].GfxObjId);
|
|
|
|
// The decorative part never appears as a COLLISION row (AP-152:
|
|
// the collision dispatch stays BSP-exclusive over collisionShapes)
|
|
// — only as a retail render part entry.
|
|
Assert.DoesNotContain(collisionRows, e => e.GfxObjId == decorativeGfxObj);
|
|
|
|
var partEntries = reg.GetRetailPartEntriesInCell(cellId)
|
|
.Where(e => e.EntityId == entityId)
|
|
.Select(e => e.GfxObjId)
|
|
.ToList();
|
|
Assert.Contains(collidingGfxObj, partEntries);
|
|
Assert.Contains(decorativeGfxObj, partEntries);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void RegisterMultiPart_WithoutDecorativeParts_RetailArrayStillEqualsCollisionCells()
|
|
{
|
|
// BSP-only shapes with partArray == shapes: retail array and
|
|
// collision cells must agree exactly, whether or not a partArray is
|
|
// supplied at all (item F: "registering with partArray and without
|
|
// must produce identical _entityToCells for BSP-only shapes").
|
|
var withPartArray = new ShadowObjectRegistry();
|
|
var withoutPartArray = new ShadowObjectRegistry();
|
|
const uint entityId = 0x21u;
|
|
IReadOnlyList<ShadowShape> shapes = new[] { Bsp(0x0100_0055u, radius: 2f) };
|
|
|
|
withPartArray.RegisterMultiPart(entityId, Pos, Quaternion.Identity, shapes,
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
seedCellId: CellA, isStatic: true, partArray: shapes);
|
|
withoutPartArray.RegisterMultiPart(entityId, Pos, Quaternion.Identity, shapes,
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
seedCellId: CellA, isStatic: true);
|
|
|
|
Assert.Equal(
|
|
withoutPartArray.GetOwnerCells(entityId),
|
|
withPartArray.GetOwnerCells(entityId));
|
|
Assert.True(withPartArray.TryGetRetailCellArray(entityId, out var retailCells));
|
|
Assert.Equal(withPartArray.GetOwnerCells(entityId), retailCells);
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Campaign OVERHAUL S2 chunk 3 item B: the staged
|
|
// TryPrepareSetPosition/TryApplySetPosition pipeline must carry the
|
|
// retail render product exactly like the direct CommitSetPosition path
|
|
// — the chunk-1 gap where InstallOwnerState never seeded the staging
|
|
// registry's retail dictionaries, so its own internal Contract-A
|
|
// recompute silently no-opped and the applied commit dropped the retail
|
|
// product on the floor.
|
|
// -------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void StagedSetPosition_WithRetainedPartArray_MovesBothProductsTogether()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint entityId = 0x30u;
|
|
IReadOnlyList<ShadowShape> parts = new[] { Cyl(0x8001u) };
|
|
|
|
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);
|
|
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
|
|
|
|
var moved = new Vector3(42f, 12f, 50f); // 30 m away in X -> CellB
|
|
Assert.True(reg.TryPrepareSetPosition(
|
|
entityId,
|
|
moved,
|
|
Quaternion.Identity,
|
|
seedCellId: CellB,
|
|
worldOffsetX: OffX,
|
|
worldOffsetY: OffY,
|
|
PhysicsShadowCommitAction.Replace,
|
|
crossCellIds: [CellB],
|
|
provenShapeless: false,
|
|
suspendOwner: false,
|
|
out var prepared));
|
|
|
|
// Not yet applied — both products still describe the OLD position.
|
|
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
|
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var stillBefore));
|
|
Assert.Equal(new[] { CellA }, stillBefore);
|
|
|
|
Assert.True(reg.TryApplySetPosition(prepared!, out var receipt));
|
|
Assert.True(receipt.Mutated);
|
|
|
|
Assert.Equal(new[] { CellB }, reg.GetOwnerCells(entityId));
|
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
|
|
Assert.Equal(new[] { CellB }, after);
|
|
Assert.Equal(RetailCellArrayRoute.Cylsphere, reg.GetRetailCellArrayRoute(entityId));
|
|
|
|
var partEntries = reg.GetRetailPartEntriesInCell(CellB)
|
|
.Where(e => e.EntityId == entityId).ToList();
|
|
Assert.Single(partEntries);
|
|
Assert.Equal(0x8001u, partEntries[0].GfxObjId);
|
|
Assert.DoesNotContain(
|
|
reg.GetRetailPartEntriesInCell(CellA), e => e.EntityId == entityId);
|
|
|
|
var collisionRows = reg.GetObjectsInCell(CellB)
|
|
.Where(e => e.EntityId == entityId).ToList();
|
|
Assert.Single(collisionRows);
|
|
Assert.Equal(0x8001u, collisionRows[0].GfxObjId);
|
|
Assert.DoesNotContain(
|
|
reg.GetObjectsInCell(CellA), e => e.EntityId == entityId);
|
|
}
|
|
|
|
[Fact]
|
|
public void StagedSetPosition_KeepWhenEmpty_PreservesBothProductsTogether()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint entityId = 0x31u;
|
|
IReadOnlyList<ShadowShape> parts = new[] { Cyl(0x8101u) };
|
|
|
|
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);
|
|
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
|
|
|
|
// seedCellId: 0 with the Recalculate action resolves NO seed at all
|
|
// (landblockId derives from seedCellId, so it is also 0 —
|
|
// DeriveOutdoorSeed's own guard) — retail keeps the previous
|
|
// shadows (SetPositionInternal num_cells gate, pc:283540). The
|
|
// staged pipeline must honor this for BOTH products, not just
|
|
// collision.
|
|
Assert.True(reg.TryPrepareSetPosition(
|
|
entityId,
|
|
new Vector3(999f, 999f, 50f),
|
|
Quaternion.Identity,
|
|
seedCellId: 0u,
|
|
worldOffsetX: OffX,
|
|
worldOffsetY: OffY,
|
|
PhysicsShadowCommitAction.Recalculate,
|
|
crossCellIds: ImmutableArray<uint>.Empty,
|
|
provenShapeless: false,
|
|
suspendOwner: false,
|
|
out var prepared));
|
|
Assert.True(reg.TryApplySetPosition(prepared!, out _));
|
|
|
|
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
|
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
|
|
Assert.Equal(new[] { CellA }, after);
|
|
Assert.Equal(RetailCellArrayRoute.Cylsphere, reg.GetRetailCellArrayRoute(entityId));
|
|
|
|
var partEntries = reg.GetRetailPartEntriesInCell(CellA)
|
|
.Where(e => e.EntityId == entityId).ToList();
|
|
Assert.Single(partEntries);
|
|
Assert.Equal(0x8101u, partEntries[0].GfxObjId);
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Campaign OVERHAUL S2 chunk 4: AttachChild/DetachChild — retail's
|
|
// add_shadows_to_cells child-inheritance recursion (Contract B). An
|
|
// attached projection (equipped weapon/shield/ammunition) owns no
|
|
// independent collision shapes; it inherits the root's CELLARRAY and
|
|
// gets PART ENTRIES only, never a collision row.
|
|
// -------------------------------------------------------------------
|
|
|
|
private static IReadOnlyList<ShadowShape> ChildParts(params uint[] gfxObjIds)
|
|
{
|
|
var shapes = new ShadowShape[gfxObjIds.Length];
|
|
for (int i = 0; i < gfxObjIds.Length; i++)
|
|
shapes[i] = Bsp(gfxObjIds[i]);
|
|
return shapes;
|
|
}
|
|
|
|
[Fact]
|
|
public void AttachChild_GivesChildTheRootsArrayAndEntries()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint rootId = 0x40u;
|
|
const uint childId = 0x41u;
|
|
IReadOnlyList<ShadowShape> rootParts = new[] { Cyl(0x9001u) };
|
|
|
|
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
partArray: rootParts);
|
|
Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCells));
|
|
Assert.Equal(new[] { CellA }, rootCells);
|
|
|
|
IReadOnlyList<ShadowShape> childParts = ChildParts(0xC001u, 0xC002u);
|
|
Assert.True(reg.AttachChild(childId, rootId, childParts));
|
|
|
|
Assert.True(reg.TryGetRetailCellArray(childId, out var childCells));
|
|
Assert.Equal(rootCells, childCells);
|
|
|
|
var entries = reg.GetRetailPartEntriesInCell(CellA)
|
|
.Where(e => e.EntityId == childId)
|
|
.OrderBy(e => e.PartIndex)
|
|
.ToList();
|
|
Assert.Equal(2, entries.Count);
|
|
Assert.Equal(0xC001u, entries[0].GfxObjId);
|
|
Assert.Equal(0xC002u, entries[1].GfxObjId);
|
|
|
|
// Never a collision row — acdream's attached projections own none.
|
|
Assert.Empty(reg.GetOwnerCells(childId));
|
|
Assert.DoesNotContain(
|
|
reg.GetObjectsInCell(CellA),
|
|
e => e.EntityId == childId);
|
|
}
|
|
|
|
[Fact]
|
|
public void AttachChild_RootMove_RepublishesTheChild()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint rootId = 0x42u;
|
|
const uint childId = 0x43u;
|
|
IReadOnlyList<ShadowShape> rootParts = new[] { Cyl(0x9101u) };
|
|
|
|
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
isStatic: false, partArray: rootParts);
|
|
Assert.True(reg.AttachChild(childId, rootId, ChildParts(0xC101u)));
|
|
Assert.True(reg.TryGetRetailCellArray(childId, out var before));
|
|
Assert.Equal(new[] { CellA }, before);
|
|
|
|
var moved = new Vector3(42f, 12f, 50f); // -> CellB
|
|
reg.CommitSetPosition(
|
|
rootId,
|
|
moved,
|
|
Quaternion.Identity,
|
|
seedCellId: CellB,
|
|
worldOffsetX: OffX,
|
|
worldOffsetY: OffY,
|
|
action: PhysicsShadowCommitAction.Replace,
|
|
crossCellIds: [CellB]);
|
|
|
|
Assert.True(reg.TryGetRetailCellArray(rootId, out var rootAfter));
|
|
Assert.Equal(new[] { CellB }, rootAfter);
|
|
Assert.True(reg.TryGetRetailCellArray(childId, out var childAfter));
|
|
Assert.Equal(rootAfter, childAfter);
|
|
|
|
Assert.DoesNotContain(
|
|
reg.GetRetailPartEntriesInCell(CellA),
|
|
e => e.EntityId == childId);
|
|
var movedEntries = reg.GetRetailPartEntriesInCell(CellB)
|
|
.Where(e => e.EntityId == childId).ToList();
|
|
Assert.Single(movedEntries);
|
|
Assert.Equal(0xC101u, movedEntries[0].GfxObjId);
|
|
}
|
|
|
|
[Fact]
|
|
public void DetachChild_ClearsTheChildsProducts()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint rootId = 0x44u;
|
|
const uint childId = 0x45u;
|
|
IReadOnlyList<ShadowShape> rootParts = new[] { Cyl(0x9201u) };
|
|
|
|
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
partArray: rootParts);
|
|
Assert.True(reg.AttachChild(childId, rootId, ChildParts(0xC201u)));
|
|
Assert.True(reg.TryGetRetailCellArray(childId, out _));
|
|
|
|
Assert.True(reg.DetachChild(childId));
|
|
|
|
Assert.False(reg.TryGetRetailCellArray(childId, out var afterCells));
|
|
Assert.Empty(afterCells);
|
|
Assert.DoesNotContain(
|
|
reg.GetRetailPartEntriesInCell(CellA),
|
|
e => e.EntityId == childId);
|
|
// The root itself is untouched.
|
|
Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCells));
|
|
Assert.Equal(new[] { CellA }, rootCells);
|
|
|
|
// A second detach is a no-op, not an error.
|
|
Assert.False(reg.DetachChild(childId));
|
|
}
|
|
|
|
[Fact]
|
|
public void Deregister_RootCascadesDetachOfAttachedChildren()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint rootId = 0x46u;
|
|
const uint childId = 0x47u;
|
|
IReadOnlyList<ShadowShape> rootParts = new[] { Cyl(0x9301u) };
|
|
|
|
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
partArray: rootParts);
|
|
Assert.True(reg.AttachChild(childId, rootId, ChildParts(0xC301u)));
|
|
|
|
reg.Deregister(rootId);
|
|
|
|
Assert.False(reg.TryGetRetailCellArray(rootId, out _));
|
|
Assert.False(reg.TryGetRetailCellArray(childId, out var childCells));
|
|
Assert.Empty(childCells);
|
|
Assert.DoesNotContain(
|
|
reg.GetRetailPartEntriesInCell(CellA),
|
|
e => e.EntityId == childId);
|
|
}
|
|
|
|
[Fact]
|
|
public void AttachChild_NestedChildOfAChild_ResolvesToTheRoot()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint rootId = 0x48u;
|
|
const uint directChildId = 0x49u;
|
|
const uint grandchildId = 0x4Au;
|
|
IReadOnlyList<ShadowShape> rootParts = new[] { Cyl(0x9401u) };
|
|
|
|
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
partArray: rootParts);
|
|
Assert.True(reg.AttachChild(directChildId, rootId, ChildParts(0xC401u)));
|
|
// Attach the grandchild to the DIRECT CHILD, not the root — nested
|
|
// attachment must still resolve to the ultimate root's array.
|
|
Assert.True(reg.AttachChild(grandchildId, directChildId, ChildParts(0xC402u)));
|
|
|
|
Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCells));
|
|
Assert.True(reg.TryGetRetailCellArray(grandchildId, out var grandchildCells));
|
|
Assert.Equal(rootCells, grandchildCells);
|
|
|
|
var entries = reg.GetRetailPartEntriesInCell(CellA)
|
|
.Where(e => e.EntityId == grandchildId).ToList();
|
|
Assert.Single(entries);
|
|
Assert.Equal(0xC402u, entries[0].GfxObjId);
|
|
|
|
// Moving the root republishes BOTH the direct child and the
|
|
// grandchild, in child-list order.
|
|
var moved = new Vector3(42f, 12f, 50f);
|
|
reg.CommitSetPosition(
|
|
rootId, moved, Quaternion.Identity, seedCellId: CellB,
|
|
worldOffsetX: OffX, worldOffsetY: OffY,
|
|
action: PhysicsShadowCommitAction.Replace, crossCellIds: [CellB]);
|
|
|
|
Assert.True(reg.TryGetRetailCellArray(grandchildId, out var grandchildAfter));
|
|
Assert.Equal(new[] { CellB }, grandchildAfter);
|
|
|
|
// Detaching the direct child cascades to the grandchild too.
|
|
Assert.True(reg.DetachChild(directChildId));
|
|
Assert.False(reg.TryGetRetailCellArray(grandchildId, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void AttachChild_SelfAttachRejected()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint entityId = 0x4Bu;
|
|
IReadOnlyList<ShadowShape> rootParts = new[] { Cyl(0x9501u) };
|
|
|
|
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, rootParts,
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
partArray: rootParts);
|
|
|
|
Assert.False(reg.AttachChild(entityId, entityId, ChildParts(0xC501u)));
|
|
}
|
|
|
|
[Fact]
|
|
public void AttachChild_CycleRejected_RootUnaffected()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint rootId = 0x4Cu;
|
|
const uint childId = 0x4Du;
|
|
IReadOnlyList<ShadowShape> rootParts = new[] { Cyl(0x9601u) };
|
|
|
|
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
partArray: rootParts);
|
|
Assert.True(reg.AttachChild(childId, rootId, ChildParts(0xC601u)));
|
|
Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCellsBefore));
|
|
|
|
// Attaching the root TO its own child would form a cycle.
|
|
Assert.False(reg.AttachChild(rootId, childId, ChildParts(0xC602u)));
|
|
|
|
Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCellsAfter));
|
|
Assert.Equal(rootCellsBefore, rootCellsAfter);
|
|
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(rootId));
|
|
// The child's own attachment is unaffected by the rejected call.
|
|
Assert.True(reg.TryGetRetailCellArray(childId, out var childCells));
|
|
Assert.Equal(rootCellsAfter, childCells);
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Campaign OVERHAUL S2 chunk 5 (item B): a live entity whose collision
|
|
// dispatch produced NO shapes but whose Setup still has a visual part
|
|
// array (the retail short-lived spell/visual effect-object case) still
|
|
// registers for render — Contract A's cylsphere-vs-bbox TEST reads the
|
|
// COLLISION shapes (none here, so it is always false) and the flood
|
|
// unconditionally falls through to the bbox route over the WHOLE part
|
|
// array. No ShadowEntry collision row is ever published; only the
|
|
// retail CELLARRAY and per-cell RetailPartEntry rows are.
|
|
// -------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void RegisterMultiPart_EmptyShapesWithPartArray_RegistersRenderOnlyNoCollisionRows()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint entityId = 0x50u;
|
|
IReadOnlyList<ShadowShape> partArray = new[] { Bsp(0x0100_0060u, radius: 2f) };
|
|
|
|
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
seedCellId: CellA, isStatic: true, partArray: partArray);
|
|
|
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var cells));
|
|
Assert.Equal(new[] { CellA }, cells);
|
|
Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId));
|
|
|
|
var entries = reg.GetRetailPartEntriesInCell(CellA)
|
|
.Where(e => e.EntityId == entityId).ToList();
|
|
Assert.Single(entries);
|
|
Assert.Equal(0x0100_0060u, entries[0].GfxObjId);
|
|
|
|
// No collision row anywhere: shadow_object_list gets nothing for an
|
|
// object with no part to contribute to it (Contract B).
|
|
Assert.Empty(reg.GetOwnerCells(entityId));
|
|
}
|
|
|
|
[Fact]
|
|
public void RegisterMultiPart_EmptyShapesAndNoPartArray_StillDeregisters()
|
|
{
|
|
// Byte-for-byte prior behavior: a caller that supplies neither
|
|
// collision shapes nor a part array is a genuine deregistration, not
|
|
// a render-only registration.
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint entityId = 0x51u;
|
|
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0061u) };
|
|
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
seedCellId: CellA, isStatic: true, partArray: parts);
|
|
Assert.True(reg.TryGetRetailCellArray(entityId, out _));
|
|
|
|
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
seedCellId: CellA, isStatic: true);
|
|
|
|
Assert.False(reg.TryGetRetailCellArray(entityId, out var cells));
|
|
Assert.Empty(cells);
|
|
Assert.Empty(reg.GetOwnerCells(entityId));
|
|
Assert.Empty(reg.GetRetailPartEntriesInCell(CellA));
|
|
}
|
|
|
|
[Fact]
|
|
public void RegisterMultiPart_RenderOnly_KeepWhenEmptyPreservesThePriorRegistration()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint entityId = 0x52u;
|
|
IReadOnlyList<ShadowShape> partArray = new[] { Bsp(0x0100_0062u, radius: 2f) };
|
|
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
seedCellId: CellA, isStatic: true, partArray: partArray);
|
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
|
|
Assert.Equal(new[] { CellA }, before);
|
|
|
|
// landblockId 0 (no seedCellId either) => DeriveOutdoorSeed returns 0
|
|
// => the retail keep-when-empty gate (pc:283540): neither product is
|
|
// touched.
|
|
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, landblockId: 0u,
|
|
isStatic: true, partArray: partArray);
|
|
|
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
|
|
Assert.Equal(before, after);
|
|
Assert.NotEmpty(reg.GetRetailPartEntriesInCell(CellA));
|
|
}
|
|
|
|
[Fact]
|
|
public void RegisterMultiPart_RenderOnly_DeregisterClearsEveryProduct()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint entityId = 0x53u;
|
|
IReadOnlyList<ShadowShape> partArray = new[] { Bsp(0x0100_0063u, radius: 2f) };
|
|
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
seedCellId: CellA, isStatic: true, partArray: partArray);
|
|
Assert.True(reg.TryGetRetailCellArray(entityId, out _));
|
|
|
|
reg.Deregister(entityId);
|
|
|
|
Assert.False(reg.TryGetRetailCellArray(entityId, out var cells));
|
|
Assert.Empty(cells);
|
|
Assert.Empty(reg.GetRetailPartEntriesInCell(CellA));
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdatePosition_RenderOnly_RecomputesTheRetailCellArrayAtTheNewPosition()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint entityId = 0x54u;
|
|
IReadOnlyList<ShadowShape> partArray = new[] { Bsp(0x0100_0064u, radius: 2f) };
|
|
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
seedCellId: CellA, isStatic: true, partArray: partArray);
|
|
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);
|
|
Assert.DoesNotContain(
|
|
reg.GetRetailPartEntriesInCell(CellA),
|
|
e => e.EntityId == entityId);
|
|
var movedEntries = reg.GetRetailPartEntriesInCell(CellB)
|
|
.Where(e => e.EntityId == entityId).ToList();
|
|
Assert.Single(movedEntries);
|
|
Assert.Equal(0x0100_0064u, movedEntries[0].GfxObjId);
|
|
// Still no collision row after the move.
|
|
Assert.Empty(reg.GetOwnerCells(entityId));
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Chunk 5 closeout: a streaming reflood (RefloodOwnerForLandblock, the
|
|
// path landblock replacement commits and the Content builder's
|
|
// post-publication reflood both take) is retail's recalc_cross_cells
|
|
// over the SAME CPartArray, so the retail render product survives it.
|
|
// Before this pin the reflood re-registered from the collision shapes
|
|
// only and every reflooded owner vanished from the walk.
|
|
// -------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void RefloodLandblock_KeepsTheRetailProductOfACollisionOwnerWithAPartArray()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint entityId = 0x60u;
|
|
IReadOnlyList<ShadowShape> collision = new[] { Bsp(0x0100_0070u, radius: 2f) };
|
|
IReadOnlyList<ShadowShape> parts = new[]
|
|
{
|
|
Bsp(0x0100_0070u, radius: 2f),
|
|
Bsp(0x0100_0071u, localPosition: new Vector3(0.5f, 0f, 0f), radius: 1f),
|
|
};
|
|
|
|
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, collision,
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
seedCellId: CellA, isStatic: true, partArray: parts);
|
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
|
|
var beforeEntries = reg.GetRetailPartEntriesInCell(CellA)
|
|
.Where(e => e.EntityId == entityId).Select(e => e.GfxObjId).ToList();
|
|
Assert.Equal(new[] { 0x0100_0070u, 0x0100_0071u }, beforeEntries);
|
|
|
|
reg.RefloodLandblock(LbId);
|
|
|
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
|
|
Assert.Equal(before, after);
|
|
Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId));
|
|
var afterEntries = reg.GetRetailPartEntriesInCell(CellA)
|
|
.Where(e => e.EntityId == entityId).Select(e => e.GfxObjId).ToList();
|
|
Assert.Equal(beforeEntries, afterEntries);
|
|
// The collision product is untouched by the reflood as well.
|
|
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
|
|
}
|
|
|
|
[Fact]
|
|
public void RefloodLandblock_KeepsARenderOnlyOwner()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint entityId = 0x61u;
|
|
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0072u, radius: 2f) };
|
|
|
|
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
seedCellId: CellA, isStatic: true, partArray: parts);
|
|
Assert.True(reg.TryGetRetailCellArray(entityId, out _));
|
|
|
|
reg.RefloodLandblock(LbId);
|
|
|
|
// Still registered, still render-only, still in its cell: the empty
|
|
// collision shape list plus the retained part array is the
|
|
// render-only registration, not the "nothing supplied" deregistration.
|
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var cells));
|
|
Assert.Equal(new[] { CellA }, cells);
|
|
var entries = reg.GetRetailPartEntriesInCell(CellA)
|
|
.Where(e => e.EntityId == entityId).ToList();
|
|
Assert.Single(entries);
|
|
Assert.Equal(0x0100_0072u, entries[0].GfxObjId);
|
|
Assert.Empty(reg.GetOwnerCells(entityId));
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// S2 review fix round (2026-09-03): lifetime symmetry of the retail
|
|
// render product. remove_shadows_from_cells (0x00511230) removes the
|
|
// CShadowObj row AND the AddPartsShadow rows in one transaction, and
|
|
// recurses through children — every acdream path that drops one product
|
|
// drops the other.
|
|
// -------------------------------------------------------------------
|
|
|
|
private static int CountRows(ShadowObjectRegistry reg, uint cellId, uint entityId)
|
|
=> reg.GetRetailPartEntriesInCell(cellId).Count(e => e.EntityId == entityId);
|
|
|
|
[Fact]
|
|
public void Suspend_ClearsTheRetailProduct_AndTheUnsuspendingMoveRepublishesIt()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint entityId = 0x70u;
|
|
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0080u, radius: 2f) };
|
|
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
seedCellId: CellA, isStatic: false, partArray: parts);
|
|
Assert.Equal(1, CountRows(reg, CellA, entityId));
|
|
|
|
Assert.True(reg.Suspend(entityId));
|
|
|
|
Assert.False(reg.TryGetRetailCellArray(entityId, out _));
|
|
Assert.Equal(0, CountRows(reg, CellA, entityId));
|
|
Assert.Empty(reg.GetOwnerCells(entityId));
|
|
// The route and part array are retained for the republish.
|
|
Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId));
|
|
|
|
// A .None commit republishes at the RETAINED (suspended) cells for
|
|
// both products — the un-suspend path.
|
|
reg.CommitSetPosition(
|
|
entityId, Pos, Quaternion.Identity,
|
|
seedCellId: CellA, worldOffsetX: OffX, worldOffsetY: OffY,
|
|
action: PhysicsShadowCommitAction.None,
|
|
crossCellIds: ImmutableArray<uint>.Empty);
|
|
|
|
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
|
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var cells));
|
|
Assert.Equal(new[] { CellA }, cells);
|
|
Assert.Equal(1, CountRows(reg, CellA, entityId));
|
|
}
|
|
|
|
[Fact]
|
|
public void AttachChild_AndDetachChild_AdvanceTheMutationRevision()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint rootId = 0x71u;
|
|
const uint childId = 0x72u;
|
|
IReadOnlyList<ShadowShape> rootParts = new[] { Bsp(0x0100_0081u, radius: 2f) };
|
|
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
seedCellId: CellA, isStatic: false, partArray: rootParts);
|
|
|
|
ulong before = reg.MutationRevision;
|
|
Assert.True(reg.AttachChild(childId, rootId, new[] { Bsp(0x0100_0082u) }));
|
|
Assert.True(reg.MutationRevision > before);
|
|
|
|
before = reg.MutationRevision;
|
|
Assert.True(reg.DetachChild(childId));
|
|
Assert.True(reg.MutationRevision > before);
|
|
}
|
|
|
|
[Fact]
|
|
public void RegisterMultiPart_OfAnAttachedChild_KeepsInheritingTheRootsCells()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint rootId = 0x73u;
|
|
const uint childId = 0x74u;
|
|
IReadOnlyList<ShadowShape> rootParts = new[] { Bsp(0x0100_0083u, radius: 2f) };
|
|
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
seedCellId: CellA, isStatic: false, partArray: rootParts);
|
|
Assert.True(reg.AttachChild(childId, rootId, new[] { Bsp(0x0100_0084u) }));
|
|
Assert.Equal(1, CountRows(reg, CellA, childId));
|
|
|
|
// An appearance update re-registers the child at its own (different)
|
|
// position with a NEW part array: retail never floods a child, so it
|
|
// keeps the root's CELLARRAY and only its parts change.
|
|
IReadOnlyList<ShadowShape> newParts = new[] { Bsp(0x0100_0085u, radius: 1f) };
|
|
reg.RegisterMultiPart(childId, new Vector3(42f, 12f, 50f), Quaternion.Identity, newParts,
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
seedCellId: CellB, isStatic: false, partArray: newParts);
|
|
|
|
Assert.True(reg.TryGetRetailCellArray(childId, out var childCells));
|
|
Assert.Equal(new[] { CellA }, childCells);
|
|
Assert.Equal(0, CountRows(reg, CellB, childId));
|
|
var rows = reg.GetRetailPartEntriesInCell(CellA).Where(e => e.EntityId == childId).ToList();
|
|
Assert.Single(rows);
|
|
Assert.Equal(0x0100_0085u, rows[0].GfxObjId);
|
|
}
|
|
|
|
[Fact]
|
|
public void RemoveLandblock_ClearsTheRetailProduct_IncludingRenderOnlyStatics()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint collidingId = 0x75u;
|
|
const uint renderOnlyId = 0x76u;
|
|
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0086u, radius: 2f) };
|
|
reg.RegisterMultiPart(collidingId, Pos, Quaternion.Identity, parts,
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
seedCellId: CellA, isStatic: true, partArray: parts);
|
|
reg.RegisterMultiPart(renderOnlyId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
seedCellId: CellA, isStatic: true, partArray: parts);
|
|
Assert.Equal(1, CountRows(reg, CellA, renderOnlyId));
|
|
|
|
reg.RemoveLandblock(LbId);
|
|
|
|
Assert.False(reg.TryGetRetailCellArray(collidingId, out _));
|
|
Assert.False(reg.TryGetRetailCellArray(renderOnlyId, out _));
|
|
Assert.Empty(reg.GetRetailPartEntriesInCell(CellA));
|
|
// Both statics ended with their landblock — nothing left to suspend.
|
|
Assert.False(reg.Suspend(collidingId));
|
|
Assert.False(reg.Suspend(renderOnlyId));
|
|
}
|
|
|
|
[Fact]
|
|
public void RetireOwnerFromLandblock_PrunesTheRetailRowsOfANonRootedOwner()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint entityId = 0x77u;
|
|
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0087u, radius: 2f) };
|
|
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
seedCellId: CellA, isStatic: false, partArray: parts);
|
|
Assert.Equal(1, CountRows(reg, CellA, entityId));
|
|
|
|
reg.RetireOwnerFromLandblock(entityId, LbId);
|
|
|
|
Assert.Equal(0, CountRows(reg, CellA, entityId));
|
|
Assert.False(reg.TryGetRetailCellArray(entityId, out _));
|
|
Assert.Empty(reg.GetOwnerCells(entityId));
|
|
// A live owner survives the prefix retirement (it is still logically
|
|
// alive for the reload reflood), so it can still be suspended.
|
|
Assert.True(reg.Suspend(entityId));
|
|
}
|
|
|
|
[Fact]
|
|
public void ReplaceMultiPartPayload_WithAnEmptyPartArray_KeepsThePriorRows()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint entityId = 0x78u;
|
|
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0088u, radius: 2f) };
|
|
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
seedCellId: CellA, isStatic: false, partArray: parts);
|
|
|
|
reg.ReplaceMultiPartPayload(entityId, Pos, Quaternion.Identity, parts,
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
seedCellId: CellA, isStatic: false, partArray: Array.Empty<ShadowShape>());
|
|
|
|
// Never a cell-array claim with no drawable rows behind it.
|
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var cells));
|
|
Assert.Equal(new[] { CellA }, cells);
|
|
Assert.Equal(1, CountRows(reg, CellA, entityId));
|
|
}
|
|
|
|
[Fact]
|
|
public void CommitSetPosition_None_MovesARenderOnlyOwnerToItsDestinationCell()
|
|
{
|
|
var reg = new ShadowObjectRegistry();
|
|
const uint entityId = 0x79u;
|
|
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0089u, radius: 1f) };
|
|
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
|
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
|
seedCellId: CellA, isStatic: false, partArray: parts);
|
|
Assert.Equal(1, CountRows(reg, CellA, entityId));
|
|
|
|
// A render-only owner has no collision cells to republish from, so
|
|
// its "cell did not change" commit would otherwise freeze it at its
|
|
// registration cells forever (retail review F3): it travels with its
|
|
// destination cell instead (AD-117).
|
|
reg.CommitSetPosition(
|
|
entityId, new Vector3(42f, 12f, 50f), Quaternion.Identity,
|
|
seedCellId: CellB, worldOffsetX: OffX, worldOffsetY: OffY,
|
|
action: PhysicsShadowCommitAction.None,
|
|
crossCellIds: ImmutableArray<uint>.Empty);
|
|
|
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var cells));
|
|
Assert.Equal(new[] { CellB }, cells);
|
|
Assert.Equal(0, CountRows(reg, CellA, entityId));
|
|
Assert.Equal(1, CountRows(reg, CellB, entityId));
|
|
Assert.Empty(reg.GetOwnerCells(entityId));
|
|
}
|
|
}
|