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 <noreply@anthropic.com>
This commit is contained in:
parent
059b8883ab
commit
5a2792d689
7 changed files with 908 additions and 11 deletions
|
|
@ -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 |
|
| 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 |
|
| 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 |
|
| 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 | |
|
| S3 Walk | PLANNED | built-mesh/world contracts + captures | — | — | — | folded into G3 | |
|
||||||
| S4 Depth + alpha | PLANNED | depth/alpha contracts + captures | — | — | — | G3 | retires AP-34 |
|
| 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 |
|
| S5 Consumers + closeout | PLANNED | landscape contract; AP-232 | — | — | — | G4 | retires AP-117/AP-232; deletes probes |
|
||||||
|
|
|
||||||
|
|
@ -111,3 +111,116 @@ FacilityStairAssembly_RegisterAcross015FTo015EWithoutCollisionRows`, the
|
||||||
and `ShadowShapeBuilder*`, `LiveEntityCollisionBuilderTests`,
|
and `ShadowShapeBuilder*`, `LiveEntityCollisionBuilderTests`,
|
||||||
`LandblockPhysicsPublisherTests`, `Issue334*`, the flood replay fixtures, and
|
`LandblockPhysicsPublisherTests`, `Issue334*`, the flood replay fixtures, and
|
||||||
the Runtime placement/physics suites (full list in the 2026-09-02 trace).
|
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<uint, GfxObjPhysics?> _getGfxObj`, `Func<uint, GfxObjVisualBounds?>
|
||||||
|
_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`.
|
||||||
|
|
|
||||||
|
|
@ -953,6 +953,20 @@ public sealed class LandblockPhysicsPublisher
|
||||||
entity.MeshRefs,
|
entity.MeshRefs,
|
||||||
entity.IsBuildingShell,
|
entity.IsBuildingShell,
|
||||||
publication.StagingCache.GetGfxObj);
|
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<ShadowShape> partArray =
|
||||||
|
ShadowShapeBuilder.FromStaticRenderParts(
|
||||||
|
entity.MeshRefs,
|
||||||
|
publication.StagingCache.GetGfxObj,
|
||||||
|
publication.StagingCache.GetVisualBounds,
|
||||||
|
out _);
|
||||||
|
|
||||||
entityBspCount = bspShapes.Count;
|
entityBspCount = bspShapes.Count;
|
||||||
if (entityBspCount > 0)
|
if (entityBspCount > 0)
|
||||||
{
|
{
|
||||||
|
|
@ -967,7 +981,8 @@ public sealed class LandblockPhysicsPublisher
|
||||||
publication.Origin.Y,
|
publication.Origin.Y,
|
||||||
landblock.LandblockId,
|
landblock.LandblockId,
|
||||||
seedCellId: entity.ParentCellId ?? 0u,
|
seedCellId: entity.ParentCellId ?? 0u,
|
||||||
isStatic: true);
|
isStatic: true,
|
||||||
|
partArray: partArray);
|
||||||
LogMultipartRegistration(landblock, entity, bspShapes);
|
LogMultipartRegistration(landblock, entity, bspShapes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1060,7 +1075,8 @@ public sealed class LandblockPhysicsPublisher
|
||||||
publication.Origin.Y,
|
publication.Origin.Y,
|
||||||
landblock.LandblockId,
|
landblock.LandblockId,
|
||||||
seedCellId: entity.ParentCellId ?? 0u,
|
seedCellId: entity.ParentCellId ?? 0u,
|
||||||
isStatic: true);
|
isStatic: true,
|
||||||
|
partArray: partArray);
|
||||||
LogSetupRegistration(landblock, entity, setupShapes);
|
LogSetupRegistration(landblock, entity, setupShapes);
|
||||||
entityCylinderCount = setupShapes.Count;
|
entityCylinderCount = setupShapes.Count;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -614,6 +614,18 @@ public static class LandblockPhysicsContentBuilder
|
||||||
entity.MeshRefs,
|
entity.MeshRefs,
|
||||||
entity.IsBuildingShell,
|
entity.IsBuildingShell,
|
||||||
cache.GetGfxObj);
|
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<ShadowShape> partArray =
|
||||||
|
ShadowShapeBuilder.FromStaticRenderParts(
|
||||||
|
entity.MeshRefs,
|
||||||
|
cache.GetGfxObj,
|
||||||
|
cache.GetVisualBounds,
|
||||||
|
out _);
|
||||||
|
|
||||||
if (bspShapes.Count > 0)
|
if (bspShapes.Count > 0)
|
||||||
{
|
{
|
||||||
engine.ShadowObjects.RegisterMultiPart(
|
engine.ShadowObjects.RegisterMultiPart(
|
||||||
|
|
@ -627,7 +639,8 @@ public static class LandblockPhysicsContentBuilder
|
||||||
worldOffsetY: origin.Y,
|
worldOffsetY: origin.Y,
|
||||||
landblockId: landblock.LandblockId,
|
landblockId: landblock.LandblockId,
|
||||||
seedCellId: entity.ParentCellId ?? 0u,
|
seedCellId: entity.ParentCellId ?? 0u,
|
||||||
isStatic: true);
|
isStatic: true,
|
||||||
|
partArray: partArray);
|
||||||
bspOwners++;
|
bspOwners++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -711,7 +724,8 @@ public static class LandblockPhysicsContentBuilder
|
||||||
worldOffsetY: origin.Y,
|
worldOffsetY: origin.Y,
|
||||||
landblockId: landblock.LandblockId,
|
landblockId: landblock.LandblockId,
|
||||||
seedCellId: entity.ParentCellId ?? 0u,
|
seedCellId: entity.ParentCellId ?? 0u,
|
||||||
isStatic: true);
|
isStatic: true,
|
||||||
|
partArray: partArray);
|
||||||
setupOwners++;
|
setupOwners++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -429,6 +429,110 @@ public static class ShadowShapeBuilder
|
||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves EVERY part of a live entity's <see cref="Setup"/> into the
|
||||||
|
/// paired sphere + vertex-box geometry used by retail's render-shadow
|
||||||
|
/// cell registration — the live-entity analog of
|
||||||
|
/// <see cref="FromStaticRenderParts"/>. Unlike <see cref="FromSetup"/>'s
|
||||||
|
/// step 3 (which emits a BSP shape only for a part whose GfxObj carries a
|
||||||
|
/// physics BSP), this walks every Setup part unconditionally:
|
||||||
|
/// <c>CEnvCell::init_static_objects</c>/<c>CPartArray::AddPartsShadow</c>
|
||||||
|
/// registers every visual part in each crossed cell even when that part
|
||||||
|
/// cannot collide, and a live Setup is no exception.
|
||||||
|
///
|
||||||
|
/// <para>The returned values are geometry carriers only. Callers must not
|
||||||
|
/// publish them to the collision registry.</para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="setup">The live entity's Setup.</param>
|
||||||
|
/// <param name="entScale">The entity's spawn scale, applied uniformly —
|
||||||
|
/// same composition as <see cref="FromSetup"/>.</param>
|
||||||
|
/// <param name="effectivePartGfxObjIds">Current part identities after
|
||||||
|
/// retail <c>AnimPartChanged</c> processing (see <see cref="FromSetup"/>).
|
||||||
|
/// Null or short lists fall back to the Setup identity.</param>
|
||||||
|
/// <param name="partPoseOverride">Pose priority 1: the motion-table
|
||||||
|
/// default-state pose (#175). Falls back to the Setup's own placement
|
||||||
|
/// frame, then identity — the same priority <see cref="FromSetup"/>'s
|
||||||
|
/// step 3 uses.</param>
|
||||||
|
/// <param name="getGfxObj">Resolves a GfxObj id to its cached physics
|
||||||
|
/// (BSP + bounding sphere + visual bounds). Production:
|
||||||
|
/// <c>id => physicsData.GetGfxObj(id)</c> — the SAME resolver shape
|
||||||
|
/// <see cref="FromStaticRenderParts"/> takes, so a static and a live
|
||||||
|
/// Setup sharing a GfxObj id can never disagree about its render
|
||||||
|
/// geometry.</param>
|
||||||
|
/// <param name="getVisualBounds">Fallback drawing-sphere/AABB source for
|
||||||
|
/// a part whose GfxObj carries no physics BSP. Production:
|
||||||
|
/// <c>id => physicsData.GetVisualBounds(id)</c>.</param>
|
||||||
|
public static List<ShadowShape> FromSetupRenderParts(
|
||||||
|
Setup setup,
|
||||||
|
float entScale,
|
||||||
|
IReadOnlyList<uint>? effectivePartGfxObjIds,
|
||||||
|
IReadOnlyList<Frame>? partPoseOverride,
|
||||||
|
Func<uint, GfxObjPhysics?> getGfxObj,
|
||||||
|
Func<uint, GfxObjVisualBounds?> getVisualBounds)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(setup);
|
||||||
|
ArgumentNullException.ThrowIfNull(getGfxObj);
|
||||||
|
ArgumentNullException.ThrowIfNull(getVisualBounds);
|
||||||
|
|
||||||
|
var parts = new List<ShadowShape>(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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The collision identity of part <paramref name="index"/>: the installed
|
/// The collision identity of part <paramref name="index"/>: the installed
|
||||||
/// <c>AnimPartChanged</c> replacement when one was supplied, else the
|
/// <c>AnimPartChanged</c> replacement when one was supplied, else the
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,14 @@ internal sealed record LiveEntityCollisionRegistration(
|
||||||
float WorldOffsetX,
|
float WorldOffsetX,
|
||||||
float WorldOffsetY,
|
float WorldOffsetY,
|
||||||
uint LandblockId,
|
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<ShadowShape> RenderParts);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ports the live-object collision-shape policy used by
|
/// Ports the live-object collision-shape policy used by
|
||||||
|
|
@ -72,6 +79,21 @@ internal sealed class LiveEntityCollisionBuilder
|
||||||
private readonly Func<uint, bool> _hasPhysicsBsp;
|
private readonly Func<uint, bool> _hasPhysicsBsp;
|
||||||
private readonly LiveEntityDefaultPoseResolver _defaultPose;
|
private readonly LiveEntityDefaultPoseResolver _defaultPose;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign OVERHAUL S2 chunk 1b: the SAME resolver shape
|
||||||
|
/// <c>WalkProductionWorldData.ResolveStaticRenderCells</c> passes to
|
||||||
|
/// <see cref="ShadowShapeBuilder.FromStaticRenderParts"/> — a static and a
|
||||||
|
/// live Setup sharing a GfxObj id can never disagree about its render
|
||||||
|
/// geometry. Production: <c>PhysicsDataCache.GetGfxObj</c>.
|
||||||
|
/// </summary>
|
||||||
|
private readonly Func<uint, GfxObjPhysics?> _getGfxObj;
|
||||||
|
|
||||||
|
/// <summary>Fallback drawing-sphere/AABB source, paired with
|
||||||
|
/// <see cref="_getGfxObj"/> exactly as <see cref="ShadowShapeBuilder.FromStaticRenderParts"/>
|
||||||
|
/// pairs its own two resolvers. Production:
|
||||||
|
/// <c>PhysicsDataCache.GetVisualBounds</c>.</summary>
|
||||||
|
private readonly Func<uint, GfxObjVisualBounds?> _getVisualBounds;
|
||||||
|
|
||||||
public LiveEntityCollisionBuilder(
|
public LiveEntityCollisionBuilder(
|
||||||
PhysicsDataCache physicsData,
|
PhysicsDataCache physicsData,
|
||||||
LiveEntityDefaultPoseResolver defaultPose)
|
LiveEntityDefaultPoseResolver defaultPose)
|
||||||
|
|
@ -86,7 +108,9 @@ internal sealed class LiveEntityCollisionBuilder
|
||||||
asset!.VisualBounds)
|
asset!.VisualBounds)
|
||||||
: (ShadowPartGeometry?)null;
|
: (ShadowPartGeometry?)null;
|
||||||
},
|
},
|
||||||
defaultPose)
|
defaultPose,
|
||||||
|
id => physicsData.GetGfxObj(id),
|
||||||
|
id => physicsData.GetVisualBounds(id))
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(physicsData);
|
ArgumentNullException.ThrowIfNull(physicsData);
|
||||||
}
|
}
|
||||||
|
|
@ -101,13 +125,21 @@ internal sealed class LiveEntityCollisionBuilder
|
||||||
/// walk cannot be left without a box (#334).</param>
|
/// walk cannot be left without a box (#334).</param>
|
||||||
internal LiveEntityCollisionBuilder(
|
internal LiveEntityCollisionBuilder(
|
||||||
Func<uint, ShadowPartGeometry?> physicsBspBounds,
|
Func<uint, ShadowPartGeometry?> physicsBspBounds,
|
||||||
LiveEntityDefaultPoseResolver defaultPose)
|
LiveEntityDefaultPoseResolver defaultPose,
|
||||||
|
Func<uint, GfxObjPhysics?>? getGfxObj = null,
|
||||||
|
Func<uint, GfxObjVisualBounds?>? getVisualBounds = null)
|
||||||
{
|
{
|
||||||
_physicsBspBounds = physicsBspBounds
|
_physicsBspBounds = physicsBspBounds
|
||||||
?? throw new ArgumentNullException(nameof(physicsBspBounds));
|
?? throw new ArgumentNullException(nameof(physicsBspBounds));
|
||||||
_hasPhysicsBsp = id => _physicsBspBounds(id) is not null;
|
_hasPhysicsBsp = id => _physicsBspBounds(id) is not null;
|
||||||
_defaultPose = defaultPose
|
_defaultPose = defaultPose
|
||||||
?? throw new ArgumentNullException(nameof(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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -197,6 +229,18 @@ internal sealed class LiveEntityCollisionBuilder
|
||||||
if (spawn.ItemType == (uint)ItemType.Creature)
|
if (spawn.ItemType == (uint)ItemType.Creature)
|
||||||
flags |= EntityCollisionFlags.IsCreature;
|
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<ShadowShape> renderParts = ShadowShapeBuilder.FromSetupRenderParts(
|
||||||
|
setup,
|
||||||
|
scale,
|
||||||
|
effectivePartGfxObjIds,
|
||||||
|
defaultPose,
|
||||||
|
_getGfxObj,
|
||||||
|
_getVisualBounds);
|
||||||
|
|
||||||
return new LiveEntityCollisionRegistration(
|
return new LiveEntityCollisionRegistration(
|
||||||
entity.Id,
|
entity.Id,
|
||||||
entity.SourceGfxObjOrSetupId,
|
entity.SourceGfxObjOrSetupId,
|
||||||
|
|
@ -208,7 +252,8 @@ internal sealed class LiveEntityCollisionBuilder
|
||||||
worldOrigin.X,
|
worldOrigin.X,
|
||||||
worldOrigin.Y,
|
worldOrigin.Y,
|
||||||
position.LandblockId,
|
position.LandblockId,
|
||||||
position.LandblockId);
|
position.LandblockId,
|
||||||
|
renderParts);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Register(
|
public static void Register(
|
||||||
|
|
@ -228,7 +273,8 @@ internal sealed class LiveEntityCollisionBuilder
|
||||||
registration.WorldOffsetY,
|
registration.WorldOffsetY,
|
||||||
registration.LandblockId,
|
registration.LandblockId,
|
||||||
registration.SeedCellId,
|
registration.SeedCellId,
|
||||||
isStatic: false);
|
isStatic: false,
|
||||||
|
partArray: registration.RenderParts);
|
||||||
|
|
||||||
if (!PhysicsDiagnostics.ProbeBuildingEnabled)
|
if (!PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||||
return;
|
return;
|
||||||
|
|
@ -276,6 +322,7 @@ internal sealed class LiveEntityCollisionBuilder
|
||||||
registration.LandblockId,
|
registration.LandblockId,
|
||||||
registration.SeedCellId,
|
registration.SeedCellId,
|
||||||
isStatic: false,
|
isStatic: false,
|
||||||
suspendIfNew);
|
suspendIfNew,
|
||||||
|
partArray: registration.RenderParts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign OVERHAUL S2 chunk 1 installed-DAT comparator
|
||||||
|
/// (<c>docs/plans/2026-09-01-campaign-overhaul-world-solidity.md</c> §9 S2
|
||||||
|
/// chunk 1; <c>docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md</c>
|
||||||
|
/// §3). Chunk 1b threaded the whole visual part array through every
|
||||||
|
/// production <c>RegisterMultiPart</c> caller
|
||||||
|
/// (<see cref="AcDream.App.Streaming.LandblockPhysicsPublisher.PublishStaticEntity"/>,
|
||||||
|
/// its <c>AcDream.Content.LandblockPhysicsContentBuilder.PublishStaticCollision</c>
|
||||||
|
/// no-window twin, and <c>AcDream.Runtime.Physics.LiveEntityCollisionBuilder</c>)
|
||||||
|
/// as a zero-pixel-change side product. This suite registers real installed-DAT
|
||||||
|
/// statics through that SAME dispatch — <see cref="ShadowShapeBuilder.FromLandblockBspParts"/>
|
||||||
|
/// first, else the Setup CylSphere/Sphere fallback exactly as
|
||||||
|
/// <c>PublishStaticEntity</c> branches, plus the
|
||||||
|
/// <see cref="ShadowShapeBuilder.FromStaticRenderParts"/> part-array input —
|
||||||
|
/// against a registry backed by the real <see cref="PhysicsDataCache"/>, then
|
||||||
|
/// prints and compares three answers per fixture: retail's CELLARRAY
|
||||||
|
/// (<see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>), today's
|
||||||
|
/// COLLISION cells (<see cref="ShadowObjectRegistry.GetOwnerCells"/>), and
|
||||||
|
/// today's OLD RENDER cells
|
||||||
|
/// (<see cref="ShadowObjectRegistry.ComputeStaticRenderCells"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<CellSurface>(), Array.Empty<PortalPlane>(), 0f, 0f);
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Caches every resolvable EnvCell CellStruct in [<paramref name="lowStart"/>,
|
||||||
|
/// <paramref name="lowEnd"/>] for <paramref name="landblockId"/> — the
|
||||||
|
/// same pattern <c>Issue177StairDescentCameraFloodTests.BuildHubEngine</c>
|
||||||
|
/// uses, generalized to any landblock/range so <see cref="CellTransit"/>
|
||||||
|
/// can traverse real portal topology for an indoor flood.
|
||||||
|
/// </summary>
|
||||||
|
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<DatEnvCell>(id);
|
||||||
|
if (datCell is null) continue;
|
||||||
|
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(
|
||||||
|
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<CellSurface>(), Array.Empty<PortalPlane>(), 0f, 0f);
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verbatim copy of <c>ShadowObjectRegistry.DeriveOutdoorSeed</c> (private
|
||||||
|
/// there) — needed here so the SAME resolved seed feeds both
|
||||||
|
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> and the
|
||||||
|
/// independent <see cref="ShadowObjectRegistry.ComputeStaticRenderCells"/>
|
||||||
|
/// recompute below (that method requires a non-zero seed directly; it
|
||||||
|
/// does not derive one).
|
||||||
|
/// </summary>
|
||||||
|
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<uint> RetailCells,
|
||||||
|
IReadOnlyList<uint> CollisionCells,
|
||||||
|
IReadOnlyList<uint> OldRenderCells,
|
||||||
|
RetailCellArrayRoute Route,
|
||||||
|
int PartCount,
|
||||||
|
int BspShapeCount,
|
||||||
|
bool CollisionRegistered);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers one static through the REAL production dispatch —
|
||||||
|
/// <see cref="ShadowShapeBuilder.FromLandblockBspParts"/> first, else the
|
||||||
|
/// Setup CylSphere/Sphere fallback, exactly the branch order
|
||||||
|
/// <c>LandblockPhysicsPublisher.PublishStaticEntity</c> uses — plus the
|
||||||
|
/// chunk-1b whole-part-array side product
|
||||||
|
/// (<see cref="ShadowShapeBuilder.FromStaticRenderParts"/>), then reads
|
||||||
|
/// back and prints all three comparator answers.
|
||||||
|
/// </summary>
|
||||||
|
private ComparatorOutcome RegisterAndCompare(
|
||||||
|
string label,
|
||||||
|
PhysicsEngine engine,
|
||||||
|
PhysicsDataCache cache,
|
||||||
|
uint entityId,
|
||||||
|
IReadOnlyList<MeshRef> meshRefs,
|
||||||
|
DatSetup? setupFallback,
|
||||||
|
Vector3 worldPos,
|
||||||
|
Quaternion worldRot,
|
||||||
|
float worldOffsetX,
|
||||||
|
float worldOffsetY,
|
||||||
|
uint landblockId,
|
||||||
|
uint seedCellId)
|
||||||
|
{
|
||||||
|
IReadOnlyList<ShadowShape> bspShapes = ShadowShapeBuilder.FromLandblockBspParts(
|
||||||
|
meshRefs, isBuildingShell: false, cache.GetGfxObj);
|
||||||
|
|
||||||
|
IReadOnlyList<ShadowShape> 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<ShadowShape>();
|
||||||
|
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<uint> oldRenderCells = resolvedSeed != 0u && partArray.Count > 0
|
||||||
|
? engine.ShadowObjects.ComputeStaticRenderCells(resolvedSeed, worldPos, worldRot, partArray)
|
||||||
|
: Array.Empty<uint>();
|
||||||
|
|
||||||
|
engine.ShadowObjects.TryGetRetailCellArray(entityId, out IReadOnlyList<uint> retailCells);
|
||||||
|
IReadOnlyList<uint> 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<uint> retailCells,
|
||||||
|
IReadOnlyList<uint> collisionCells,
|
||||||
|
IReadOnlyList<uint> oldRenderCells,
|
||||||
|
RetailCellArrayRoute route,
|
||||||
|
int partCount,
|
||||||
|
int bspShapeCount,
|
||||||
|
bool hasPhysicsBsp,
|
||||||
|
bool collisionRegistered,
|
||||||
|
uint seedCellId)
|
||||||
|
{
|
||||||
|
static string CellSet(IEnumerable<uint> 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<uint> retailOnly = retailCells.Except(collisionCells);
|
||||||
|
IEnumerable<uint> 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<MeshRef> 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<uint> retail, IReadOnlyList<uint> 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<DatSetup>(dats.Get<DatSetup>(setupId));
|
||||||
|
IReadOnlyList<MeshRef> meshRefs = SetupMesh.Flatten(setup);
|
||||||
|
foreach (MeshRef meshRef in meshRefs)
|
||||||
|
{
|
||||||
|
DatGfxObj gfxObj = Assert.IsType<DatGfxObj>(dats.Get<DatGfxObj>(meshRef.GfxObjId));
|
||||||
|
cache.CacheGfxObj(meshRef.GfxObjId, gfxObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
DatEnvCell parent = Assert.IsType<DatEnvCell>(
|
||||||
|
dats.Get<DatEnvCell>(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<DatEnvCell>(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<DatSetup>(dats.Get<DatSetup>(setupId));
|
||||||
|
IReadOnlyList<MeshRef> meshRefs = SetupMesh.Flatten(setup);
|
||||||
|
foreach (MeshRef meshRef in meshRefs)
|
||||||
|
{
|
||||||
|
DatGfxObj gfxObj = Assert.IsType<DatGfxObj>(dats.Get<DatGfxObj>(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<LandBlockInfo>(NeftetLandblockInfo);
|
||||||
|
Assert.NotNull(info);
|
||||||
|
Stab formation = info!.Objects.First(o => o.Id == FormationGfxObj);
|
||||||
|
|
||||||
|
DatGfxObj gfx = Assert.IsType<DatGfxObj>(dats.Get<DatGfxObj>(FormationGfxObj));
|
||||||
|
|
||||||
|
PhysicsEngine engine = BuildOutdoorEngine(NeftetLandblock);
|
||||||
|
var cache = (PhysicsDataCache)engine.DataCache!;
|
||||||
|
cache.CacheGfxObj(FormationGfxObj, gfx);
|
||||||
|
|
||||||
|
var meshRefs = new List<MeshRef> { 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<LandBlockInfo>(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<DatGfxObj>(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<DatGfxObj>(dats.Get<DatGfxObj>(gfxObjId));
|
||||||
|
PhysicsEngine engine = BuildOutdoorEngine(landblockId);
|
||||||
|
var cache = (PhysicsDataCache)engine.DataCache!;
|
||||||
|
cache.CacheGfxObj(gfxObjId, gfx);
|
||||||
|
|
||||||
|
var meshRefs = new List<MeshRef> { 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<uint> 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.
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue