fix(physics): port ordered part-array building transit
This commit is contained in:
parent
4e4e8e8253
commit
1e645ee404
7 changed files with 531 additions and 6 deletions
|
|
@ -1143,12 +1143,20 @@ composition/pass facades. The superseded `PortalVisibilityBuilder`,
|
|||
OVERHAUL S5-c5; production has one visibility graph and one walk owner.
|
||||
**Campaign OVERHAUL S2 (chunks 1-5, closed 2026-09-03) gave render
|
||||
and physics cross-cell membership one canonical owner:**
|
||||
`ShadowObjectRegistry` computes retail's exact CELLARRAY
|
||||
`ShadowObjectRegistry` computes the retail CELLARRAY (with the source and
|
||||
boundary residuals tracked in AD-117/AP-159)
|
||||
(`calc_cross_cells_static`/`add_shadows_to_cells`, Contract A/B) once per
|
||||
registration/move transaction and retains it both as collision rows
|
||||
(`GetOwnerCells`) and per-cell `RetailPartEntry` render rows
|
||||
(`GetRetailPartEntriesInCell`) — one flood drives both, never two
|
||||
independent ones. The equipped-child controller publishes every DAT-resolved
|
||||
independent ones. The part-array outdoor building bridge uses the distinct
|
||||
retail reciprocal-plane sphere/box and destination-containment gates, then
|
||||
immediately expands each admitted destination using the complete part array
|
||||
before the next building portal, even for a duplicate destination. It shares
|
||||
the existing growing worklist and outside latch; ordinary sphere/player
|
||||
building transit remains separate. Positive malformed reciprocal indices
|
||||
fail at the managed content-integrity boundary (AP-159), not a second owner.
|
||||
The equipped-child controller publishes every DAT-resolved
|
||||
effective Setup part (after `AnimPartChanged`) through the same strict
|
||||
prepared-collision publisher used by ordinary live hydration before it calls
|
||||
`AttachChild`; children still inherit the parent's CELLARRAY and acquire only
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -952,6 +952,19 @@ source approximation remains open. Verification in
|
|||
part-array building bridge and immediate transit ordering, ordinary sphere
|
||||
callers unchanged. No geometry fix or C1a/G4 PASS yet; FPS deferred.
|
||||
|
||||
**C1a/AP159 production bridge review-closed (packet§67):** distinct building
|
||||
part-array box/containment gates and immediate full-array transit now replace
|
||||
only the wrong sphere-only caller. Focused52/52, ordering omission mutation
|
||||
fails as intended, exact restoration52/52; six Release builds0W0E. Two
|
||||
explicit test-fixture corrections, no production repair after its first patch;
|
||||
narrow independent retail/ordering and production/coverage PASS. AP159 narrows
|
||||
in this implementation commit and records the malformed-positive-reciprocal
|
||||
InvalidDataException adaptation. Its two boundary ties and AD117 remain.
|
||||
Next: integrate the original nonempty-shell-guarded witness, establish actual
|
||||
three-cell/21-entry installed membership and deterministic product, then the
|
||||
remaining final gates. No original golden relaxed, no known retail leak/FPS
|
||||
change, no C1a/G4 pass yet. Evidence: `c1a-building-bridge-verification.md`.
|
||||
|
||||
## 14. Risk register (condensed)
|
||||
|
||||
- **R1 second scene graph.** Graph records own only topology/membership facts;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
# C1a / AP159 part-array building bridge verification
|
||||
|
||||
2026-09-05, campaign base4e4e8e825. Implementation contract:
|
||||
`c1a-building-bridge-repair-contract.md`. This is the separate production
|
||||
repair supported by the preserved red authored-input diagnostic, not a
|
||||
third fix to that diagnostic. No known retail cathedral leak, drawing-sphere
|
||||
source, shared epsilon predicate, ordinary player sphere route or FPS change.
|
||||
|
||||
## Candidate and discriminating evidence
|
||||
|
||||
One new private helper in CellTransit and one changed caller in
|
||||
BuildShadowCellSetFromParts. Ordered building portals resolve loaded
|
||||
destinations and their reciprocal planes through existing graph/prepared
|
||||
access. The supplied matching parts use the inclusive building sphere-side
|
||||
gate, reciprocal CROSSING-or-SAME box gate and destination containment.
|
||||
An admitted destination receives immediate complete-array indoor transit
|
||||
even if its candidate ID already existed; the existing outer walk and
|
||||
outside-expansion latch remain the only traversal owners.
|
||||
|
||||
The positive out-of-range reciprocal guard throws InvalidDataException only
|
||||
after existing negative/unavailable/rootless skips. That is the explicitly
|
||||
authorized managed corruption policy, not native trusted-metadata behavior;
|
||||
it must remain recorded with AP159 in the production commit.
|
||||
|
||||
Lead independently read the complete source/test delta, the recovered
|
||||
named/paired native contract, and raw test outputs. Paired executable SHA
|
||||
006FFEADC5D679C871497112A5BD1F87714D0E273E2166BAE5052DDE369297B1.
|
||||
The key bridge is006B52A0→0052C680, not sphere0052C5D0; immediate append/call
|
||||
0052C802/0052C812 resolves virtual+7C to0052CAE0. Predicate and raw authored
|
||||
evidence are retained in `c1a-bridge-native-contract.md` and
|
||||
`c1a-authored-input-diagnostic-verification.md`.
|
||||
|
||||
Candidate production SHA256:
|
||||
B4AFA5FE2654E8AEA2961E2008DC253F368D153180B73B2D1D4A7B5003040591.
|
||||
Candidate Core test SHA256:
|
||||
EC835A9865DDD6CBFC046EF5B3C02739395348336888C445655E5AC94DDF4EAA.
|
||||
Ordinary CheckBuildingTransit overloads and existing tests are unchanged.
|
||||
|
||||
Artifacts: `artifacts/s5-c1a-building-parts-20260905-r1/` in the campaign
|
||||
worktree. Lead independently parsed these TRX counters:
|
||||
|
||||
| Run | Passed / failed / skipped | Meaning |
|
||||
|---|---|---|
|
||||
| pre-fix |12/15/0|Original production; includes one incorrect outside-origin fixture.|
|
||||
| pre-fix-validated |13/14/0|Corrected fixture; original production still fails bridge/ordering/side/guard checks.|
|
||||
| fixed-affected |51/1/0|Production repair; one cheap-equality fixture also reached the separately registered WhichSide tie.|
|
||||
| fixed-isolated-affected |52/0/0|Both cheap equalities and adjacent representable rejects now isolated from that unchanged tie.|
|
||||
| ordering-mutation |0/1/0|Omitting only immediate transit produces A,B,C rather than expected A,C,B.|
|
||||
| restored-affected |52/0/0|Exact production restoration, same source hash, final affected lane PASS.|
|
||||
|
||||
The two harness corrections are explicit and exhausted: move the test part
|
||||
origin itself to the outside-cell boundary while retaining a local box;
|
||||
then place the cheap-equality point-box strictly within the existing epsilon
|
||||
band, at the supplied float-stored sphere's nearest point. Sphere centers
|
||||
remain exactly at both signed padded-radius limits, and adjacent
|
||||
representable outside values still reject. No production correction was
|
||||
needed after its first patch. Any further unexpected source/test failure
|
||||
returns a finding rather than another hidden correction.
|
||||
|
||||
The27 new cases cover graph and prepared containment, decoy polygon ID7 at
|
||||
index0 versus reciprocal ID42 at index1, both sides and opposite box reject,
|
||||
both cheap equalities, later-part admission, complete-array immediate
|
||||
expansion, duplicates/shared outside latch, first-hit break, malformed
|
||||
reciprocals, empty/unloaded/rootless guards. The25 existing sphere, indoor-box
|
||||
and registration cases remain in the affected lane. The ordering mutation
|
||||
was a deliberate discriminator, not an unexpected failure or fix round.
|
||||
|
||||
## Frozen return and narrow review
|
||||
|
||||
Implementer returned execution ownership with no live build/test handle.
|
||||
All six Release builds exit0,0W0E. Lead independently checked final TRX52/52,
|
||||
zero skipped, and exact restored production/test hashes above. Independent
|
||||
retail/ordering and production/coverage review returned PASS with no findings;
|
||||
reviewer re-read named bodies and rehashed paired executable/windows. Its
|
||||
return preceded the last restored test; lead separately verified that final
|
||||
result. Two absent-private-helper failures in the pre-fix baseline are
|
||||
structural only; separate public-entry geometry/order failures establish the
|
||||
behavioral discrepancy. No further source/test correction followed the two
|
||||
explicit harness corrections.
|
||||
|
||||
| Sealed artifact | SHA256 |
|
||||
|---|---|
|
||||
|pre-fix-validated.trx|FED5D47A793D35BF8C13408AFD5A5941C2BD7ADE5237A2824334F636EF5ADDF2|
|
||||
|ordering-mutation.trx|913A96897126B6EDDD55A063838BC24A9E8DDF14084A292667F1D37FF4AB37E4|
|
||||
|restored-build.log|09E89BFCEBCDF0AA181076C4B20A94D39D7BA2781788B2506D6E3F4A62F405F4|
|
||||
|restored-affected.trx|8A4E12C61C45FB4F4A000EC1B13A3D9E8C575D9C7E67AF05DE5708DAADEF97F1|
|
||||
|
||||
AP159's building clause is narrowed and its managed corruption guard recorded
|
||||
in the same implementation commit; both boundary ties and AD117 remain open.
|
||||
Architecture retains one canonical membership owner. No full-suite or
|
||||
installed-DAT membership result is claimed here yet. The separate original
|
||||
geometry witness retains the three-cell/21-entry assertions and unpinned
|
||||
placeholder; its later execution needs the explicit lead release.
|
||||
|
||||
Clients/debugger were absent and ACE UDP9000/PID13340 present at the lead's
|
||||
20:24 precheck. No graphical launch belongs to this chunk. G4 remains
|
||||
unpassed; real installed membership, final lifecycle/stability and owner
|
||||
acceptance remain required. No main merge.
|
||||
|
|
@ -5216,3 +5216,30 @@ Exact inputs, raw hashes, all edges and limits:
|
|||
`c1a-authored-input-diagnostic-verification.md`. No production geometry or
|
||||
graphical gate changed; clients/debugger absent, ACE up. C1a/G4 unpassed,
|
||||
known retail leakage unchanged, FPS deferred, no main merge.
|
||||
|
||||
## 67. C1a/AP159 distinct building bridge review-closed — 2026-09-05
|
||||
|
||||
The separately bounded production repair replaces only the part-array
|
||||
outdoor caller's sphere helper. Reciprocal-side inclusive sphere, authored
|
||||
box and destination containment gates now precede immediate complete-array
|
||||
destination transit, including duplicates, before the next building portal.
|
||||
Existing sphere/player overloads, indoor ties, shared primitives, residency
|
||||
and single registry ownership remain unchanged. AP159's building clause is
|
||||
narrowed in the same commit; its positive malformed-reciprocal integrity guard
|
||||
is explicitly recorded as a managed adaptation, not native trusted metadata.
|
||||
|
||||
Original production gives13/14 on the corrected27-case baseline; the fixed
|
||||
affected lane52/52 passes. One deliberate immediate-transit omission fails
|
||||
expected A,C,B versus actual A,B,C. Exact restored source and final52/52
|
||||
confirmed by lead; all six Release builds0W0E. Two test-harness corrections
|
||||
are preserved and documented, not hidden green retries. Independent narrow
|
||||
retail/ordering and production/coverage review PASS, no source/test findings.
|
||||
Full evidence and hashes: `c1a-building-bridge-verification.md`.
|
||||
|
||||
This closes the bridge repair's focused review, not the actual installed
|
||||
geometry witness or G4. AD117 and both AP159 boundary ties remain open.
|
||||
The original witness's three-cell/21-entry assertions and placeholder remain;
|
||||
only a nonempty actual F4180104 mesh guard was prepared under its separate
|
||||
write-only contract. Lead owns its explicit capture/mutation release and
|
||||
final campaign gates. No client launch, known retail leakage/FPS change or
|
||||
main merge in this chunk.
|
||||
|
|
|
|||
|
|
@ -947,6 +947,76 @@ public static class CellTransit
|
|||
return candidates.OrderedIds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AP-159: the distinct part-array building bridge, CBuildingObj 006B52A0
|
||||
/// to CEnvCell 0052C680. Unlike the sphere/player helper, this requires
|
||||
/// BOTH box gates and immediately expands each admitted destination.
|
||||
/// </summary>
|
||||
private static void CheckBuildingTransitFromParts(
|
||||
PhysicsDataCache cache,
|
||||
BuildingPhysics building,
|
||||
IReadOnlyList<ShadowPartBox> worldParts,
|
||||
IReadOnlyList<Sphere> worldPartSpheres,
|
||||
ICollection<uint> candidates,
|
||||
uint seedCellId,
|
||||
Vector3 blockOrigin,
|
||||
ref bool outdoorAdded)
|
||||
{
|
||||
int partCount = Math.Min(worldParts.Count, worldPartSpheres.Count);
|
||||
if (partCount == 0) return;
|
||||
|
||||
foreach (BldPortalInfo buildingPortal in building.Portals)
|
||||
{
|
||||
int reciprocalIndex = buildingPortal.OtherPortalId;
|
||||
if (reciprocalIndex < 0) continue;
|
||||
|
||||
RecordUnionOnlyProbe(candidates, buildingPortal.OtherCellId);
|
||||
CellPhysics? destination = cache.GetCellStruct(buildingPortal.OtherCellId);
|
||||
if (destination is null || !CollisionTraversal.HasCellContainment(cache, destination))
|
||||
continue; // GetVisible only: no unloaded-cell hint or load request.
|
||||
|
||||
// Managed content-integrity guard; native indexes trusted metadata.
|
||||
if (reciprocalIndex >= destination.Portals.Count)
|
||||
throw new InvalidDataException(
|
||||
$"Building portal to cell 0x{buildingPortal.OtherCellId:X8} references reciprocal portal {reciprocalIndex}, but the destination has {destination.Portals.Count} portals.");
|
||||
|
||||
PortalInfo reciprocal = destination.Portals[reciprocalIndex];
|
||||
if (!TryGetPortalPlane(destination, reciprocalIndex, reciprocal, out Plane plane))
|
||||
continue;
|
||||
|
||||
for (int i = 0; i < partCount; i++)
|
||||
{
|
||||
Sphere sphere = worldPartSpheres[i];
|
||||
float paddedRadius = sphere.Radius + FEpsilon;
|
||||
Vector3 center = Vector3.Transform(sphere.Origin, destination.InverseWorldTransform);
|
||||
float distance = Vector3.Dot(center, plane.Normal) + plane.D;
|
||||
// Inclusive BUILDING gate: opposite direction to the indoor exit gate.
|
||||
bool passesCheapReject = reciprocal.PortalSide
|
||||
? distance <= paddedRadius
|
||||
: distance >= -paddedRadius;
|
||||
if (!passesCheapReject) continue;
|
||||
|
||||
worldParts[i].RefitToLocal(destination.InverseWorldTransform, out Vector3 min, out Vector3 max);
|
||||
BSPQuery.PlaneSide side = BSPQuery.ClassifyBox(plane, min, max);
|
||||
BSPQuery.PlaneSide sameSide = reciprocal.PortalSide
|
||||
? BSPQuery.PlaneSide.Negative : BSPQuery.PlaneSide.Positive;
|
||||
if (side != BSPQuery.PlaneSide.Straddle && side != sameSide)
|
||||
continue;
|
||||
if (!CollisionTraversal.BoxIntersectsCell(cache, destination, min, max))
|
||||
continue;
|
||||
|
||||
candidates.Add(buildingPortal.OtherCellId);
|
||||
// 0052C802 -> 0052C812: this call follows even a deduplicated Add,
|
||||
// uses every part, and precedes the next authored building portal.
|
||||
FindTransitCellsBox(cache, destination, buildingPortal.OtherCellId,
|
||||
worldParts, worldPartSpheres, candidates, out bool exitOutside);
|
||||
if (exitOutside && !outdoorAdded)
|
||||
outdoorAdded = AddAllOutsideCellsFromParts(worldParts, seedCellId, blockOrigin, candidates);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #334 (2026-08-06). Registration-side cell-set builder for a
|
||||
/// PHYSICS-BSP-BEARING object — retail's OTHER cross-cell algorithm, which
|
||||
|
|
@ -1095,9 +1165,9 @@ public static class CellTransit
|
|||
var building = cache.GetBuilding(cellId);
|
||||
if (building is not null && sphereCount > 0)
|
||||
{
|
||||
CheckBuildingTransit(
|
||||
cache, building, worldPartSpheres!, sphereCount,
|
||||
candidates, out _);
|
||||
CheckBuildingTransitFromParts(
|
||||
cache, building, worldParts, worldPartSpheres!,
|
||||
candidates, seedCellId, blockOrigin, ref outdoorAdded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,309 @@
|
|||
using System.Collections;
|
||||
using System.Collections.Immutable;
|
||||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
public sealed class CellTransitBuildingPartsTests
|
||||
{
|
||||
private const uint Prefix = 0xA9B40000u;
|
||||
private const uint Outside = Prefix | 1u;
|
||||
private const uint A = Prefix | 0x100u;
|
||||
private const uint B = Prefix | 0x101u;
|
||||
private const uint C = Prefix | 0x102u;
|
||||
private const float Epsilon = 0.000199999995f;
|
||||
private static readonly Vector3 Origin = new(12, 12, 0);
|
||||
private static readonly Plane Permissive = new(Vector3.UnitZ, 100);
|
||||
private static readonly PortalSpec DefaultPortal = new(0xFFFF, new Plane(Vector3.UnitX, 0), 2);
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void CathedralLikeSphereHit_BoxBelowDestinationPlane_RejectsOnlyPartRoute(bool prepared)
|
||||
{
|
||||
PhysicsDataCache cache = Cache(prepared);
|
||||
CellPhysics cell = Cell(A, prepared, new Plane(Vector3.UnitZ, -21.8948f), DefaultPortal);
|
||||
cache.RegisterCellStructForTest(A, cell);
|
||||
BuildingPhysics building = Building(new BldPortalInfo(A, 0, 0));
|
||||
cache.RegisterBuildingForTest(Outside, building);
|
||||
ShadowPartBox[] boxes = [Box(new(-1, -1, 16.86699f), new(1, 1, 21.894302f))];
|
||||
Sphere[] spheres = [SphereAt(new(0, 0, 19.380646f), 3.93215f)];
|
||||
|
||||
var sphereCells = new CellArray();
|
||||
CellTransit.CheckBuildingTransit(cache, building, spheres, 1, sphereCells, out bool hits);
|
||||
Assert.True(hits);
|
||||
Assert.Equal(new[] { A }, sphereCells.OrderedIds);
|
||||
Assert.Equal(new[] { Outside }, Flood(cache, boxes, spheres));
|
||||
if (prepared)
|
||||
{
|
||||
Assert.Null(cell.PortalPolygons);
|
||||
Assert.Equal((ushort)42, cell.Portals[0].PolygonId);
|
||||
Assert.Equal(1, cell.FlatTopology!.Portals[0].PolygonIndex);
|
||||
Assert.Equal((ushort)7, cell.FlatPortalPolygons!.Polygons[0].Id);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, -1, false)]
|
||||
[InlineData(false, 0, true)]
|
||||
[InlineData(false, 1, true)]
|
||||
[InlineData(true, -1, true)]
|
||||
[InlineData(true, 0, true)]
|
||||
[InlineData(true, 1, false)]
|
||||
public void ReciprocalSide_AdmitsOnlyCrossingOrSameSide(bool negativeSide, int boxSide, bool admitted)
|
||||
{
|
||||
PhysicsDataCache cache = Cache(true);
|
||||
ushort flags = negativeSide ? (ushort)0 : (ushort)2;
|
||||
cache.RegisterCellStructForTest(A, Cell(A, true, Permissive,
|
||||
new PortalSpec(0xFFFF, new Plane(Vector3.UnitZ, 0), flags)));
|
||||
// Opposite building flags must not replace the destination's reciprocal side.
|
||||
cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, 0, (ushort)(flags ^ 2))));
|
||||
float center = boxSide * 0.5f;
|
||||
ShadowPartBox[] boxes = [Box(new(-0.1f, -0.1f, center - 0.1f), new(0.1f, 0.1f, center + 0.1f))];
|
||||
IReadOnlyList<uint> result = Flood(cache, boxes, [SphereAt(Vector3.Zero, 1)]);
|
||||
Assert.Equal(admitted ? new[] { Outside, A } : new[] { Outside }, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(true, true)]
|
||||
public void BuildingCheapGate_EqualityAdmits_AdjacentOutsideFloatRejects(bool negativeSide, bool beyond)
|
||||
{
|
||||
PhysicsDataCache cache = Cache(true);
|
||||
cache.RegisterCellStructForTest(A, Cell(A, true, Permissive,
|
||||
new PortalSpec(0xFFFF, new Plane(Vector3.UnitZ, 0), negativeSide ? (ushort)0 : (ushort)2)));
|
||||
cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, 0, 0)));
|
||||
float padded = 0.5f + Epsilon;
|
||||
float z = negativeSide ? padded : -padded;
|
||||
if (beyond) z = negativeSide ? MathF.BitIncrement(z) : MathF.BitDecrement(z);
|
||||
// Nearest point of the float-stored equality sphere. It is strictly inside
|
||||
// the box epsilon band, isolating cheap equality from the open WhichSide +epsilon tie.
|
||||
float boxZ = negativeSide ? padded - 0.5f : 0.5f - padded;
|
||||
Assert.InRange(MathF.Abs(boxZ), 0f, MathF.BitDecrement(Epsilon));
|
||||
ShadowPartBox[] boxes = [Box(new(0, 0, boxZ), new(0, 0, boxZ))];
|
||||
Assert.Equal(beyond ? new[] { Outside } : new[] { Outside, A },
|
||||
Flood(cache, boxes, [SphereAt(new(0, 0, z), 0.5f)]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EarlierPartRejects_LaterPartAdmits_WithoutChangingTheirOrder()
|
||||
{
|
||||
PhysicsDataCache cache = Cache(true);
|
||||
cache.RegisterCellStructForTest(A, Cell(A, true, Permissive,
|
||||
new PortalSpec(0xFFFF, new Plane(Vector3.UnitZ, 0), 2)));
|
||||
cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, 0, 0)));
|
||||
ShadowPartBox[] boxes = [Box(new(-0.1f, -0.1f, -2), new(0.1f, 0.1f, -1)),
|
||||
Box(new(-0.1f, -0.1f, 1), new(0.1f, 0.1f, 2))];
|
||||
Sphere[] spheres = [SphereAt(Vector3.Zero, 3), SphereAt(Vector3.Zero, 3)];
|
||||
Assert.Equal(new[] { Outside }, Flood(cache, boxes[..1], spheres[..1]));
|
||||
Assert.Equal(new[] { Outside, A }, Flood(cache, boxes, spheres));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildingDestination_ExpandsBeforeNextAuthoredPortal()
|
||||
{
|
||||
PhysicsDataCache cache = Cache(true);
|
||||
cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, DefaultPortal,
|
||||
new PortalSpec(0x102, new Plane(Vector3.UnitX, 0), 0)));
|
||||
cache.RegisterCellStructForTest(B, Cell(B, true, Permissive, DefaultPortal));
|
||||
cache.RegisterCellStructForTest(C, Cell(C, true, Permissive));
|
||||
cache.RegisterBuildingForTest(Outside, Building(new(A, 0, 0), new(B, 0, 0)));
|
||||
Assert.Equal(new[] { Outside, A, C, B }, Flood(cache, [SmallBox()], [SphereAt(Vector3.Zero, 1)]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImmediateDestinationExpansion_ReceivesAllParts_NotJustTheBuildingHit()
|
||||
{
|
||||
PhysicsDataCache cache = Cache(true);
|
||||
cache.RegisterCellStructForTest(A, Cell(A, true, Permissive,
|
||||
new PortalSpec(0xFFFF, new Plane(Vector3.UnitZ, 0), 2),
|
||||
new PortalSpec(0x102, new Plane(Vector3.UnitX, 0), 0)));
|
||||
cache.RegisterCellStructForTest(B, Cell(B, true, Permissive, DefaultPortal));
|
||||
cache.RegisterCellStructForTest(C, Cell(C, true, Permissive));
|
||||
cache.RegisterBuildingForTest(Outside, Building(new(A, 0, 0), new(B, 0, 0)));
|
||||
ShadowPartBox[] boxes = [Box(new(-1, -0.1f, 0.1f), new(-0.5f, 0.1f, 0.2f)),
|
||||
Box(new(0.5f, -0.1f, 0.1f), new(1, 0.1f, 0.2f))];
|
||||
Assert.Equal(new[] { Outside, A, C, B }, Flood(cache, boxes,
|
||||
[SphereAt(Vector3.Zero, 2), SphereAt(Vector3.Zero, 2)]));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void DuplicateDestination_StillExpandsImmediately_WithSharedOutsideLatch(bool alreadyOutside)
|
||||
{
|
||||
PhysicsDataCache cache = Cache(true);
|
||||
cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, DefaultPortal,
|
||||
new PortalSpec(0x102, new Plane(Vector3.UnitX, 0), 0)));
|
||||
cache.RegisterCellStructForTest(B, Cell(B, true, Permissive, DefaultPortal));
|
||||
cache.RegisterCellStructForTest(C, Cell(C, true, Permissive));
|
||||
var candidates = new CellArray();
|
||||
candidates.Add(A);
|
||||
// This exact prefilled worklist cannot be supplied through the public entry.
|
||||
MethodInfo helper = Assert.IsAssignableFrom<MethodInfo>(typeof(CellTransit).GetMethod(
|
||||
"CheckBuildingTransitFromParts", BindingFlags.Static | BindingFlags.NonPublic));
|
||||
object[] arguments = [cache, Building(new(A, 0, 0), new(B, 0, 0)),
|
||||
new[] { SmallBox() }, new[] { SphereAt(Vector3.Zero, 1) }, candidates,
|
||||
Outside, Vector3.Zero, alreadyOutside];
|
||||
helper.Invoke(null, arguments);
|
||||
Assert.True((bool)arguments[7]);
|
||||
Assert.Equal(alreadyOutside ? new[] { A, C, B } : new[] { A, C, Outside, B }, candidates.OrderedIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstHitBreaksPartLoop_ImmediateAndOuterDestinationVisitsRemainDistinct()
|
||||
{
|
||||
PhysicsDataCache cache = Cache(true);
|
||||
cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, DefaultPortal));
|
||||
cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, 0, 0)));
|
||||
var spheres = new ReadTrackingSpheres([SphereAt(Vector3.Zero, 1), SphereAt(Vector3.Zero, 1)]);
|
||||
Assert.Equal(new[] { Outside, A }, Flood(cache, [SmallBox(), SmallBox()], spheres));
|
||||
// Building hit, immediate destination portal, then growing-array destination portal.
|
||||
Assert.Equal(new[] { 0, 0, 0 }, spheres.Reads);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1)]
|
||||
[InlineData(short.MinValue)]
|
||||
public void NegativeReciprocal_IsSkipped(short reciprocal)
|
||||
{
|
||||
PhysicsDataCache cache = Cache(true);
|
||||
cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, DefaultPortal));
|
||||
cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, reciprocal, 0)));
|
||||
Assert.Equal(new[] { Outside }, Flood(cache, [SmallBox()], [SphereAt(Vector3.Zero, 1)]));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void UnavailableOrRootlessDestination_IsSkippedBeforeReciprocalValidation(bool rootless)
|
||||
{
|
||||
PhysicsDataCache cache = Cache(true);
|
||||
if (rootless) cache.RegisterCellStructForTest(A, Cell(A, true, null, DefaultPortal));
|
||||
cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, 300, 0)));
|
||||
Assert.Equal(new[] { Outside }, Flood(cache, [SmallBox()], [SphereAt(Vector3.Zero, 1)]));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(300)]
|
||||
public void MalformedPositiveReciprocal_ThrowsExplicitContentIntegrityFailure(short reciprocal)
|
||||
{
|
||||
PhysicsDataCache cache = Cache(true);
|
||||
cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, DefaultPortal));
|
||||
cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, reciprocal, 0)));
|
||||
InvalidDataException failure = Assert.Throws<InvalidDataException>(() =>
|
||||
Flood(cache, [SmallBox()], [SphereAt(Vector3.Zero, 1)]));
|
||||
Assert.Equal($"Building portal to cell 0xA9B40100 references reciprocal portal {reciprocal}, but the destination has 1 portals.", failure.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyInputs_PreserveExistingSeedAndPartGuards()
|
||||
{
|
||||
PhysicsDataCache cache = Cache(true);
|
||||
cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, 0, 0)));
|
||||
Assert.Empty(Flood(cache, [], []));
|
||||
Assert.Equal(new[] { Outside }, Flood(cache, [SmallBox()], []));
|
||||
Assert.Empty(CellTransit.BuildShadowCellSetFromParts(cache, 0, [SmallBox()], [SphereAt(Vector3.Zero, 1)], false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnloadedActiveOutdoorCell_PreservesOutsideIdsWithoutWalkingCachedBuilding()
|
||||
{
|
||||
PhysicsDataCache cache = Cache(true, terrain: false);
|
||||
cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, DefaultPortal));
|
||||
cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, 0, 0)));
|
||||
Assert.Equal(new[] { Outside }, Flood(cache, [SmallBox()], [SphereAt(Vector3.Zero, 1)]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnloadedLaterOutdoorCandidate_DoesNotWalkItsCachedBuilding()
|
||||
{
|
||||
PhysicsDataCache cache = Cache(true);
|
||||
const uint adjacentOutside = 0xAAB40001u;
|
||||
cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, DefaultPortal));
|
||||
cache.RegisterBuildingForTest(adjacentOutside, Building(new BldPortalInfo(A, 0, 0)));
|
||||
ShadowPartBox[] boxes = [Box(new(-1, -1, -0.1f), new(1, 1, 0.1f), new Vector3(192, 12, 0))];
|
||||
IReadOnlyList<uint> result = CellTransit.BuildShadowCellSetFromParts(
|
||||
cache, Prefix | 57u, boxes, [SphereAt(new(180, 0, 0), 2)], false);
|
||||
Assert.Equal(new[] { Prefix | 57u, adjacentOutside }, result);
|
||||
}
|
||||
|
||||
private static PhysicsDataCache Cache(bool prepared, bool terrain = true)
|
||||
{
|
||||
PhysicsDataCache cache = prepared ? PhysicsDataCache.CreateProduction() : new PhysicsDataCache();
|
||||
if (terrain) cache.CellGraph.RegisterTerrain(Prefix, new TerrainSurface(new byte[81], new float[256]), Vector3.Zero);
|
||||
return cache;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<uint> Flood(PhysicsDataCache cache, IReadOnlyList<ShadowPartBox> boxes, IReadOnlyList<Sphere> spheres) =>
|
||||
CellTransit.BuildShadowCellSetFromParts(cache, Outside, boxes, spheres, isStatic: true);
|
||||
|
||||
private static BuildingPhysics Building(params BldPortalInfo[] portals) => new()
|
||||
{
|
||||
WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, Portals = portals,
|
||||
};
|
||||
|
||||
private readonly record struct PortalSpec(ushort Other, Plane Plane, ushort Flags);
|
||||
|
||||
private static CellPhysics Cell(uint id, bool prepared, Plane? containment, params PortalSpec[] specs)
|
||||
{
|
||||
CellBSPNode? root = containment is { } plane ? new CellBSPNode
|
||||
{
|
||||
Type = BSPNodeType.BPOL, SplittingPlane = plane, PosNode = null, NegNode = null,
|
||||
} : null;
|
||||
// Polygon ID 42 is deliberately at flat table index1, not index0 or42.
|
||||
var polygons = new Dictionary<ushort, ResolvedPolygon>
|
||||
{
|
||||
[7] = new() { Id = 7, Plane = new Plane(Vector3.UnitZ, -1000), Vertices = [], NumPoints = 0, SidesType = CullMode.None },
|
||||
};
|
||||
var portals = new List<PortalInfo>();
|
||||
var flatPortals = ImmutableArray.CreateBuilder<FlatEnvCellPortal>();
|
||||
for (int i = 0; i < specs.Length; i++)
|
||||
{
|
||||
ushort polygonId = checked((ushort)(42 + i));
|
||||
PortalSpec spec = specs[i];
|
||||
polygons.Add(polygonId, new ResolvedPolygon { Id = polygonId, Plane = spec.Plane, Vertices = [], NumPoints = 0, SidesType = CullMode.None });
|
||||
portals.Add(new PortalInfo(spec.Other, polygonId, spec.Flags));
|
||||
flatPortals.Add(new FlatEnvCellPortal(spec.Other, polygonId, spec.Flags, i + 1));
|
||||
}
|
||||
return new CellPhysics
|
||||
{
|
||||
SourceId = id, WorldTransform = Matrix4x4.CreateTranslation(Origin),
|
||||
InverseWorldTransform = Matrix4x4.CreateTranslation(-Origin), Resolved = [],
|
||||
CellBSP = prepared ? null : new CellBSPTree { Root = root },
|
||||
FlatContainmentBsp = FlatCollisionAssetBuilder.FlattenCellContainmentBsp(root),
|
||||
Portals = portals, PortalPolygons = prepared ? null : polygons,
|
||||
FlatPortalPolygons = new FlatPolygonTable(polygons.Values.OrderBy(p => p.Id)
|
||||
.Select(p => new FlatCollisionPolygon(p.Id, p.Plane, p.SidesType, 0, new FlatIndexRange(0, 0))).ToImmutableArray(), []),
|
||||
FlatTopology = new FlatEnvCellTopology(flatPortals.ToImmutable(), [], true),
|
||||
};
|
||||
}
|
||||
|
||||
private static Sphere SphereAt(Vector3 localCenter, float radius) => new() { Origin = Origin + localCenter, Radius = radius };
|
||||
private static ShadowPartBox SmallBox() => Box(new(-0.1f), new(0.1f));
|
||||
|
||||
private static ShadowPartBox Box(Vector3 min, Vector3 max, Vector3? worldPosition = null)
|
||||
{
|
||||
var geometry = ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 1),
|
||||
new FlatGfxObjVisualBounds(min, max, (min + max) * 0.5f, ((max - min) * 0.5f).Length(), (max - min) * 0.5f));
|
||||
ShadowShape shape = ShadowShape.Bsp(0x01000001, Vector3.Zero, Quaternion.Identity, 1, geometry);
|
||||
return ShadowPartBox.FromShape(shape, worldPosition ?? Origin, Quaternion.Identity);
|
||||
}
|
||||
|
||||
private sealed class ReadTrackingSpheres(Sphere[] values) : IReadOnlyList<Sphere>
|
||||
{
|
||||
public List<int> Reads { get; } = [];
|
||||
public int Count => values.Length;
|
||||
public Sphere this[int index] { get { Reads.Add(index); return values[index]; } }
|
||||
public IEnumerator<Sphere> GetEnumerator() => ((IEnumerable<Sphere>)values).GetEnumerator();
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue