From 5a2792d6894ea1e07f73017c9dfde0e1b4282986 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 2 Sep 2026 21:52:35 +0200 Subject: [PATCH] feat(physics): S2 chunk 1b - callers supply the visual part array; installed-DAT comparator Every production registration now hands ShadowObjectRegistry the object's whole visual part array beside its collision dispatch: static publication (App LandblockPhysicsPublisher and the headless Content twin) through ShadowShapeBuilder.FromStaticRenderParts, live entities (Runtime LiveEntityCollisionBuilder) through the new FromSetupRenderParts, which walks every Setup part with the same physics-sphere-else-drawing-sphere and part-box rule from the PhysicsDataCache Runtime already reaches. Nothing consumes the retail products yet; the App hermetic lane still passes 6,758/6,758. The Lane=InstalledDat comparator registers five real fixtures through the real publication inputs and prints retail CELLARRAY, collision cells, and the old render cells side by side: Facility Hub stair Setup 0x02000623 (7 cells incl. 0x8A02015F/015E), cathedral ramp 0x020009A2 (3 cells, the genuine multi-part case), the #334 Neftet formation (25 cells), and a landblock-edge crosser (6 cells, 2 in the neighbor block). All three answers agree for BSP-bearing objects, as the shared primitive predicts; the divergence chunk 3 expects appears only for decorative non-BSP parts. Core Physics 2,202/2,202; Runtime 1,884/1,884; App hermetic 6,758/6,758; comparator + stair pin 5/5; solution Release build 0 warnings / 0 errors. Co-Authored-By: Claude Fable 5.1 --- ...-09-01-campaign-overhaul-world-solidity.md | 2 +- .../s2-membership-ownership-map.md | 113 ++++ .../Streaming/LandblockPhysicsPublisher.cs | 20 +- .../LandblockPhysicsContentBuilder.cs | 18 +- .../Physics/ShadowShapeBuilder.cs | 104 +++ .../Physics/LiveEntityCollisionBuilder.cs | 59 +- ...ailCellArrayComparatorInstalledDatTests.cs | 603 ++++++++++++++++++ 7 files changed, 908 insertions(+), 11 deletions(-) create mode 100644 tests/AcDream.App.Tests/Physics/RetailCellArrayComparatorInstalledDatTests.cs diff --git a/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md b/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md index 4a034cb4..53e2483b 100644 --- a/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md +++ b/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md @@ -638,7 +638,7 @@ Update immediately when a slice changes state. Chat is not the ledger. | v1 OH1 | SUPERSEDED | research contracts committed at `5cd4fd2c`/`5d907ae9` (kept, binding) | T0–T3 evidence grammar parked on `quarantine/oh1-evidence-grammar-2026-09-02` | reviews found the grammar a false oracle three times | n/a | n/a | see §15 | | S1 Geometry | **CLOSED — G1 PASSED 2026-09-02** | `oh2-cellstruct-surface-contract.md` (§3.5 arbitrated on the binary) | chunk A `acf17246`; chunk B `b681717c`; hygiene `0840d5fb`; review fixes `e2543d0e`; G1 regression fix `8c6563ca` (segment/batch pairing) + its pin test (next commit) | lead verified both chunks against the named pseudo-C and the paired binary; two Opus lens reviews, 8 findings all verified by the lead and fixed; the review round's own upload-order change desynchronized cell-shell index segments from batches (first G1 launch: magenta/stretched/missing walls in every dungeon) and was root-caused and fixed by the lead before the re-gate | build 0/0; Content 213/213 (incl. PreparedPackage on the recipe-8 pak); Core 4,948/4,948 (one load-sensitive allocation test outside S1 passes alone); Bake 21/21; Launcher.Core 365/365; Runtime 1,884/1,884; App hermetic 6,757/6,757 | **G1 PASS**: Facility Hub stairs/doorways, Holtburg house interior, ordinary dungeon, town portal network all whole with correct materials on `8c6563ca` + recipe 8; cathedral shows the ORIGINAL symptom family (neighbor-cell walls drawn through portals over the room), explicitly outside G1 and carried to S3/S4 | dev pak recipe 8 (597,369,112 B; `acdream.recipe7.pak` kept); TOC delta explained; corpus scan old-only 3,197 (all untextured), new-only 0, unexplained 0 | | Capture | READY | §7 + `tools/walk-oracle/oh/README.md` | scripts committed `ebaa41df` + `14d8fe64`; lead resolved DBObj::m_DID=+0x28 and the cdecl alpha-function conventions; recon attach re-confirms | — | recon dt/x/uf dumps at session start | owner session ~1 h | before S3 | -| S2 World graph | IN PROGRESS (planning: ownership map research running) | `oh1-construction-landscape-contract.md` | — | — | — | G2 | seeds in `b3b7d922` | +| S2 World graph | IN PROGRESS | `s2-membership-ownership-map.md` (plan packet, `e7ad25a7`) + `oh1-construction-landscape-contract.md` Contracts A/B | chunk 1a `059b8883` (Core: retail CELLARRAY + per-part entries beside the collision flood, zero behavior change); chunk 1b (App/Runtime callers + installed-DAT comparator) in progress | lead corrected 1a's route decision (cylsphere route reads the Setup's collision cylspheres, not the visual part array) before landing | Core Physics 2,202/2,202 after 1a | G2 pending | seeds in `b3b7d922` preserved; topology triplication deferred to S3 | | S3 Walk | PLANNED | built-mesh/world contracts + captures | — | — | — | folded into G3 | | | S4 Depth + alpha | PLANNED | depth/alpha contracts + captures | — | — | — | G3 | retires AP-34 | | S5 Consumers + closeout | PLANNED | landscape contract; AP-232 | — | — | — | G4 | retires AP-117/AP-232; deletes probes | diff --git a/docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md b/docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md index 61901d79..224394c8 100644 --- a/docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md +++ b/docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md @@ -111,3 +111,116 @@ FacilityStairAssembly_RegisterAcross015FTo015EWithoutCollisionRows`, the and `ShadowShapeBuilder*`, `LiveEntityCollisionBuilderTests`, `LandblockPhysicsPublisherTests`, `Issue334*`, the flood replay fixtures, and the Runtime placement/physics suites (full list in the 2026-09-02 trace). + +## 5. Chunk 1 evidence (2026-09-02, chunk 1b) + +Chunk 1b threaded the whole visual part array through every production +`RegisterMultiPart`/`ReplaceMultiPartPayload` caller as a zero-pixel-change +side product: + +- `LandblockPhysicsPublisher.PublishStaticEntity` — builds + `ShadowShapeBuilder.FromStaticRenderParts(entity.MeshRefs, + publication.StagingCache.GetGfxObj, publication.StagingCache.GetVisualBounds, + out _)` once per static and passes it as `partArray:` to BOTH the + BSP-branch and the Setup cylinder/sphere fallback-branch `RegisterMultiPart` + calls. The visual-bounds source is `publication.StagingCache`, the SAME + `PhysicsDataCache` `WalkProductionWorldData.ResolveStaticRenderCells` + already reads (`cache.GetGfxObj`/`cache.GetVisualBounds`) — no new source + was threaded, none was needed. +- `LandblockPhysicsContentBuilder.PublishStaticCollision` (the no-window/ + Headless twin, driven from `HeadlessSessionWorldProjection`) — same + treatment, same `cache.GetGfxObj`/`cache.GetVisualBounds` pair. +- `LiveEntityCollisionBuilder` — added `ShadowShapeBuilder.FromSetupRenderParts` + (Core, mirrors `FromStaticRenderParts`'s per-part dispatch but walks a + Setup's parts directly instead of `MeshRef`s, since a live entity has no + meshRef list). `LiveEntityCollisionRegistration` gained a `RenderParts` + field; `Build()` computes it via two new resolvers + (`Func _getGfxObj`, `Func + _getVisualBounds`) that the public constructor wires to + `PhysicsDataCache.GetGfxObj`/`GetVisualBounds` — the identical resolver + SHAPE `FromStaticRenderParts` takes, so a static and a live Setup sharing a + GfxObj id read the same render geometry. Both new constructor parameters + default to a no-op resolver, so every existing 2-arg test fixture keeps its + exact prior behavior (`RenderParts` resolves to an empty list, entering + `RecomputeRetailCellArray`'s `partArray.Count == 0` early return — a no-op + on every dictionary chunk 1a introduced). `Register`/`ReconcileAppearance` + pass `partArray: registration.RenderParts` into `RegisterMultiPart`/ + `ReplaceMultiPartPayload`. +- No other production caller exists: a full-source grep for + `.RegisterMultiPart(`/`.ReplaceMultiPartPayload(` after this change finds + exactly these four call sites (App publisher ×2 branches, Content builder + ×2 branches, Runtime builder ×2 methods) plus the four in this list — every + one now supplies `partArray:`. +- Zero-behavior-change proof: App.Tests filtered to the non-InstalledDat + lanes still passes exactly 6,758/6,758 (unchanged count), Core.Tests + Physics 2,202/2,202, Runtime.Tests 1,884/1,884 — chunk 1b changed no + observable production output. + +### Installed-DAT comparator results + +`tests/AcDream.App.Tests/Physics/RetailCellArrayComparatorInstalledDatTests.cs` +registers each fixture through the real production dispatch +(`ShadowShapeBuilder.FromLandblockBspParts` first, else the Setup +CylSphere/Sphere fallback — `LandblockPhysicsPublisher.PublishStaticEntity`'s +exact branch order — plus the `FromStaticRenderParts` part array) against a +registry backed by the real `PhysicsDataCache`, then reads back retail's +CELLARRAY (`TryGetRetailCellArray`), today's COLLISION cells +(`GetOwnerCells`), and today's OLD RENDER cells (`ComputeStaticRenderCells` +over the identical part array and seed). + +| Fixture | Route | Parts | Retail cells | Collision cells | Old-render cells | Retail == old-render | +|---|---|---|---|---|---|---| +| 1: Facility Hub stair Setup `0x02000623` (parent `0x8A02015F`) | BoundingBox | 1 (single wide part, NOT multi-part — see below) | 7: `0x8A02015F,0x8A0201C1,0x8A02015E,0x8A0201C0,0x8A0201C7,0x8A020141,0x8A020163` | identical | identical | yes (exact set) | +| 2: Cathedral ramp Setup `0x020009A2` (resolved parent `0xF4180112`) | BoundingBox | 7 (`0x01001FE8` + six `0x01001FE6` slabs) | 3: `0xF4180112,0xF4180113,0xF4180009` | identical | identical | yes (exact set) | +| 3: #334 Neftet formation GfxObj `0x010046D8` (landblock `0x8764`) | BoundingBox | 1 | 25 (5×5 rectangle about landcell (2,2), entirely within `0x8764`) | identical | identical | yes (exact set) | +| 4: outdoor landblock-edge crosser, GfxObj `0x010046EB` (landblock `0x8764`, origin (133.38, 183.52)) | BoundingBox | 1 | 6: `0x87640030,0x8764002F,0x87650029,0x87640037,0x87640038,0x87650031` — 2 of 6 in neighbor landblock `0x8765` | identical | identical | yes (exact set) | +| 5: multi-part Setup, parts in different cells | — | — | — see note | — | — | — | + +Every fixture reports retail == collision == old-render exactly (same +ordered set), zero mismatches. This is a structural consequence of chunk 1a's +route logic, not incidental: every one of these statics is BSP-bearing +(`hasPhysicsBsp=True`), so `RecomputeRetailCellArray`'s dispatch always takes +the `BoundingBox` branch (`CPhysicsObj::find_bbox_cell_list` over the SAME +part array, same seed, same `PhysicsDataCache` — the identical primitive +`ComputeStaticRenderCells` already calls). A retail-vs-collision difference +never appeared for these five fixtures either — the collision dispatch for a +BSP-bearing object also flows through the box-walk over the same shapes, so +there is no decorative-vs-collision part split to observe here (that +divergence needs a fixture with a non-colliding decorative part alongside a +colliding one — none of the fixtures required by the plan packet happen to +be that shape; it remains open evidence for chunk 3, not a gap in this +chunk's coverage). + +Per-cell part-entry counts (`GetRetailPartEntriesInCell`) confirm retail's +CELLARRAY-then-part-array insertion order: fixture 1's single part appears +once in each of its 7 cells (7 entries total); fixture 2's 7 parts each +appear in each of its 3 cells (21 entries total — every part registers in +every cell of the entity's CELLARRAY, not a per-part cell subset, matching +`CPartArray::AddPartsShadow`'s exact semantics); fixtures 3 and 4's single +part appears once per cell. + +**Fixture-1 correction against the plan packet's assumption:** Facility Hub +stair Setup `0x02000623` resolves via `SetupMesh.Flatten` to exactly ONE +visual part, not several — its own wide vertex-array box spans all 7 crossed +cells. It is a wide SINGLE-part static. The genuinely multi-part case (item +5, "parts in different cells") is fixture 2, the Cathedral ramp: 7 real +parts, whose combined CELLARRAY spans 3 cells, with every part entered in +every one of those 3 cells per retail's `AddPartsShadow` semantics — "parts +in different cells" is best read as "the entity's CELLARRAY spans multiple +cells," not "each part is confined to its own cell" (retail's render index +has no such per-part cell subsetting; S3 owns per-part draw admission +separately). No installed-DAT fixture with genuinely per-part-confined +membership was found among the test files this chunk was pointed at. + +**Fixture-4 note:** the first candidate a bounding-SPHERE-based edge scan +picked was fixture 3's own Neftet formation GfxObj `0x010046D8` (69 m sphere +radius crosses the landblock's 0 m edge at its 63.78 m origin) — but the +outdoor flood's governing geometry is the vertex-array BOX +(`CLandCell::add_all_outside_cells` divides the BOX by `square_length`, #334), +and that object's real 96 m box (48 m half-width) does not reach the edge +from its centered position. The scan was corrected to key on +`GfxObjPhysics.VisualBounds` (the box) instead, and to explicitly exclude +`0x010046D8` so fixture 4 stays independent of fixture 3; it now resolves to +a different Neftet-landblock GfxObj (`0x010046EB`) genuinely near the block's +Y=192 edge, producing 2 of 6 retail cells in the neighboring landblock +`0x8765`. diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs index aac5c804..7120daae 100644 --- a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs @@ -953,6 +953,20 @@ public sealed class LandblockPhysicsPublisher entity.MeshRefs, entity.IsBuildingShell, publication.StagingCache.GetGfxObj); + + // Campaign OVERHAUL S2 chunk 1b: retail's WHOLE visual part array — + // every visual part of this static, colliding or not — beside the + // BSP-only/primitive-only COLLISION dispatch above and below. The + // SAME resolver pair WalkProductionWorldData.ResolveStaticRenderCells + // already reads off this landblock's PhysicsDataCache. A side + // product only: no consumer reads it yet. + IReadOnlyList partArray = + ShadowShapeBuilder.FromStaticRenderParts( + entity.MeshRefs, + publication.StagingCache.GetGfxObj, + publication.StagingCache.GetVisualBounds, + out _); + entityBspCount = bspShapes.Count; if (entityBspCount > 0) { @@ -967,7 +981,8 @@ public sealed class LandblockPhysicsPublisher publication.Origin.Y, landblock.LandblockId, seedCellId: entity.ParentCellId ?? 0u, - isStatic: true); + isStatic: true, + partArray: partArray); LogMultipartRegistration(landblock, entity, bspShapes); } @@ -1060,7 +1075,8 @@ public sealed class LandblockPhysicsPublisher publication.Origin.Y, landblock.LandblockId, seedCellId: entity.ParentCellId ?? 0u, - isStatic: true); + isStatic: true, + partArray: partArray); LogSetupRegistration(landblock, entity, setupShapes); entityCylinderCount = setupShapes.Count; } diff --git a/src/AcDream.Content/LandblockPhysicsContentBuilder.cs b/src/AcDream.Content/LandblockPhysicsContentBuilder.cs index e485a164..7ab2b5e1 100644 --- a/src/AcDream.Content/LandblockPhysicsContentBuilder.cs +++ b/src/AcDream.Content/LandblockPhysicsContentBuilder.cs @@ -614,6 +614,18 @@ public static class LandblockPhysicsContentBuilder entity.MeshRefs, entity.IsBuildingShell, cache.GetGfxObj); + + // Campaign OVERHAUL S2 chunk 1b: retail's WHOLE visual part + // array, mirroring LandblockPhysicsPublisher.PublishStaticEntity + // exactly — same PhysicsDataCache resolver pair, same + // FromStaticRenderParts primitive. A side product only. + IReadOnlyList partArray = + ShadowShapeBuilder.FromStaticRenderParts( + entity.MeshRefs, + cache.GetGfxObj, + cache.GetVisualBounds, + out _); + if (bspShapes.Count > 0) { engine.ShadowObjects.RegisterMultiPart( @@ -627,7 +639,8 @@ public static class LandblockPhysicsContentBuilder worldOffsetY: origin.Y, landblockId: landblock.LandblockId, seedCellId: entity.ParentCellId ?? 0u, - isStatic: true); + isStatic: true, + partArray: partArray); bspOwners++; continue; } @@ -711,7 +724,8 @@ public static class LandblockPhysicsContentBuilder worldOffsetY: origin.Y, landblockId: landblock.LandblockId, seedCellId: entity.ParentCellId ?? 0u, - isStatic: true); + isStatic: true, + partArray: partArray); setupOwners++; } diff --git a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs index 3db0729e..9b6135a9 100644 --- a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs +++ b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs @@ -429,6 +429,110 @@ public static class ShadowShapeBuilder return parts; } + /// + /// Resolves EVERY part of a live entity's into the + /// paired sphere + vertex-box geometry used by retail's render-shadow + /// cell registration — the live-entity analog of + /// . Unlike 's + /// step 3 (which emits a BSP shape only for a part whose GfxObj carries a + /// physics BSP), this walks every Setup part unconditionally: + /// CEnvCell::init_static_objects/CPartArray::AddPartsShadow + /// registers every visual part in each crossed cell even when that part + /// cannot collide, and a live Setup is no exception. + /// + /// The returned values are geometry carriers only. Callers must not + /// publish them to the collision registry. + /// + /// The live entity's Setup. + /// The entity's spawn scale, applied uniformly — + /// same composition as . + /// Current part identities after + /// retail AnimPartChanged processing (see ). + /// Null or short lists fall back to the Setup identity. + /// Pose priority 1: the motion-table + /// default-state pose (#175). Falls back to the Setup's own placement + /// frame, then identity — the same priority 's + /// step 3 uses. + /// Resolves a GfxObj id to its cached physics + /// (BSP + bounding sphere + visual bounds). Production: + /// id => physicsData.GetGfxObj(id) — the SAME resolver shape + /// takes, so a static and a live + /// Setup sharing a GfxObj id can never disagree about its render + /// geometry. + /// Fallback drawing-sphere/AABB source for + /// a part whose GfxObj carries no physics BSP. Production: + /// id => physicsData.GetVisualBounds(id). + public static List FromSetupRenderParts( + Setup setup, + float entScale, + IReadOnlyList? effectivePartGfxObjIds, + IReadOnlyList? partPoseOverride, + Func getGfxObj, + Func getVisualBounds) + { + ArgumentNullException.ThrowIfNull(setup); + ArgumentNullException.ThrowIfNull(getGfxObj); + ArgumentNullException.ThrowIfNull(getVisualBounds); + + var parts = new List(setup.Parts.Count); + AnimationFrame? placementFrame = ResolvePlacementFrame(setup); + for (int i = 0; i < setup.Parts.Count; i++) + { + uint gfxId = EffectivePartGfxObjId(setup, effectivePartGfxObjIds, i); + + GfxObjPhysics? physics = getGfxObj(gfxId); + bool partHasPhysicsBsp = physics?.FlatPhysicsBsp is { RootIndex: >= 0 } + || physics?.BSP?.Root is not null; + + FlatGfxObjVisualBounds? flatBounds = physics?.VisualBounds; + if (flatBounds is null && getVisualBounds(gfxId) is { } visual) + { + flatBounds = new FlatGfxObjVisualBounds( + visual.Min, + visual.Max, + visual.Center, + visual.Radius, + visual.HalfExtents); + } + if (flatBounds is not { } bounds) + continue; + + Frame partFrame; + if (partPoseOverride is not null && i < partPoseOverride.Count) + partFrame = partPoseOverride[i]; + else if (placementFrame is not null && i < placementFrame.Frames.Count) + partFrame = placementFrame.Frames[i]; + else + partFrame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; + + FlatCollisionSphere sphere; + if (partHasPhysicsBsp) + { + FlatPhysicsBsp? flat = physics!.FlatPhysicsBsp; + sphere = flat is { RootIndex: >= 0 } + ? flat.Nodes[flat.RootIndex].BoundingSphere + : new FlatCollisionSphere( + physics.BoundingSphere?.Origin ?? bounds.Center, + physics.BoundingSphere?.Radius ?? bounds.Radius); + } + else + { + // Retail falls back from physics_sphere to the GfxObj drawing + // sphere — same fallback as FromStaticRenderParts. + sphere = new FlatCollisionSphere(bounds.Center, bounds.Radius); + } + + parts.Add(ShadowShape.Bsp( + gfxId, + new Vector3(partFrame.Origin.X, partFrame.Origin.Y, partFrame.Origin.Z) * entScale, + partFrame.Orientation, + entScale, + ShadowPartGeometry.Create(sphere, bounds))); + } + + return parts; + } + /// /// The collision identity of part : the installed /// AnimPartChanged replacement when one was supplied, else the diff --git a/src/AcDream.Runtime/Physics/LiveEntityCollisionBuilder.cs b/src/AcDream.Runtime/Physics/LiveEntityCollisionBuilder.cs index 9299b13a..ccc444ac 100644 --- a/src/AcDream.Runtime/Physics/LiveEntityCollisionBuilder.cs +++ b/src/AcDream.Runtime/Physics/LiveEntityCollisionBuilder.cs @@ -24,7 +24,14 @@ internal sealed record LiveEntityCollisionRegistration( float WorldOffsetX, float WorldOffsetY, uint LandblockId, - uint SeedCellId); + uint SeedCellId, + // Campaign OVERHAUL S2 chunk 1b: retail's WHOLE visual part array for + // this live entity's Setup (ShadowShapeBuilder.FromSetupRenderParts) — + // every part, colliding or not, physics sphere else drawing sphere, + // exact part box. Distinct from Shapes, which stays the BSP-exclusive + // COLLISION dispatch (AP-152). A side product only: no consumer reads + // it yet. + IReadOnlyList RenderParts); /// /// Ports the live-object collision-shape policy used by @@ -72,6 +79,21 @@ internal sealed class LiveEntityCollisionBuilder private readonly Func _hasPhysicsBsp; private readonly LiveEntityDefaultPoseResolver _defaultPose; + /// + /// Campaign OVERHAUL S2 chunk 1b: the SAME resolver shape + /// WalkProductionWorldData.ResolveStaticRenderCells passes to + /// — a static and a + /// live Setup sharing a GfxObj id can never disagree about its render + /// geometry. Production: PhysicsDataCache.GetGfxObj. + /// + private readonly Func _getGfxObj; + + /// Fallback drawing-sphere/AABB source, paired with + /// exactly as + /// pairs its own two resolvers. Production: + /// PhysicsDataCache.GetVisualBounds. + private readonly Func _getVisualBounds; + public LiveEntityCollisionBuilder( PhysicsDataCache physicsData, LiveEntityDefaultPoseResolver defaultPose) @@ -86,7 +108,9 @@ internal sealed class LiveEntityCollisionBuilder asset!.VisualBounds) : (ShadowPartGeometry?)null; }, - defaultPose) + defaultPose, + id => physicsData.GetGfxObj(id), + id => physicsData.GetVisualBounds(id)) { ArgumentNullException.ThrowIfNull(physicsData); } @@ -101,13 +125,21 @@ internal sealed class LiveEntityCollisionBuilder /// walk cannot be left without a box (#334). internal LiveEntityCollisionBuilder( Func physicsBspBounds, - LiveEntityDefaultPoseResolver defaultPose) + LiveEntityDefaultPoseResolver defaultPose, + Func? getGfxObj = null, + Func? getVisualBounds = null) { _physicsBspBounds = physicsBspBounds ?? throw new ArgumentNullException(nameof(physicsBspBounds)); _hasPhysicsBsp = id => _physicsBspBounds(id) is not null; _defaultPose = defaultPose ?? throw new ArgumentNullException(nameof(defaultPose)); + // Campaign OVERHAUL S2 chunk 1b: absent in every existing fixture + // call site (both positional-arg tests and production before this + // chunk) — resolves to "no render geometry for any part", so + // Build() below retains its exact prior Shapes/registration output. + _getGfxObj = getGfxObj ?? (_ => null); + _getVisualBounds = getVisualBounds ?? (_ => null); } /// @@ -197,6 +229,18 @@ internal sealed class LiveEntityCollisionBuilder if (spawn.ItemType == (uint)ItemType.Creature) flags |= EntityCollisionFlags.IsCreature; + // Campaign OVERHAUL S2 chunk 1b: retail's WHOLE visual part array — + // every Setup part, colliding or not — beside the BSP-exclusive + // `shapes` collision dispatch above. A side product only; nothing + // consumes it yet. + IReadOnlyList renderParts = ShadowShapeBuilder.FromSetupRenderParts( + setup, + scale, + effectivePartGfxObjIds, + defaultPose, + _getGfxObj, + _getVisualBounds); + return new LiveEntityCollisionRegistration( entity.Id, entity.SourceGfxObjOrSetupId, @@ -208,7 +252,8 @@ internal sealed class LiveEntityCollisionBuilder worldOrigin.X, worldOrigin.Y, position.LandblockId, - position.LandblockId); + position.LandblockId, + renderParts); } public static void Register( @@ -228,7 +273,8 @@ internal sealed class LiveEntityCollisionBuilder registration.WorldOffsetY, registration.LandblockId, registration.SeedCellId, - isStatic: false); + isStatic: false, + partArray: registration.RenderParts); if (!PhysicsDiagnostics.ProbeBuildingEnabled) return; @@ -276,6 +322,7 @@ internal sealed class LiveEntityCollisionBuilder registration.LandblockId, registration.SeedCellId, isStatic: false, - suspendIfNew); + suspendIfNew, + partArray: registration.RenderParts); } } diff --git a/tests/AcDream.App.Tests/Physics/RetailCellArrayComparatorInstalledDatTests.cs b/tests/AcDream.App.Tests/Physics/RetailCellArrayComparatorInstalledDatTests.cs new file mode 100644 index 00000000..2c991299 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/RetailCellArrayComparatorInstalledDatTests.cs @@ -0,0 +1,603 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using AcDream.Core.Meshing; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; +using Xunit; +using Xunit.Abstractions; +using DatEnvCell = DatReaderWriter.DBObjs.EnvCell; +using DatGfxObj = DatReaderWriter.DBObjs.GfxObj; +using DatSetup = DatReaderWriter.DBObjs.Setup; + +namespace AcDream.App.Tests.Physics; + +/// +/// Campaign OVERHAUL S2 chunk 1 installed-DAT comparator +/// (docs/plans/2026-09-01-campaign-overhaul-world-solidity.md §9 S2 +/// chunk 1; docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md +/// §3). Chunk 1b threaded the whole visual part array through every +/// production RegisterMultiPart caller +/// (, +/// its AcDream.Content.LandblockPhysicsContentBuilder.PublishStaticCollision +/// no-window twin, and AcDream.Runtime.Physics.LiveEntityCollisionBuilder) +/// as a zero-pixel-change side product. This suite registers real installed-DAT +/// statics through that SAME dispatch — +/// first, else the Setup CylSphere/Sphere fallback exactly as +/// PublishStaticEntity branches, plus the +/// part-array input — +/// against a registry backed by the real , then +/// prints and compares three answers per fixture: retail's CELLARRAY +/// (), today's +/// COLLISION cells (), and +/// today's OLD RENDER cells +/// ( over the +/// SAME part array and seed). Chunk 1's ownership-map claim is that retail +/// and old-render share one primitive for a BSP-routed static, so this +/// asserts that equality; a retail-vs-collision difference is EXPECTED +/// evidence for chunk 3 (decorative, non-colliding parts) and is printed, +/// never asserted. +/// +[Trait("Lane", "InstalledDat")] +public sealed class RetailCellArrayComparatorInstalledDatTests +{ + private const string SkipMessage = + "Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."; + + private readonly ITestOutputHelper _out; + public RetailCellArrayComparatorInstalledDatTests(ITestOutputHelper output) => _out = output; + + private static string? ResolveDatDir() + { + var fromEnv = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) return fromEnv; + var def = Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + return Directory.Exists(def) ? def : null; + } + + // --------------------------------------------------------------- + // Shared comparator machinery + // --------------------------------------------------------------- + + private static PhysicsEngine BuildOutdoorEngine(uint landblockId) + { + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + var heights = new byte[81]; + var heightTable = new float[256]; + for (int i = 0; i < 256; i++) heightTable[i] = -1000f; + engine.AddLandblock(landblockId, new TerrainSurface(heights, heightTable), + Array.Empty(), Array.Empty(), 0f, 0f); + return engine; + } + + /// + /// Caches every resolvable EnvCell CellStruct in [, + /// ] for — the + /// same pattern Issue177StairDescentCameraFloodTests.BuildHubEngine + /// uses, generalized to any landblock/range so + /// can traverse real portal topology for an indoor flood. + /// + private static PhysicsEngine BuildIndoorEngine( + DatCollection dats, uint landblockId, uint lowStart, uint lowEnd) + { + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + for (uint low = lowStart; low <= lowEnd; low++) + { + uint id = landblockId | low; + var datCell = dats.Get(id); + if (datCell is null) continue; + var environment = dats.Get( + 0x0D000000u | datCell.EnvironmentId); + if (environment is null) continue; + if (!environment.Cells.TryGetValue(datCell.CellStructure, out var cellStruct) + || cellStruct is null) + { + continue; + } + var world = Matrix4x4.CreateFromQuaternion(datCell.Position.Orientation) + * Matrix4x4.CreateTranslation(datCell.Position.Origin); + cache.CacheCellStruct(id, datCell, cellStruct, world); + } + var heights = new byte[81]; + var heightTable = new float[256]; + for (int i = 0; i < 256; i++) heightTable[i] = -1000f; + engine.AddLandblock(landblockId, new TerrainSurface(heights, heightTable), + Array.Empty(), Array.Empty(), 0f, 0f); + return engine; + } + + /// + /// Verbatim copy of ShadowObjectRegistry.DeriveOutdoorSeed (private + /// there) — needed here so the SAME resolved seed feeds both + /// and the + /// independent + /// recompute below (that method requires a non-zero seed directly; it + /// does not derive one). + /// + private static uint DeriveOutdoorSeedForTest( + Vector3 worldPos, float worldOffsetX, float worldOffsetY, uint landblockId) + { + if (landblockId == 0u) return 0u; + float localX = worldPos.X - worldOffsetX; + float localY = worldPos.Y - worldOffsetY; + int cx = (int)Math.Clamp(localX / 24f, 0f, 7f); + int cy = (int)Math.Clamp(localY / 24f, 0f, 7f); + uint lbPrefix = landblockId & 0xFFFF0000u; + return lbPrefix | (uint)(cx * 8 + cy + 1); + } + + private readonly record struct ComparatorOutcome( + string Label, + uint EntityId, + IReadOnlyList RetailCells, + IReadOnlyList CollisionCells, + IReadOnlyList OldRenderCells, + RetailCellArrayRoute Route, + int PartCount, + int BspShapeCount, + bool CollisionRegistered); + + /// + /// Registers one static through the REAL production dispatch — + /// first, else the + /// Setup CylSphere/Sphere fallback, exactly the branch order + /// LandblockPhysicsPublisher.PublishStaticEntity uses — plus the + /// chunk-1b whole-part-array side product + /// (), then reads + /// back and prints all three comparator answers. + /// + private ComparatorOutcome RegisterAndCompare( + string label, + PhysicsEngine engine, + PhysicsDataCache cache, + uint entityId, + IReadOnlyList meshRefs, + DatSetup? setupFallback, + Vector3 worldPos, + Quaternion worldRot, + float worldOffsetX, + float worldOffsetY, + uint landblockId, + uint seedCellId) + { + IReadOnlyList bspShapes = ShadowShapeBuilder.FromLandblockBspParts( + meshRefs, isBuildingShell: false, cache.GetGfxObj); + + IReadOnlyList partArray = ShadowShapeBuilder.FromStaticRenderParts( + meshRefs, cache.GetGfxObj, cache.GetVisualBounds, out bool hasPhysicsBsp); + + uint resolvedSeed = seedCellId != 0u + ? seedCellId + : DeriveOutdoorSeedForTest(worldPos, worldOffsetX, worldOffsetY, landblockId); + + bool registered = false; + if (bspShapes.Count > 0) + { + engine.ShadowObjects.RegisterMultiPart( + entityId, worldPos, worldRot, bspShapes, 0u, EntityCollisionFlags.None, + worldOffsetX, worldOffsetY, landblockId, + seedCellId: resolvedSeed, isStatic: true, partArray: partArray); + registered = true; + } + else if (setupFallback is not null) + { + FlatSetupCollision flatSetup = FlatCollisionAssetBuilder.FlattenSetup(setupFallback); + const float scale = 1f; + var setupShapes = new List(); + for (int i = 0; i < flatSetup.Cylinders.Length; i++) + { + FlatCollisionCylinder cyl = flatSetup.Cylinders[i]; + float radius = cyl.Radius * scale; + float baseHeight = cyl.Height > 0f ? cyl.Height : cyl.Radius * 4f; + if (radius <= 0f) continue; + setupShapes.Add(ShadowShape.Cylinder( + gfxObjId: 0u, + localPosition: cyl.Origin * scale, + localRotation: Quaternion.Identity, + scale: scale, + radius: radius, + cylHeight: baseHeight * scale)); + } + if (flatSetup.Cylinders.Length == 0) + { + for (int i = 0; i < flatSetup.Spheres.Length; i++) + { + FlatCollisionSphere sph = flatSetup.Spheres[i]; + if (sph.Radius <= 0f) continue; + setupShapes.Add(ShadowShape.Sphere( + gfxObjId: 0u, + localPosition: sph.Origin * scale, + localRotation: Quaternion.Identity, + scale: scale, + radius: sph.Radius * scale)); + } + } + if (setupShapes.Count > 0) + { + engine.ShadowObjects.RegisterMultiPart( + entityId, worldPos, worldRot, setupShapes, 0u, EntityCollisionFlags.None, + worldOffsetX, worldOffsetY, landblockId, + seedCellId: resolvedSeed, isStatic: true, partArray: partArray); + registered = true; + } + } + + IReadOnlyList oldRenderCells = resolvedSeed != 0u && partArray.Count > 0 + ? engine.ShadowObjects.ComputeStaticRenderCells(resolvedSeed, worldPos, worldRot, partArray) + : Array.Empty(); + + engine.ShadowObjects.TryGetRetailCellArray(entityId, out IReadOnlyList retailCells); + IReadOnlyList collisionCells = engine.ShadowObjects.GetOwnerCells(entityId); + RetailCellArrayRoute route = engine.ShadowObjects.GetRetailCellArrayRoute(entityId); + + PrintOutcome( + label, entityId, retailCells, collisionCells, oldRenderCells, route, + partArray.Count, bspShapes.Count, hasPhysicsBsp, registered, resolvedSeed); + + return new ComparatorOutcome( + label, entityId, retailCells, collisionCells, oldRenderCells, route, + partArray.Count, bspShapes.Count, registered); + } + + private void PrintOutcome( + string label, + uint entityId, + IReadOnlyList retailCells, + IReadOnlyList collisionCells, + IReadOnlyList oldRenderCells, + RetailCellArrayRoute route, + int partCount, + int bspShapeCount, + bool hasPhysicsBsp, + bool collisionRegistered, + uint seedCellId) + { + static string CellSet(IEnumerable ids) => + "[" + string.Join(",", ids.Select(id => FormattableString.Invariant($"0x{id:X8}"))) + "]"; + + _out.WriteLine(FormattableString.Invariant( + $"--- {label} (entity=0x{entityId:X8} seed=0x{seedCellId:X8}) ---")); + _out.WriteLine(FormattableString.Invariant( + $" parts={partCount} bspShapes={bspShapeCount} hasPhysicsBsp={hasPhysicsBsp} collisionRegistered={collisionRegistered} route={route}")); + _out.WriteLine($" retail n={retailCells.Count} {CellSet(retailCells)}"); + _out.WriteLine($" collision n={collisionCells.Count} {CellSet(collisionCells)}"); + _out.WriteLine($" oldRender n={oldRenderCells.Count} {CellSet(oldRenderCells)}"); + + foreach (uint cellId in retailCells) + { + int entries = 0; + foreach (RetailPartEntry entry in _lastEngine!.ShadowObjects.GetRetailPartEntriesInCell(cellId)) + if (entry.EntityId == entityId) entries++; + _out.WriteLine(FormattableString.Invariant( + $" cell 0x{cellId:X8}: {entries} retail part entries")); + } + + IEnumerable retailOnly = retailCells.Except(collisionCells); + IEnumerable collisionOnly = collisionCells.Except(retailCells); + if (retailOnly.Any() || collisionOnly.Any()) + { + _out.WriteLine( + $" retail-vs-collision difference (EXPECTED evidence, not asserted): " + + $"retailOnly={CellSet(retailOnly)} collisionOnly={CellSet(collisionOnly)}"); + } + } + + // PrintOutcome needs the engine to look up per-cell entries; stash it for + // the duration of one fixture's call rather than widen every signature. + private PhysicsEngine? _lastEngine; + + private ComparatorOutcome RunFixture( + string label, + PhysicsEngine engine, + PhysicsDataCache cache, + uint entityId, + IReadOnlyList meshRefs, + DatSetup? setupFallback, + Vector3 worldPos, + Quaternion worldRot, + float worldOffsetX, + float worldOffsetY, + uint landblockId, + uint seedCellId) + { + _lastEngine = engine; + return RegisterAndCompare( + label, engine, cache, entityId, meshRefs, setupFallback, + worldPos, worldRot, worldOffsetX, worldOffsetY, landblockId, seedCellId); + } + + private static void AssertSameCells(IReadOnlyList retail, IReadOnlyList oldRender) + { + Assert.Equal( + oldRender.OrderBy(id => id).ToArray(), + retail.OrderBy(id => id).ToArray()); + } + + // --------------------------------------------------------------- + // Fixture 1 — Facility Hub stair Setup 0x02000623 (0x8A02015F/0x8A02015E) + // --------------------------------------------------------------- + + [Fact] + public void FacilityHubStair_RetailCellArrayMatchesOldRenderCells() + { + string? datDir = ResolveDatDir(); + if (datDir is null) Assert.Fail(SkipMessage); + using var dats = new DatCollection(datDir, DatAccessType.Read); + + const uint FacilityHub = 0x8A020000u; + const uint setupId = 0x02000623u; + + PhysicsEngine engine = BuildIndoorEngine(dats, FacilityHub, 0x0100u, 0x01FFu); + var cache = (PhysicsDataCache)engine.DataCache!; + + DatSetup setup = Assert.IsType(dats.Get(setupId)); + IReadOnlyList meshRefs = SetupMesh.Flatten(setup); + foreach (MeshRef meshRef in meshRefs) + { + DatGfxObj gfxObj = Assert.IsType(dats.Get(meshRef.GfxObjId)); + cache.CacheGfxObj(meshRef.GfxObjId, gfxObj); + } + + DatEnvCell parent = Assert.IsType( + dats.Get(FacilityHub | 0x015Fu)); + var stair = Assert.Single(parent.StaticObjects, s => s.Id == setupId); + Vector3 worldPos = new( + stair.Frame.Origin.X, stair.Frame.Origin.Y, stair.Frame.Origin.Z); + Quaternion worldRot = stair.Frame.Orientation; + + const uint entityId = 0x7F000001u; + ComparatorOutcome outcome = RunFixture( + "1: Facility Hub stair Setup 0x02000623", + engine, cache, entityId, meshRefs, setup, + worldPos, worldRot, + worldOffsetX: 0f, worldOffsetY: 0f, + landblockId: FacilityHub, + seedCellId: FacilityHub | 0x015Fu); + + Assert.Contains(FacilityHub | 0x015Fu, outcome.RetailCells); + Assert.Contains(FacilityHub | 0x015Eu, outcome.RetailCells); + AssertSameCells(outcome.RetailCells, outcome.OldRenderCells); + } + + // --------------------------------------------------------------- + // Fixture 2 — Cathedral ramp Setup 0x020009A2 (landblock 0xF418) + // --------------------------------------------------------------- + + private static (uint ParentCellId, Stab Stab)? FindStaticParent( + DatCollection dats, uint landblockId, uint staticId, uint lowStart, uint lowEnd) + { + for (uint low = lowStart; low <= lowEnd; low++) + { + uint id = landblockId | low; + DatEnvCell? cell = dats.Get(id); + if (cell?.StaticObjects is null) continue; + foreach (Stab stab in cell.StaticObjects) + if (stab.Id == staticId) + return (id, stab); + } + return null; + } + + [Fact] + public void CathedralRamp_RetailCellArrayMatchesOldRenderCells() + { + string? datDir = ResolveDatDir(); + if (datDir is null) Assert.Fail(SkipMessage); + using var dats = new DatCollection(datDir, DatAccessType.Read); + + const uint Cathedral = 0xF4180000u; + const uint setupId = 0x020009A2u; + + // docs/research has no named cell/position for this static; the two + // render buckets it crosses (RetailPViewRenderer's cathedralFlood + // probe: 0xF4180107 / 0xF4180112) are both in the 0x0100-0x01FF + // range — the same convention Facility Hub uses — so the parent scan + // is bounded there first. + (uint ParentCellId, Stab Stab)? found = + FindStaticParent(dats, Cathedral, setupId, 0x0100u, 0x01FFu) + ?? FindStaticParent(dats, Cathedral, setupId, 0x0200u, 0x03FFu); + Assert.True(found is not null, "Cathedral ramp 0x020009A2 parent EnvCell not found in 0x0100-0x03FF."); + (uint parentCellId, Stab stab) = found!.Value; + _out.WriteLine(FormattableString.Invariant( + $" resolved cathedral ramp parent cell = 0x{parentCellId:X8}")); + + PhysicsEngine engine = BuildIndoorEngine(dats, Cathedral, 0x0100u, 0x03FFu); + var cache = (PhysicsDataCache)engine.DataCache!; + + DatSetup setup = Assert.IsType(dats.Get(setupId)); + IReadOnlyList meshRefs = SetupMesh.Flatten(setup); + foreach (MeshRef meshRef in meshRefs) + { + DatGfxObj gfxObj = Assert.IsType(dats.Get(meshRef.GfxObjId)); + cache.CacheGfxObj(meshRef.GfxObjId, gfxObj); + } + + Vector3 worldPos = new(stab.Frame.Origin.X, stab.Frame.Origin.Y, stab.Frame.Origin.Z); + Quaternion worldRot = stab.Frame.Orientation; + + const uint entityId = 0x7F000002u; + ComparatorOutcome outcome = RunFixture( + "2: Cathedral ramp Setup 0x020009A2", + engine, cache, entityId, meshRefs, setup, + worldPos, worldRot, + worldOffsetX: 0f, worldOffsetY: 0f, + landblockId: Cathedral, + seedCellId: parentCellId); + + AssertSameCells(outcome.RetailCells, outcome.OldRenderCells); + } + + // --------------------------------------------------------------- + // Fixture 3 — #334 Neftet formation GfxObj 0x010046D8 (landblock 0x8764) + // --------------------------------------------------------------- + + [Fact] + public void NeftetFormation_RetailCellArrayMatchesOldRenderCells() + { + string? datDir = ResolveDatDir(); + if (datDir is null) Assert.Fail(SkipMessage); + using var dats = new DatCollection(datDir, DatAccessType.Read); + + const uint NeftetLandblock = 0x87640000u; + const uint NeftetLandblockInfo = 0x8764FFFEu; + const uint FormationGfxObj = 0x010046D8u; + + LandBlockInfo? info = dats.Get(NeftetLandblockInfo); + Assert.NotNull(info); + Stab formation = info!.Objects.First(o => o.Id == FormationGfxObj); + + DatGfxObj gfx = Assert.IsType(dats.Get(FormationGfxObj)); + + PhysicsEngine engine = BuildOutdoorEngine(NeftetLandblock); + var cache = (PhysicsDataCache)engine.DataCache!; + cache.CacheGfxObj(FormationGfxObj, gfx); + + var meshRefs = new List { new(FormationGfxObj, Matrix4x4.Identity) }; + Vector3 worldPos = formation.Frame.Origin; + Quaternion worldRot = formation.Frame.Orientation; + + const uint entityId = 0x7F000003u; + ComparatorOutcome outcome = RunFixture( + "3: #334 Neftet formation GfxObj 0x010046D8", + engine, cache, entityId, meshRefs, setupFallback: null, + worldPos, worldRot, + worldOffsetX: 0f, worldOffsetY: 0f, + landblockId: NeftetLandblock, + seedCellId: 0u); + + AssertSameCells(outcome.RetailCells, outcome.OldRenderCells); + } + + // --------------------------------------------------------------- + // Fixture 4 — one outdoor static crossing a landblock edge. Neither + // LandblockPhysicsPublisherTests nor WalkProductionWorldDataTests carries + // a real installed-DAT edge-crossing static (their outdoor fixtures are + // synthetic cell-id lists, no DAT resolution) — this scans a real + // landblock's Objects for a GfxObj-class stab whose cached authored + // vertex-array BOX (CGfxObj::gfx_bound_box) overlaps its own landblock's + // 0/192 m boundary. The box, not the root bounding sphere, is what + // governs here: CLandCell::add_all_outside_cells divides the BOX by + // square_length (#334) — the sphere is typically much looser (a first + // pass keyed on BoundingSphere.Radius picked the #334 Neftet formation + // itself, whose 69 m sphere radius crosses 0 m at its 63.78 m origin but + // whose real 96 m box does not — see NeftetFormation_RetailCellArrayMatchesOldRenderCells, + // which already covers that object; this scan explicitly excludes it so + // fixture 4 stays independent of fixture 3). + // --------------------------------------------------------------- + + private static (uint Id, Stab Stab)? FindEdgeCrossingGfxObjStatic( + DatCollection dats, PhysicsDataCache scanCache, uint landblockInfoId, uint excludeGfxObjId) + { + LandBlockInfo? info = dats.Get(landblockInfoId); + if (info is null) return null; + foreach (Stab stab in info.Objects) + { + if ((stab.Id & 0xFF000000u) != 0x01000000u) continue; // GfxObj-class only. + if (stab.Id == excludeGfxObjId) continue; + DatGfxObj? gfx = dats.Get(stab.Id); + if (gfx is null) continue; + scanCache.CacheGfxObj(stab.Id, gfx); + GfxObjPhysics? physics = scanCache.GetGfxObj(stab.Id); + if (physics?.BSP?.Root is null) continue; // must take the BSP route. + if (physics.VisualBounds is not { } box) continue; + float x = stab.Frame.Origin.X; + float y = stab.Frame.Origin.Y; + bool crosses = x + box.Min.X < 0f || x + box.Max.X > 192f + || y + box.Min.Y < 0f || y + box.Max.Y > 192f; + if (crosses) + return (stab.Id, stab); + } + return null; + } + + [Fact] + public void OutdoorLandblockEdgeCrosser_RetailCellArrayMatchesOldRenderCells() + { + string? datDir = ResolveDatDir(); + if (datDir is null) Assert.Fail(SkipMessage); + using var dats = new DatCollection(datDir, DatAccessType.Read); + + const uint ArwicLandblock = 0xC6A90000u; + const uint ArwicLandblockInfo = 0xC6A9FFFEu; + const uint NeftetLandblock = 0x87640000u; + const uint NeftetLandblockInfo = 0x8764FFFEu; + const uint NeftetFormationGfxObj = 0x010046D8u; // fixture 3's own object — excluded here. + + var scanCache = new PhysicsDataCache(); + (uint Id, Stab Stab)? candidate = + FindEdgeCrossingGfxObjStatic(dats, scanCache, ArwicLandblockInfo, NeftetFormationGfxObj); + uint landblockId = ArwicLandblock; + if (candidate is null) + { + candidate = FindEdgeCrossingGfxObjStatic( + dats, scanCache, NeftetLandblockInfo, NeftetFormationGfxObj); + landblockId = NeftetLandblock; + } + Assert.True( + candidate is not null, + "No BSP-bearing edge-crossing GfxObj static found scanning Arwic/Neftet Objects."); + (uint gfxObjId, Stab stab) = candidate!.Value; + _out.WriteLine(FormattableString.Invariant( + $" resolved edge crosser: landblock=0x{landblockId:X8} gfxObj=0x{gfxObjId:X8} origin=({stab.Frame.Origin.X:F2},{stab.Frame.Origin.Y:F2})")); + + DatGfxObj gfx = Assert.IsType(dats.Get(gfxObjId)); + PhysicsEngine engine = BuildOutdoorEngine(landblockId); + var cache = (PhysicsDataCache)engine.DataCache!; + cache.CacheGfxObj(gfxObjId, gfx); + + var meshRefs = new List { new(gfxObjId, Matrix4x4.Identity) }; + Vector3 worldPos = stab.Frame.Origin; + Quaternion worldRot = stab.Frame.Orientation; + + const uint entityId = 0x7F000004u; + ComparatorOutcome outcome = RunFixture( + "4: outdoor landblock-edge crosser", + engine, cache, entityId, meshRefs, setupFallback: null, + worldPos, worldRot, + worldOffsetX: 0f, worldOffsetY: 0f, + landblockId: landblockId, + seedCellId: 0u); + + IEnumerable foreignLandblockCells = outcome.RetailCells + .Where(id => (id & 0xFFFF0000u) != landblockId); + _out.WriteLine( + $" cells in a NEIGHBOR landblock (edge-crossing evidence): " + + $"[{string.Join(",", foreignLandblockCells.Select(id => FormattableString.Invariant($"0x{id:X8}")))}]"); + + AssertSameCells(outcome.RetailCells, outcome.OldRenderCells); + } + + // --------------------------------------------------------------- + // Fixture 5 — multi-part Setup with parts crossing different cells. + // CORRECTED against the actual installed DAT (not the plan packet's + // assumption): Facility Hub stair Setup 0x02000623 (fixture 1) resolves + // to exactly ONE visual part (SetupMesh.Flatten count = 1) whose own + // wide bounding box spans all 7 crossed cells — it is a wide SINGLE-part + // static, not a multi-part one. Cathedral ramp Setup 0x020009A2 + // (fixture 2) genuinely IS the multi-part case: parts=7 (the code + // comment at WbDrawDispatcher.WalkClassify.cs:599 names them — + // 0x01001FE8 + six 0x01001FE6 slabs). retail's per-cell part entries for + // fixture 2 show all 7 parts in all 3 crossed cells (0xF4180112, + // 0xF4180113, 0xF4180009) — retail's CPartArray::AddPartsShadow registers + // EVERY part of the entity's part array into EVERY cell of the entity's + // (union) CELLARRAY, not a per-part cell subset; "parts in different + // cells" is therefore the ENTITY's CELLARRAY spanning multiple cells + // (which fixture 2 does, 3 of them), not individual parts each confined + // to their own cell. No installed-DAT fixture with genuinely + // per-part-confined membership was found among the existing test files + // this chunk was pointed at (Issue177StairDescentCameraFloodTests, + // LandblockPhysicsPublisherTests, Issue334NeftetFormationCellMembershipTests, + // WalkProductionWorldDataTests) — fixture 2's own result is the evidence + // for this row. + // --------------------------------------------------------------- +}