fix(physics): port retail's find_bbox_cell_list outdoor extent walk (#334)
acdream had never implemented retail's SECOND cell-membership algorithm.
CPhysicsObj::calc_cross_cells @0x00515230 tests HAS_PHYSICS_BSP_PS at
0x00515285 and jumps (0x0051528f jne 0x515305) to find_bbox_cell_list
@0x00510fc0 for a BSP-bearing object; everything below that jump is the
OTHER algorithm, CObjCell::find_cell_list, and that is all we had. Every
object, BSP-bearing or not, was routed through it.
That path's outdoor expansion is a HARD CAP of one cell in each direction.
CellTransit.AddAllOutsideCells computes minRad = radius, maxRad = 24 - radius
and adds at most the eight neighbours of the sphere's own cell, so for any
radius >= 12 m both boundary tests are unconditionally true and the result is
exactly 3x3. Widening the radius or adding a second sphere is mechanically
incapable of adding a tenth cell. The user's live probe measured the
consequence directly: standing inside a Neftet formation, inCell=2 exempt=2
reached=0 -- the geometry was not a candidate at all.
The port. AddAllOutsideCellsFromParts is CLandCell::add_all_outside_cells
@0x00533360 plus add_cell_block @0x005331d0: base landcell from the FIRST
part's own adjust_to_outside, baseX/baseY within-block, each part's authored
CGfxObj::gfx_bound_box re-fit through all eight corners
(BBox::LocalToGlobal @0x005b2120), floor(v / square_length) where
square_length = 0x7c920c = 24.0f, four accumulators seeded to zero, ONE
rectangle unioned across all parts, FILLED, in GLOBAL lcoords so it crosses
landblocks freely, clamped only to [0, 0x7f8).
BuildShadowCellSetFromParts is find_bbox_cell_list's worklist.
RegisterMultiPart dispatches on the same flag retail does, and
BuildFloodSpheres' BSP arm is deleted rather than left unreachable.
Disassembled from the PDB-paired 2013-09-06 binary, not read from Binary
Ninja: BN mis-renders four separate constructs inside add_all_outside_cells
alone -- a dropped `and eax,0xffff` on baseX, a neg/sbb/and select shown as
identically zero, a wrong get_landcell argument, and both x87 flag tests as
`unimplemented {test ah}`.
ShadowPartGeometry pairs the BSP root sphere with the authored box so no
resolver can answer one and leave the other call site to synthesize a
substitute -- the AP-156 invariant applied a second time, since that split is
what produced AP-156 and then this. The box comes from
FlatGfxObjVisualBounds, already computed by exactly CGfxObj::init_end's
algorithm and already in the prepared package: no bake change, no DAT re-read.
Cost, measured over the installed DATs before any code was written: 1,258
physics-BSP GfxObjs, cells/object p50 4, p90 4, p99 12, max 49. The port is
CHEAPER than the old 3x3 = 9 for 98.97% of them. Row totals (shapes x cells)
over all 1,031 landblocks with BSP owners fall 97,173 -> 15,607 (0.161x);
dense Arwic 0xC6A9 falls 342 -> 43. One landblock more than doubles.
Precondition confirmed before pinning any expected cell set: 0x010046D8's box
is 96 m x 96 m about cell (2,2) = 0x87640013, which independently corroborates
the 3x3-centred-there diagnosis, and its rectangle does contain 0x87640011 and
0x87640019 -- the two cells the probe measured empty.
Register: AP-156's outdoor half CLOSED and its risk column CORRECTED (it read
"extra broadphase candidates, never a missed one", which generalised the indoor
direction to the whole row and is why #334 sat inside it unnoticed). AP-159 +
issue #335 file the unported indoor arm; AD-49 records the seed-time rectangle.
Issue #336 files a fourth load-sensitive test flake seen once during the gate.
Ten tests, every one sabotage-verified in both directions across eight
mutations (dispatch, 8-corner refit, floor-vs-truncation, union-vs-per-part,
map clamp, adjust guard, landblock clamp, box-path-for-everything). The
strongest is an installed-DAT replay of the user's own probe evidence.
Suite 11,208 -> 11,218 passed / 4 skipped / 0 failed; the +10 is exactly the
new tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f0588725cf
commit
13fcf38138
19 changed files with 2186 additions and 137 deletions
|
|
@ -24,6 +24,31 @@ What does NOT go here:
|
||||||
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
|
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
|
||||||
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
|
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
|
||||||
|
|
||||||
|
## #335 — The INDOOR half of retail's part-array `find_transit_cells` is not ported: an EnvCell neighbour is admitted on a SPHERE test where retail uses a BOX
|
||||||
|
|
||||||
|
**Status:** OPEN
|
||||||
|
**Severity:** low. Over-inclusive only — extra broadphase candidates indoors, never a missed one. The opposite direction (the outdoor half) was #334 and is closed.
|
||||||
|
**Filed:** 2026-08-06, at the #334 fix.
|
||||||
|
**Component:** physics / cell membership
|
||||||
|
**Register row:** AP-159.
|
||||||
|
|
||||||
|
#334 ported `CPhysicsObj::find_bbox_cell_list` @0x00510fc0 and the OUTDOOR arm of the part-array `find_transit_cells` it dispatches to (`CLandCell::find_transit_cells` @0x00533840 → `add_all_outside_cells` @0x00533360 → `add_cell_block` @0x005331d0). The INDOOR arm of that same dispatch is still acdream's sphere traversal.
|
||||||
|
|
||||||
|
**What retail does** (`CEnvCell::find_transit_cells` @0x0052cae0, disassembled from the PDB-paired 2013-09-06 binary), per portal × per part:
|
||||||
|
|
||||||
|
1. cheap reject: the part's `CGfxObj::physics_sphere` centre through `Position::localtolocal` (`0x0052cb5a`), tested against the portal plane with `eps = F_EPSILON + radius` (`0x0052cb65`);
|
||||||
|
2. on pass, the ADMITTING test is box-vs-plane: `CPhysicsPart::GetBoundingBox` @0x0050d600 (`0x0052cbdd`) → `BBox::LocalToLocal` @0x005b1e60 (`0x0052cbf9`) → `Plane::intersect_box` @0x005aa170 (`0x0052cc05`);
|
||||||
|
3. if the side differs from `portal_side`: `other_cell_id == 0xFFFFFFFF` (`0x0052cc1e`) sets the “leads outside” flag; otherwise `CCellPortal::GetOtherCell` @0x0053ba30 (`0x0052cc2b`) — a THISCALL on the portal record (`ecx` set at `0x0052cc18`) taking ONE explicit argument, `cellarray->do_not_load_cells` (`0x0052cc27 mov eax,[edi+4]`; the CELLARRAY layout is +0 `added_outside`, +4 `do_not_load_cells`, +8 `num_cells`, +0xc `cells`, cross-checked against `find_bbox_cell_list`'s `0x00510fc8`/`0x00510fcf` zeroing and `add_all_outside_cells`' `0x0053336c` read of `[arg3]`). That RESOLVES the #334 contract's open question 11.4 in the AFFIRMATIVE — the flag IS threaded through, as the single explicit argument, not omitted. Then `BBox::LocalToLocal` into the destination and `CCellStruct::box_intersects_cell` @0x00533910 → `BSPTREE` @0x0053c880 gates the add (`0x0052cc5a`);
|
||||||
|
4. after all portals, the outside flag runs `add_all_outside_cells` (`0x0052ccea`).
|
||||||
|
|
||||||
|
**What acdream does:** `CellTransit.BuildShadowCellSetFromParts`'s indoor arm calls `FindTransitCellsSphere` with the per-part BSP root spheres (`ShadowObjectRegistry.BuildBspPartSpheres`), i.e. step 1's cheap reject used as the admitting test. Same for the outdoor building bridge (`CEnvCell::check_building_transit` @0x0052c5d0).
|
||||||
|
|
||||||
|
**Why it was deferred rather than folded into #334:** closing it needs a BOX traversal of the containment BSP in BOTH the graph (`BSPQuery`) and the production flat (`FlatBspQuery`) representations, plus their exact referee — a separately gateable change with no bearing on #334's outdoor defect, and one that no #334 gate would exercise. Adding ~150 lines of unverified geometry under a green-but-uncovering test is the failure mode this campaign has now hit ten times.
|
||||||
|
|
||||||
|
**Files:** `src/AcDream.Core/Physics/CellTransit.cs` (`BuildShadowCellSetFromParts` indoor arm, `FindTransitCellsSphere`); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`BuildBspPartSpheres`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## #333 — The shadow broadphase reach filter measures from the PART ORIGIN, so an off-centre BSP part can be in the right cell and still never be tested
|
## #333 — The shadow broadphase reach filter measures from the PART ORIGIN, so an off-centre BSP part can be in the right cell and still never be tested
|
||||||
|
|
||||||
**Status:** OPEN
|
**Status:** OPEN
|
||||||
|
|
@ -1741,6 +1766,28 @@ it. Do #297 FIRST — #298 depends on it.
|
||||||
the radar, same defect class, same fix shape (mirror the property into the
|
the radar, same defect class, same fix shape (mirror the property into the
|
||||||
bitfield/snapshot at its source). Filed from the #297 delta review.
|
bitfield/snapshot at its source). Filed from the #297 delta review.
|
||||||
|
|
||||||
|
- **#336 — OPEN — `RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate` is a FOURTH, load-sensitive flake — distinct from #302, #308 and #321. LOW.**
|
||||||
|
`tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs:2381` asserts
|
||||||
|
`GC.GetAllocatedBytesForCurrentThread()` is EXACTLY 0 across a warmed 10,000-iteration
|
||||||
|
steady-contact refresh loop. Observed failing once on 2026-08-06 inside a full-solution
|
||||||
|
`dotnet test AcDream.slnx -c Release -m:1` run (measured 2,944 bytes), then passing on the
|
||||||
|
immediate full-suite retry, on both of two isolated `AcDream.Runtime.Tests` project runs,
|
||||||
|
and on a filtered single-test run.
|
||||||
|
**Filed rather than absorbed, and deliberately NOT conflated with the other three.**
|
||||||
|
It shares #302's MECHANISM (an exact `GC.GetAllocatedBytesForCurrentThread()` assertion,
|
||||||
|
sensitive to JIT tiering and background GC on the measuring thread) but it is a different
|
||||||
|
test in a different assembly — #302 is `AcDream.App.Tests`, this is
|
||||||
|
`AcDream.Runtime.Tests` — so “the known allocation flake” would hide whichever of the two
|
||||||
|
is real on any given run. #308 is a wall-clock deadline in `AcDream.Core.Net.Tests`;
|
||||||
|
#321 is a concurrent-decode dedup in the sound cache. Four mechanisms, four rows.
|
||||||
|
**Not caused by #334's cell-membership port,** which is the change in flight when it was
|
||||||
|
seen: the measured loop calls only `RuntimeCollisionReportingState` handling, registers no
|
||||||
|
shadow inside the measurement, and touches none of `CellTransit` / `ShadowObjectRegistry` /
|
||||||
|
`ShadowShape`. Fix shape, same as #302: warm the path before measuring, or assert a bounded
|
||||||
|
range rather than an exact zero — matching how the other allocation gates in the repo are
|
||||||
|
written. Do not delete the assertion; the 0 B/resolve budget it guards is a real Slice I1
|
||||||
|
invariant.
|
||||||
|
|
||||||
- **#302 — OPEN — `PortalProjectionTests.ClipToRegion_FrameOwnedStore_ReusesExactResultArray`
|
- **#302 — OPEN — `PortalProjectionTests.ClipToRegion_FrameOwnedStore_ReusesExactResultArray`
|
||||||
is flaky. LOW.** Measured 1 failure in 6 consecutive isolated runs of
|
is flaky. LOW.** Measured 1 failure in 6 consecutive isolated runs of
|
||||||
`AcDream.App.Tests` at `88348f67`, and once in a full-suite run that passed on
|
`AcDream.App.Tests` at `88348f67`, and once in a full-suite run that passed on
|
||||||
|
|
@ -10556,7 +10603,19 @@ missing is the plugin-API surface.
|
||||||
|
|
||||||
## #334 — Large static formations lose collision at their boundaries (Neftet)
|
## #334 — Large static formations lose collision at their boundaries (Neftet)
|
||||||
|
|
||||||
**Status:** OPEN
|
**Status:** DONE (2026-08-06) — retail's `CPhysicsObj::find_bbox_cell_list` @0x00510fc0 path is ported. A physics-BSP object's outdoor cell membership is now the FILLED land-cell rectangle its authored `CGfxObj::gfx_bound_box` spans, crossing landblock boundaries freely, instead of the fixed 3×3 sphere neighbourhood. Awaiting the user's live gate at the same Neftet formations.
|
||||||
|
|
||||||
|
**Fix.** `CellTransit.BuildShadowCellSetFromParts` + `CellTransit.AddAllOutsideCellsFromParts` (`CLandCell::add_all_outside_cells` @0x00533360 + `add_cell_block` @0x005331d0, disassembled from the PDB-paired 2013-09-06 binary — Binary Ninja mis-renders four separate constructs inside that one function); `ShadowPartGeometry` / `ShadowPartBox` carry the BSP root sphere AND the authored box as one value; `ShadowObjectRegistry.RegisterMultiPart` dispatches on `HAS_PHYSICS_BSP_PS` exactly as retail does at `0x00515285`, and `BuildFloodSpheres`' BSP arm is deleted rather than left unreachable. Register: AP-156's outdoor half CLOSED and its risk column CORRECTED (it read “extra broadphase candidates, never a missed one” — #334 is a missed one); AP-159 (indoor part-array overload, issue #335) and AD-49 (seed-time rectangle) filed.
|
||||||
|
|
||||||
|
**Cost, measured over the installed DATs BEFORE any code was written** (1,258 physics-BSP GfxObjs with vertices): cells/object p50 = 4, p90 = 4, p99 = 12, max 49 (7×7). The port is CHEAPER than the old 3×3 = 9 for 98.97% of them — the crossover is exact, any object under 24 m of XY extent yields at most 2×2. Row totals (`shapes × cells`) over all 1,031 landblocks carrying BSP owners fall from 97,173 to 15,607 (0.161×); dense Arwic 0xC6A9 falls 342 → 43 (0.126×). Exactly ONE landblock more than doubles (0x8964, 45 → 112 rows, 2.489×). The worst single-owner rectangle in the whole world is 81 cells (9×9) in 0x8766 — above the 7×7 bound predicted from root-sphere statistics, because that bound assumed the BSP root sphere bounds the whole vertex array and it bounds only the physics polygons' subset.
|
||||||
|
|
||||||
|
**Precondition confirmed before any expected cell set was pinned:** `0x010046D8`'s authored box is 96 m × 96 m about a part origin at block-local (63.78, 56.29) — cell (2,2) = `0x87640013`, which independently corroborates the 3×3-centred-at-`0x87640013` diagnosis derived from the live probe. Its rectangle spans cell columns 0..4 on both axes and DOES contain `0x87640011` and `0x87640019`, the two cells the probe measured empty.
|
||||||
|
|
||||||
|
**Gate note (AP-158 / #333):** the fix is necessary and not sufficient in general. A player at the far corner of a large new rectangle can still be discarded by the broadphase reach filter, which measures from the part origin. The live gate FAILS if `inCell` rises while `rejectedReach` rises with it; the remedy for that is #333, not a wider budget here.
|
||||||
|
|
||||||
|
Original finding below.
|
||||||
|
|
||||||
|
**Status (at filing):** OPEN
|
||||||
**Severity:** HIGH — walk-through and fall-through on world geometry.
|
**Severity:** HIGH — walk-through and fall-through on world geometry.
|
||||||
**Filed:** 2026-08-06, user-reported in live play.
|
**Filed:** 2026-08-06, user-reported in live play.
|
||||||
**Component:** physics / collision / broadphase
|
**Component:** physics / collision / broadphase
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
774
docs/research/2026-08-06-334-contract.md
Normal file
774
docs/research/2026-08-06-334-contract.md
Normal file
|
|
@ -0,0 +1,774 @@
|
||||||
|
# #334 — implementation contract: port retail's BSP cell-membership path
|
||||||
|
|
||||||
|
**Filed** 2026-08-06. **Base** `f0588725`, branch
|
||||||
|
`claude/resume-session-e0bd03e1-d5bf45`.
|
||||||
|
**Status** IMPLEMENTED 2026-08-06. S0 measured (see the ISSUES #334 entry for the
|
||||||
|
figures); the S1/S2 split was collapsed into one commit because S1 alone changes no
|
||||||
|
behaviour and its P2 golden is asserted by S2 test T6. Deviations from this contract,
|
||||||
|
all reported at the fix: (a) §2.7 / T7 — the INDOOR part-array overload is NOT ported
|
||||||
|
and is now AP-159 / issue #335, so the fix covers the outdoor half only, which is the
|
||||||
|
whole of the measured defect; (b) the outdoor rectangle runs at seed time rather than
|
||||||
|
only from find_bbox_cell_list’s residency-gated walk (AD-49); (c) §11.4 is RESOLVED —
|
||||||
|
CCellPortal::GetOtherCell @0x0053ba30 IS handed cellarray->do_not_load_cells as its
|
||||||
|
single explicit thiscall argument (0x0052cc27/0x0052cc2b); (d) §4.2’s 7×7 upper bound
|
||||||
|
is EXCEEDED in the field — the worst single-owner rectangle over all installed
|
||||||
|
landblocks is 9×9, for the reason the contract itself caveated. Everything else in
|
||||||
|
§1 was independently byte-verified by disassembly and held.
|
||||||
|
|
||||||
|
**Original status** CONTRACT ONLY — no production code, no tests, no commit in the
|
||||||
|
session that produced this file.
|
||||||
|
|
||||||
|
**One line.** acdream has never implemented retail's
|
||||||
|
`CPhysicsObj::find_bbox_cell_list` path at all. Every object — including
|
||||||
|
BSP-bearing ones — is routed through a port of the *other* branch,
|
||||||
|
`CObjCell::find_cell_list`, whose outdoor expansion is a fixed 3×3 land-cell
|
||||||
|
neighbourhood. The fix is to implement the missing path, not to enlarge
|
||||||
|
anything.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Executive summary
|
||||||
|
|
||||||
|
Retail dispatches cell membership on `HAS_PHYSICS_BSP_PS` (0x10000) into two
|
||||||
|
structurally different algorithms. acdream implements one of them and uses it
|
||||||
|
for both.
|
||||||
|
|
||||||
|
| | retail | acdream at `f0588725` |
|
||||||
|
|---|---|---|
|
||||||
|
| BSP-bearing object | `find_bbox_cell_list` → per-part **bounding box** → filled land-cell **rectangle** | `BuildShadowCellSet` → per-part **sphere** → **3×3** neighbourhood |
|
||||||
|
| CylSphere object | `find_cell_list(cylspheres)` → 3×3 per sphere | same |
|
||||||
|
| Sorting-sphere object | `find_cell_list(sortingSphere)` → 3×3 | same shape, different source field (AP-157) |
|
||||||
|
|
||||||
|
The outdoor 3×3 is a **hard cap of ±1 cell (±24 m)** and is independent of the
|
||||||
|
sphere's radius — see §9.3 for the proof. This is why AP-156 (which fixed the
|
||||||
|
sphere's *position*) could not fix #334, and why widening the radius, adding a
|
||||||
|
second sphere, or tuning any constant cannot fix it either. Those are not
|
||||||
|
merely disallowed by policy; they are mechanically incapable of adding a tenth
|
||||||
|
cell.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Stage 1 — what retail actually does
|
||||||
|
|
||||||
|
All addresses below were **disassembled from the PDB-paired binary**
|
||||||
|
`C:\Users\erikn\Downloads\acclient.exe`
|
||||||
|
(`py tools/pdb-extract/check_exe_pdb.py` → `=== MATCH ===`, linker
|
||||||
|
2013-09-06T00:17:56Z, CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`),
|
||||||
|
not taken from Binary Ninja. Every address in §1 was resolved to the construct
|
||||||
|
claimed for it. Four BN artifacts found in the process are listed in §9.
|
||||||
|
|
||||||
|
### 1.1 The dispatch (`CPhysicsObj::calc_cross_cells` @`0x00515230`, pc:283332)
|
||||||
|
|
||||||
|
```
|
||||||
|
00515285 f786a800000000000100 test dword ptr [esi + 0xa8], 0x10000
|
||||||
|
0051528f 7574 jne 0x515305 ; -> find_bbox_cell_list
|
||||||
|
00515291 8b4e10 mov ecx, [esi + 0x10] ; part_array
|
||||||
|
00515298 e8e32d0000 call 0x518080 ; GetNumCylsphere
|
||||||
|
0051529f 743b je 0x5152dc ; 0 -> sorting sphere
|
||||||
|
```
|
||||||
|
|
||||||
|
`0xa8` is `CPhysicsObj::state`; `0x10000` is `HAS_PHYSICS_BSP_PS`
|
||||||
|
(`acclient.h:2833`). The static twin
|
||||||
|
`CPhysicsObj::calc_cross_cells_static` @`0x00515160` (pc:283280) carries the
|
||||||
|
**identical** gate at `0x005151b0` and differs only in setting
|
||||||
|
`CELLARRAY::do_not_load_cells = 1`. Both tails are
|
||||||
|
`remove_shadows_from_cells` → `add_shadows_to_cells`.
|
||||||
|
|
||||||
|
### 1.2 The flood driver (`CPhysicsObj::find_bbox_cell_list` @`0x00510fc0`, pc:279006)
|
||||||
|
|
||||||
|
This function **forms no bounding box itself** — it is a worklist. That is the
|
||||||
|
grain of truth in the "no bounding box at all" claim, and it is why that claim
|
||||||
|
is misleading: the boxes are formed one and two levels down (§1.4, §1.6).
|
||||||
|
|
||||||
|
```
|
||||||
|
00510fd5 mov eax,[ebx+0x90] ; obj->cell
|
||||||
|
00510fe2 call 0x6b4ff0 ; CELLARRAY::add_cell(ca, cell->m_DID.id, cell) <- seed
|
||||||
|
00510ff8 mov eax,[esi+8] ; num_cells
|
||||||
|
00511012 call 0x518160 ; CPartArray::calc_cross_cells_static(pa, cell_i, ca)
|
||||||
|
00511017 mov eax,[esi+8] ; num_cells RE-READ each iteration <- the array GROWS
|
||||||
|
0051101d jb 0x511002
|
||||||
|
```
|
||||||
|
|
||||||
|
Seed with the object's own cell, then walk the array while it grows. Transitive
|
||||||
|
closure over the cell graph, terminated by `CELLARRAY::add_cell`'s dedup.
|
||||||
|
|
||||||
|
### 1.3 The per-cell dispatch (`CPartArray::calc_cross_cells_static` @`0x00518160`, pc:286228)
|
||||||
|
|
||||||
|
Three-instruction thunk. **Not** the extent walk, despite the name:
|
||||||
|
|
||||||
|
```
|
||||||
|
00518176 ff527c call dword ptr [edx + 0x7c] ; cell->vtable[0x7c]
|
||||||
|
```
|
||||||
|
|
||||||
|
`CObjCell`'s vftable base is `0x007c8b20`; `+0x7c` = `0x007c8b9c`, which holds
|
||||||
|
`0x0052b080` — the 4-argument
|
||||||
|
`find_transit_cells(uint numParts, CPhysicsPart** parts, CELLARRAY*)`
|
||||||
|
overload, distinct from the 6-argument `(Position, uint, CSphere*, CELLARRAY*,
|
||||||
|
SPHEREPATH*)` sphere/transition overload at `0x0052b070`.
|
||||||
|
|
||||||
|
Overrides: `CEnvCell` @`0x0052cae0` (pc:310127), `CLandCell` @`0x00533840`
|
||||||
|
(pc:317612), `CSortCell` @`0x00534080` (pc:318323), base `CObjCell`
|
||||||
|
@`0x0052b080` = `Turbine::Debug::Abort()`.
|
||||||
|
|
||||||
|
### 1.4 Outdoors — the extent walk (`CLandCell::add_all_outside_cells` @`0x00533360`, pc:317289)
|
||||||
|
|
||||||
|
`CLandCell::find_transit_cells` = `add_all_outside_cells` + `CSortCell`'s
|
||||||
|
building bridge. The extent walk is here.
|
||||||
|
|
||||||
|
```
|
||||||
|
if (cellarray.added_outside) return; # 0053336c, runs ONCE per flood
|
||||||
|
cellarray.added_outside = 1;
|
||||||
|
p0 = first non-null part; # 005333a2..005333ad
|
||||||
|
gid = adjust_to_outside(&p0->pos) ? outCellId : 0;
|
||||||
|
# 005333dd call 0x5a9bc0
|
||||||
|
# 005333eb neg esi / sbb esi,esi / and esi,eax
|
||||||
|
cell0 = LScape::get_landcell(landscape, gid); # 0053340c
|
||||||
|
if (!cell0) return; # 00533417 je 0x53361c
|
||||||
|
if (!gid_to_lcoord(gid, &gx, &gy)) return; # 00533428, GLOBAL land-cell coords
|
||||||
|
baseX = ((gid & 0xFFFF) - 1) >> 3; # 0053343a and eax,0xffff / dec / shr 3
|
||||||
|
baseY = (gid - 1) & 7; # 00533443 dec esi / and esi,7
|
||||||
|
minDX = minDY = maxDX = maxDY = 0; # 00533390..0053339c
|
||||||
|
for each non-null part p:
|
||||||
|
if (!p->Always2D()):
|
||||||
|
b = BBox::LocalToGlobal(p->gfxobj->gfx_bound_box, p->pos, cell0->pos);
|
||||||
|
# 0053350e GetBoundingBox, 00533527 LocalToGlobal
|
||||||
|
a = floor(b.min.x / 24); bb = floor(b.min.y / 24);
|
||||||
|
c = floor(b.max.x / 24); d = floor(b.max.y / 24);
|
||||||
|
else:
|
||||||
|
(sphere centre -/+ radius) / 24, floored
|
||||||
|
minDX = min(minDX, a - baseX); # 005335a2 sub esi,edx / jge
|
||||||
|
minDY = min(minDY, bb - baseY); # 005335b4
|
||||||
|
maxDX = max(maxDX, c - baseX); # 005335c2 / jle
|
||||||
|
maxDY = max(maxDY, d - baseY); # 005335d5
|
||||||
|
add_cell_block(gx+minDX, gy+minDY, gx+maxDX, gy+maxDY, cellarray); # 00533614
|
||||||
|
```
|
||||||
|
|
||||||
|
**The four stack reads are byte-verified as `min.x, min.y, max.x, max.y`.** The
|
||||||
|
`fld` displacements (`[esp+0x48]`, `[esp+0x54]`, `[esp+0x5c]`, `[esp+0x60]`)
|
||||||
|
look inconsistent because `sub esp,8` at `0x533536` and `add esp,8` at
|
||||||
|
`0x533592` bracket the middle three; normalised to the entry frame they are
|
||||||
|
`+0x48, +0x4c, +0x54, +0x58`, and the out-`BBox` written by `LocalToGlobal`
|
||||||
|
(`lea ecx,[esp+0x54]` at three-pushes depth) is based at `+0x48`. A `BBox` is
|
||||||
|
`m_vMin`(0,4,8) `m_vMax`(0xc,0x10,0x14), so those four are exactly
|
||||||
|
min.x / min.y / max.x / max.y. Z is never read — land cells are a 2-D grid.
|
||||||
|
|
||||||
|
The four accumulators are initialised to **0**, so the rectangle always
|
||||||
|
contains the base cell even when the box math contributes nothing.
|
||||||
|
|
||||||
|
`square_length` = `0x7c920c` = `24.0f`, read from the binary
|
||||||
|
(`00 00 c0 41`).
|
||||||
|
|
||||||
|
### 1.5 Filling the rectangle (`CLandCell::add_cell_block` @`0x005331d0`, pc:317202)
|
||||||
|
|
||||||
|
```
|
||||||
|
for x = x0 .. x1 inclusive: # 005331e4 / 0053324d jle
|
||||||
|
for y = y0 .. y1 inclusive: # 005331f0 / 00533246 jle
|
||||||
|
if (x >= 0 && y >= 0 && x < 0x7f8 && y < 0x7f8): # 2040 = 255*8
|
||||||
|
id = (((x >> 3) << 8) | (y >> 3)) << 16 | ((x & 7) * 8 + (y & 7) + 1)
|
||||||
|
# 0053320a..0053322e
|
||||||
|
add_cell(ca, id, LScape::get_landcell(landscape, id))
|
||||||
|
```
|
||||||
|
|
||||||
|
Three properties that matter:
|
||||||
|
|
||||||
|
1. **The rectangle is filled, not outlined.** An L-shaped or diagonal object
|
||||||
|
claims cells its geometry never enters. Retail's coverage is deliberately
|
||||||
|
conservative.
|
||||||
|
2. **`x`/`y` are GLOBAL land-cell coordinates** over the 2040×2040 world grid
|
||||||
|
and the landblock prefix is re-derived per cell, so the rectangle **crosses
|
||||||
|
landblock boundaries freely**.
|
||||||
|
3. `add_cell` (`0x006b4ff0`) dedups by **id** via a linear scan and stores the
|
||||||
|
`LScape` pointer beside it — including `null` for a non-resident cell.
|
||||||
|
|
||||||
|
`gid_to_lcoord` @`0x00497a90` (pc:163500) byte-verified to return global
|
||||||
|
coords: `*x = ((gid>>21) & 0x7f8) + ((cellIdx-1)>>3)`,
|
||||||
|
`*y = (lby << 3) + ((cellIdx-1) & 7)`.
|
||||||
|
|
||||||
|
### 1.6 The box itself (`CPhysicsPart::GetBoundingBox` @`0x0050d600`, pc:274837)
|
||||||
|
|
||||||
|
```
|
||||||
|
0050d60a return &this->gfxobj->gfx_bound_box;
|
||||||
|
```
|
||||||
|
|
||||||
|
`gfx_bound_box` is filled by `CGfxObj::init_end` @`0x00534200` (pc:318480):
|
||||||
|
seed `min = max = vertices[0]`, then `BBox::AdjustBBox` over every vertex of
|
||||||
|
`vertex_array`. **It is the AABB of the GfxObj's vertex array in the GfxObj's
|
||||||
|
own frame** — the render vertex array, which is also the array the physics
|
||||||
|
polygons index into.
|
||||||
|
|
||||||
|
`BBox::LocalToGlobal` @`0x005b2120` (pc:448440) is a proper **8-corner
|
||||||
|
re-fit**: transform `min`, seed both corners from it, transform the other
|
||||||
|
seven and `AdjustBBox` each. A rotated box therefore grows, conservatively.
|
||||||
|
The output frame is `cell0->pos`'s — i.e. landblock-local metres, which is
|
||||||
|
what makes `floor(v / 24)` comparable to `baseX`/`baseY` in 0..7.
|
||||||
|
|
||||||
|
### 1.7 Indoors (`CEnvCell::find_transit_cells` @`0x0052cae0`, pc:310127)
|
||||||
|
|
||||||
|
Per portal × per part:
|
||||||
|
|
||||||
|
- centre of the part's physics sphere in cell-local space
|
||||||
|
(`Position::localtolocal`), tested against the portal plane with
|
||||||
|
`eps = 0.0002 + radius` (`0x0052cb65`) — a cheap reject;
|
||||||
|
- on pass, `BBox::LocalToLocal(partBBox, part->pos, cell->pos)` then
|
||||||
|
`Plane::intersect_box(portalPlane, box)` (`0x0052cc05`). **The admitting
|
||||||
|
test is box-vs-plane, not sphere-vs-plane.**
|
||||||
|
- if the result differs from `portal_side`: `other_cell_id == 0xFFFFFFFF` sets
|
||||||
|
a flag meaning *this portal leads outside*; otherwise
|
||||||
|
`BBox::LocalToLocal` into the destination cell and
|
||||||
|
`CCellStruct::box_intersects_cell` gates the add.
|
||||||
|
- after all portals, the outside flag runs
|
||||||
|
`CLandCell::add_all_outside_cells` (`0x0052ccea`).
|
||||||
|
|
||||||
|
### 1.8 Answer to "is retail exact or conservative?"
|
||||||
|
|
||||||
|
**Conservative, in four compounding ways**, all in the over-inclusive
|
||||||
|
direction: the render-mesh AABB rather than the physics hull; axis-aligned
|
||||||
|
re-fit after rotation; a *filled* rectangle rather than a per-cell test; one
|
||||||
|
rectangle unioned across all parts rather than per-part rectangles. Retail
|
||||||
|
registers the object in cells its geometry does not touch and lets the narrow
|
||||||
|
phase reject. That is the safe direction (#98 / #168 are the other one), and it
|
||||||
|
means a faithful port does not need to be clever.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The exact change, by symbol
|
||||||
|
|
||||||
|
### 2.1 New: `CellTransit.BuildShadowCellSetFromParts` (`src/AcDream.Core/Physics/CellTransit.cs`)
|
||||||
|
|
||||||
|
Port of `find_bbox_cell_list` (§1.2). Signature mirrors
|
||||||
|
`BuildShadowCellSet`, taking part boxes instead of spheres:
|
||||||
|
|
||||||
|
```
|
||||||
|
public static IReadOnlyList<uint> BuildShadowCellSetFromParts(
|
||||||
|
PhysicsDataCache cache,
|
||||||
|
uint seedCellId,
|
||||||
|
IReadOnlyList<ShadowPartBox> worldParts, // new value type, §2.3
|
||||||
|
bool isStatic)
|
||||||
|
```
|
||||||
|
|
||||||
|
Body: seed with `seedCellId`; walk `candidates` by index while it grows
|
||||||
|
(re-reading `Count`, §1.2); per candidate dispatch outdoor →
|
||||||
|
`AddAllOutsideCellsFromParts` + the existing building bridge, indoor →
|
||||||
|
`FindTransitCellsParts`.
|
||||||
|
|
||||||
|
### 2.2 New: `CellTransit.AddAllOutsideCellsFromParts`
|
||||||
|
|
||||||
|
Port of §1.4 + §1.5. Reuses the existing `AddOutsideCell` helper (already
|
||||||
|
global-lcoord and already landblock-crossing — do not touch it) inside a
|
||||||
|
double loop, with the `0 <= v < 0x7f8` clamp from §1.5. Guarded by the same
|
||||||
|
once-per-flood `added_outside` latch `BuildShadowCellSet` already models, but
|
||||||
|
note the **cardinality change**: the sphere overload runs the whole body per
|
||||||
|
sphere; the parts overload computes **one** rectangle over all parts and runs
|
||||||
|
once.
|
||||||
|
|
||||||
|
### 2.3 New: `ShadowPartBox` (`src/AcDream.Core/Physics/`)
|
||||||
|
|
||||||
|
`(Vector3 LocalMin, Vector3 LocalMax, Vector3 LocalPosition, Quaternion
|
||||||
|
LocalRotation, float Scale)` — the per-part input to the 8-corner re-fit.
|
||||||
|
Follow `ShadowShape`'s AP-156 precedent: **factory-only construction, with min
|
||||||
|
and max arriving as one value**, so no future call site can take one and drop
|
||||||
|
the other.
|
||||||
|
|
||||||
|
### 2.4 Changed: `ShadowShape` — carry the box
|
||||||
|
|
||||||
|
Add `LocalBoundsMin` / `LocalBoundsMax`, filled by the **same resolver that
|
||||||
|
already supplies `Radius` and `BoundsCenter`**. This is the AP-156 invariant
|
||||||
|
re-applied: one resolver, one value, scaled together.
|
||||||
|
|
||||||
|
Source: `FlatGfxObjVisualBounds.Min` / `.Max`, which
|
||||||
|
`FlatCollisionAssetBuilder.FlattenGfxObj` already computes from
|
||||||
|
`PhysicsDataCache.ComputeVisualBounds(source.VertexArray)` — **the exact
|
||||||
|
`CGfxObj::init_end` computation** — and which
|
||||||
|
`FlatCollisionAssetSerializer` already writes into the prepared package. **No
|
||||||
|
bake-format change, no DAT re-read, no new parsing.** This is the single
|
||||||
|
largest de-risking fact in this contract.
|
||||||
|
|
||||||
|
Resolvers to widen: `ShadowShapeBuilder.FromSetup`'s
|
||||||
|
`physicsBspBounds: Func<uint, FlatCollisionSphere?>` and
|
||||||
|
`FromLandblockBspParts`'s `Func<uint, GfxObjPhysics?> getGfxObj`;
|
||||||
|
`LiveEntityCollisionBuilder._physicsBspBounds` is the single live supplier.
|
||||||
|
|
||||||
|
### 2.5 Changed: `ShadowObjectRegistry.RegisterMultiPart`
|
||||||
|
|
||||||
|
The dispatch, mirroring §1.1 — this is the whole fix in one place:
|
||||||
|
|
||||||
|
```
|
||||||
|
bool hasBsp = shapes.Any(s => s.CollisionType == ShadowCollisionType.BSP);
|
||||||
|
var cellSet = hasBsp
|
||||||
|
? CellTransit.BuildShadowCellSetFromParts(FloodCache, seed, boxes, isStatic)
|
||||||
|
: CellTransit.BuildShadowCellSet (FloodCache, seed, spheres, spheres.Count, isStatic);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.6 What happens to `BuildFloodSpheres`
|
||||||
|
|
||||||
|
**It stays, unchanged, and keeps its cap logic** — it is a correct port of the
|
||||||
|
`!HAS_PHYSICS_BSP` branch's two arms, which retail still uses for CylSphere and
|
||||||
|
sorting-sphere objects. What changes is that its **BSP arm becomes dead**: with
|
||||||
|
the §2.5 dispatch, a shape list containing a BSP shape never reaches it.
|
||||||
|
|
||||||
|
Delete the BSP arm rather than leaving it unreachable. That arm's XML doc
|
||||||
|
(`ShadowObjectRegistry.cs:436-442`, "A BSP part contributes its ROOT BOUNDING
|
||||||
|
SPHERE placed at its real center") becomes false the moment §2.5 lands and must
|
||||||
|
go with it. Leaving a dead-but-plausible BSP arm behind is exactly how a future
|
||||||
|
producer silently re-acquires the bug.
|
||||||
|
|
||||||
|
Objects that legitimately are spherical are **untouched**: same function, same
|
||||||
|
cap, same 3×3, byte-identical cell sets. That is proof obligation P2.
|
||||||
|
|
||||||
|
### 2.7 Indoor half: `CellTransit.FindTransitCellsParts`
|
||||||
|
|
||||||
|
Port of §1.7 alongside `FindTransitCellsSphere` (which stays for the sphere
|
||||||
|
route). This is the half AP-156's row already names as its open residual.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Interaction with what landed tonight
|
||||||
|
|
||||||
|
### 3.1 AP-156 (`b52967de`) — **this port RETIRES its open residual**
|
||||||
|
|
||||||
|
AP-156's row states its remainder explicitly: *"Closing it means porting the
|
||||||
|
per-cell `find_transit_cells` part-array overload, which is different work from
|
||||||
|
getting the sphere set right."* That is precisely §2.1/§2.2/§2.7. **Sequential,
|
||||||
|
not competing; AP-156 is a prerequisite and stands.**
|
||||||
|
|
||||||
|
Two register consequences, both in the same commit as the fix:
|
||||||
|
|
||||||
|
- **AP-156's Risk column is FALSE as written and must be corrected before it is
|
||||||
|
retired.** It records the traversal residual as *"extra broadphase
|
||||||
|
candidates, never a missed one."* #334 is a missed one. The row generalised
|
||||||
|
the **indoor** direction (sphere-vs-portal-plane, over-inclusive) to the
|
||||||
|
whole residual, and the **outdoor** direction is the opposite: a fixed 3×3
|
||||||
|
that is under-inclusive for every object wider than one cell. Correct the row
|
||||||
|
first, then retire it — a row deleted while still carrying a false risk
|
||||||
|
statement takes the finding with it.
|
||||||
|
- `BoundsCenter` stays. It still positions the sphere for the non-BSP routes
|
||||||
|
and for the `eps = 0.0002 + radius` portal pre-reject in §1.7.
|
||||||
|
|
||||||
|
### 3.2 AP-152 (`4abd1b5e`) — **preserved and depended on, not conflicting**
|
||||||
|
|
||||||
|
AP-152 made shape emission BSP-exclusive: a BSP-bearing Setup emits BSP shapes
|
||||||
|
and no primitive. §2.5's `hasBsp` predicate is therefore *unambiguous* — post
|
||||||
|
AP-152 a shape list is homogeneous in practice, so "has a BSP shape" and
|
||||||
|
"is a BSP object" coincide, exactly as retail's cached `HAS_PHYSICS_BSP_PS`
|
||||||
|
does. **Without AP-152 this dispatch would be ill-defined.** Nothing to narrow
|
||||||
|
or retire; add a cross-reference from AP-152's row.
|
||||||
|
|
||||||
|
### 3.3 AP-158 / #333 — **a blocking interaction, and the one thing that can make this fix look like it did nothing**
|
||||||
|
|
||||||
|
The broadphase reach filter discards a candidate when
|
||||||
|
`distToCurr > sphereRadius + obj.Radius + movement + 2f`, measuring from the
|
||||||
|
**part origin**. This port's whole purpose is to register objects in cells
|
||||||
|
*further from the part origin than the sphere reaches* — which is the precise
|
||||||
|
input that makes AP-158 fire.
|
||||||
|
|
||||||
|
Bound: a player standing at the far corner of the new rectangle is up to
|
||||||
|
`~1.73·R + |BoundsCenter|` from the part origin, against a budget of
|
||||||
|
`R + r + move + 2`. For `R = 69.471` and the measured
|
||||||
|
`|BoundsCenter| = 34.977` that is ~155 m tested against ~72 m — **rejected**.
|
||||||
|
|
||||||
|
For the specific Neftet object the fix does still work: the player positions in
|
||||||
|
the two adjacent cells sit ~50 m from the part origin against a ~72 m budget,
|
||||||
|
so those cells pass. **But the general statement is that #334's fix is
|
||||||
|
necessary and not sufficient**, and the AP-156 fix review already recorded this
|
||||||
|
exact failure mode one layer up ("the fix may produce no visible change at all,
|
||||||
|
because the geometry now lands in the right cell and is then discarded by the
|
||||||
|
filter"). Do not let it happen twice.
|
||||||
|
|
||||||
|
**Directive:** the §7 gate must report `rejectedReach` per scenario. A gate that
|
||||||
|
shows `inCell` rise while `rejectedReach` rises with it is a **fail**, and the
|
||||||
|
remedy is #333, not a wider budget here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Cost — measured where possible, and honestly bounded where not
|
||||||
|
|
||||||
|
### 4.1 What the cost is, structurally
|
||||||
|
|
||||||
|
Cells per object changes from **≤ 9, position-dependent** to
|
||||||
|
**(⌈Xextent/24⌉+1) × (⌈Yextent/24⌉+1)**.
|
||||||
|
|
||||||
|
The crossover is exact and favourable: **any object whose XY extent is ≤ 24 m
|
||||||
|
yields at most 2×2 = 4 cells — fewer than today's 9.** The port is *cheaper*
|
||||||
|
for every creature, prop, door and item, and more expensive only for objects
|
||||||
|
wider than one land cell. Those are landblock-baked terrain formations and
|
||||||
|
building shells.
|
||||||
|
|
||||||
|
### 4.2 Measured worst live case
|
||||||
|
|
||||||
|
From the committed probe log (`334-neftet-probe.log`), the only object in the
|
||||||
|
sample above 1.4 m: `gfx=0x010046D8`, `objR = 69.471`,
|
||||||
|
`|bspCentreOffset| = 34.977`. The three other BSP objects observed are
|
||||||
|
1.075 / 1.271 / 1.370 m — i.e. 1×1 rectangles, strictly cheaper than today.
|
||||||
|
|
||||||
|
Upper bound for the outlier: the box is contained in the mesh's extent, so
|
||||||
|
extent ≤ 2R = 138.9 m → at most `floor(138.9/24)+1 = 6` cells per axis, +1 for
|
||||||
|
straddle = **7×7 = 49 cells**, versus 9 today. Caveat stated plainly: this
|
||||||
|
bound assumes the BSP root sphere bounds the whole vertex array; it bounds the
|
||||||
|
*physics polygons'* vertices, which are a subset, so a render-only vertex
|
||||||
|
outside it would exceed the bound.
|
||||||
|
|
||||||
|
### 4.3 Where the cost lands relative to existing budgets
|
||||||
|
|
||||||
|
- **Not on the per-frame resolve path.** Slice I1 measured 0 B/resolve for
|
||||||
|
player, remote, projectile, camera and grounded walkable-publication
|
||||||
|
profiles; the flood is registration-time, not resolve-time. The ordinary
|
||||||
|
production profile's CPU/GPU p50 of 1.869 / 1.096 ms is not exposed to it.
|
||||||
|
- **Landblock statics** (`isStatic: true`, both hosts): once per landblock
|
||||||
|
publication, already metered by the Slice E retirement/publication budgets.
|
||||||
|
- **Live remotes:** `RuntimeRemotePhysicsUpdater` re-floods per tick, gated on
|
||||||
|
>1 cm movement / rotation / cell change. Creature extents are ≪ 24 m → ≤ 4
|
||||||
|
cells → **strictly cheaper than the current 3×3 on the hottest path in the
|
||||||
|
system.**
|
||||||
|
|
||||||
|
### 4.4 The real cost is memory, not CPU
|
||||||
|
|
||||||
|
`_cells` is `Dictionary<uint, List<ShadowEntry>>` and
|
||||||
|
`RegisterMultiPart` writes **every shape row into every flooded cell**. Rows
|
||||||
|
per object = `shapes × cells`. For a many-part baked formation at 49 cells this
|
||||||
|
is a 5.4× row multiplication over today's 9. Landblock-baked part arrays are
|
||||||
|
the population with both the largest part counts and the largest extents, so
|
||||||
|
the two multiply.
|
||||||
|
|
||||||
|
### 4.5 What I could NOT measure, and the measurement to run first
|
||||||
|
|
||||||
|
**I did not enumerate the installed distribution of physics-BSP GfxObj bounding
|
||||||
|
boxes.** It is not derivable from anything in the repo: the register's existing
|
||||||
|
figures (973 physics-BSP parts, 530 BSP-bearing Setups, 477 unique physics-BSP
|
||||||
|
GfxObjs, 118 above 2.5 m offset, 46 above 5 m) are all **sphere** statistics.
|
||||||
|
|
||||||
|
**Required before any code is written** — same route the AP-156 and #333
|
||||||
|
figures used (an out-of-repo scratch program over the installed
|
||||||
|
`client_portal.dat`), reporting over all 477 unique physics-BSP GfxObjs:
|
||||||
|
|
||||||
|
1. histogram of `ceil(Xextent/24)+1` × `ceil(Yextent/24)+1`;
|
||||||
|
2. the count exceeding 1×1, 2×2 and 4×4;
|
||||||
|
3. the worst case, with its gfx id;
|
||||||
|
4. total `Σ shapes × cells` over one dense landblock (Arwic) before and after.
|
||||||
|
|
||||||
|
**Gate:** if the p99 rectangle exceeds 7×7 or the dense-Arwic row total more
|
||||||
|
than doubles, stop and report rather than proceeding. That is the point at
|
||||||
|
which "the faithful port is too expensive" becomes a real finding and the
|
||||||
|
honest alternative — retail's own `CELLARRAY` growth policy, or a shared row
|
||||||
|
rather than a per-cell copy — gets designed deliberately instead of discovered
|
||||||
|
in a profile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Blast radius — BOTH hosts, checked not inferred
|
||||||
|
|
||||||
|
`ShadowObjectRegistry` and `CellTransit` are in **`AcDream.Core`**, which both
|
||||||
|
hosts reference. Project graph read from the `.csproj` files:
|
||||||
|
|
||||||
|
```
|
||||||
|
AcDream.Headless -> AcDream.Runtime -> {Core, Core.Net, Content, Plugin.Abstractions}
|
||||||
|
AcDream.App -> {Runtime, Core, Core.Net, Content, UI.Abstractions, Plugins.Smoke}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.1 Production call sites of `RegisterMultiPart` (complete)
|
||||||
|
|
||||||
|
| site | host reach |
|
||||||
|
|---|---|
|
||||||
|
| `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs:178` | App only |
|
||||||
|
| `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:959, 1044` | App only |
|
||||||
|
| `src/AcDream.Content/LandblockPhysicsContentBuilder.cs:619, 700` | **App AND Headless** |
|
||||||
|
|
||||||
|
### 5.2 Headless is reached — verified by call site, not by dependency inference
|
||||||
|
|
||||||
|
`src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs` calls
|
||||||
|
`LandblockPhysicsContentBuilder.HydrateStaticEntities` (:386),
|
||||||
|
`HydrateProceduralScenery` (:392), `BuildDatBundle` (:403),
|
||||||
|
`PublishPreparedCells` (:435), `CacheBuildings` (:442),
|
||||||
|
`CachePreparedObjects` (:447). Lines 619 and 700 of that builder — the
|
||||||
|
`FromLandblockBspParts` BSP path and the `FromSetup` path — are exactly the
|
||||||
|
sites that register the landblock-baked formations #334 is about.
|
||||||
|
|
||||||
|
**Headless registers the same objects through the same Core code and is
|
||||||
|
affected identically.** This is the survey C5b missed and the direction AP-152's
|
||||||
|
contract got wrong; it is settled here by reading the call sites.
|
||||||
|
|
||||||
|
### 5.3 Consumers that must NOT change
|
||||||
|
|
||||||
|
`_cells` shape is unchanged (same key, same row type), so every reader —
|
||||||
|
`TransitionTypes`, `PhysicsEngine`, `CollisionWorldState`,
|
||||||
|
`RuntimePhysicsState`, `ShadowPositionSynchronizer`,
|
||||||
|
`RuntimeCollisionReportingState`, `ProjectileController`, camera collision —
|
||||||
|
sees only a different *membership*, never a different *shape*. No consumer
|
||||||
|
signature changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Proof obligations and test plan
|
||||||
|
|
||||||
|
### 6.1 Proof obligations
|
||||||
|
|
||||||
|
- **P1 — rectangle equality.** For a BSP object the registered outdoor set
|
||||||
|
equals `add_cell_block(gx+minDX, gy+minDY, gx+maxDX, gy+maxDY)` exactly:
|
||||||
|
not a superset, not a subset, and *filled*.
|
||||||
|
- **P2 — non-BSP invariance.** Cylinder-only and Sphere-only owners register
|
||||||
|
byte-identical cell sets to `f0588725`.
|
||||||
|
- **P3 — map bounds.** No registered cell has a global lcoord outside
|
||||||
|
`[0, 0x7f8)`.
|
||||||
|
- **P4 — host agreement.** App and Headless produce the same cell set for the
|
||||||
|
same landblock and seed.
|
||||||
|
- **P5 — negative-safe floor.** `floor`, not truncation (see trap §8.5).
|
||||||
|
|
||||||
|
### 6.2 Tests — each with the sabotage that must redden it
|
||||||
|
|
||||||
|
Every fixture below is **non-degenerate on the axis under test**: in particular
|
||||||
|
**every BSP fixture has an extent that exceeds its own radius**, which is the
|
||||||
|
whole property at issue. A fixture whose box fits inside its sphere makes the
|
||||||
|
new path and the old path agree and proves nothing — that is the failure mode
|
||||||
|
this campaign has now hit ten times.
|
||||||
|
|
||||||
|
| # | test | sabotage that MUST redden it |
|
||||||
|
|---|---|---|
|
||||||
|
| T1 | Part with 100 m × 100 m box and 1 m sphere → rectangle spans ≥ 5 cells per axis | swap the box for the sphere → collapses to 3×3 |
|
||||||
|
| T2 | Two parts forming an L → the notch cell **is** present (rectangle is filled) | compute per-part rectangles and union them → notch disappears |
|
||||||
|
| T3 | Box reaching past cellX 7 → cells carry the **neighbour landblock's** prefix | clamp the rectangle to the seed landblock |
|
||||||
|
| T4 | Rectangle at the map corner → no cell outside `[0, 0x7f8)` | drop the `0x7f8` clamp |
|
||||||
|
| T5 | Non-cubic box rotated 37° → rectangle grows vs unrotated | transform only min/max instead of all 8 corners |
|
||||||
|
| T6 | Cylinder-only owner → cell set identical to a `f0588725` golden | route every owner through the box path |
|
||||||
|
| T7 | Owner straddling an EnvCell portal whose **box** crosses the plane but whose **sphere** does not → destination cell present | keep `FindTransitCellsSphere` on the BSP route |
|
||||||
|
| T8 | Seed cell not resident → outdoor registration skipped, no throw (§1.4 `if (!cell0) return`) | drop the null check |
|
||||||
|
| T9 | Negative delta (box extends below the base cell) → cells with lower lcoord present | use `(int)(v/24f)` truncation instead of `MathF.Floor` |
|
||||||
|
|
||||||
|
### 6.3 T10 — the installed-DAT replay of the measured evidence (strongest test)
|
||||||
|
|
||||||
|
Assert that `gfx=0x010046D8` (entity `0xC8764000`, position
|
||||||
|
`(63.78, 248.29, 0.08)`, from the committed probe log) registers into
|
||||||
|
**`0x87640011` and `0x87640019`** — the two cells the probe measured EMPTY —
|
||||||
|
as well as `0x8764000A` and `0x87640012`, which it measured populated.
|
||||||
|
|
||||||
|
Sabotage: revert `RegisterMultiPart` to `BuildFloodSpheres` → the two new cells
|
||||||
|
vanish.
|
||||||
|
|
||||||
|
This asserts observed reality and re-encodes no constant under test.
|
||||||
|
|
||||||
|
**Precondition that must be honoured, not assumed.** Whether the box actually
|
||||||
|
reaches those two cells is a **prediction, not a measurement**. §9.3 establishes
|
||||||
|
that the current 3×3 is centred on cell `0x87640013` (x=2, y=2) and therefore
|
||||||
|
structurally cannot reach cellY 0 — that half is proven. Whether
|
||||||
|
`0x010046D8`'s box extends ≥ 48 m in −Y is not.
|
||||||
|
|
||||||
|
**Directive:** measure `0x010046D8`'s `FlatGfxObjVisualBounds` first and derive
|
||||||
|
the expected rectangle from it. If the measured box does **not** reach
|
||||||
|
`0x87640011` / `0x87640019`, **stop and report** — that would mean the
|
||||||
|
diagnosis is incomplete and a second mechanism is present. Do not weaken the
|
||||||
|
test to match; do not pin the cell list before the box is read.
|
||||||
|
|
||||||
|
### 6.4 Not permitted
|
||||||
|
|
||||||
|
No source-text pins. No test asserting `24f` or `0x7f8` by reading the
|
||||||
|
constant it is testing. No test whose expected cell set was produced by running
|
||||||
|
the new code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Gate design — positive evidence, named observables
|
||||||
|
|
||||||
|
`ACDREAM_PROBE_REACH` (`b61f5fd4`,
|
||||||
|
`PhysicsDiagnostics.ProbeReachEnabled`) produces the before/after comparison
|
||||||
|
directly. Capture one log at `f0588725` and one at the fix commit, same route.
|
||||||
|
|
||||||
|
**Per-scenario pass condition** — all three must hold:
|
||||||
|
|
||||||
|
1. a `[reach-obj]` row with `gfx=0x010046D8` appears in cells where it did not
|
||||||
|
before;
|
||||||
|
2. that row's `disp` is `tested-*`, **not** `rejected-reach` (§3.3);
|
||||||
|
3. `[reach-q]`'s `inCell` rises **and** `rejectedReach` does **not** rise with
|
||||||
|
it.
|
||||||
|
|
||||||
|
**Scenarios, named by the user's own report:**
|
||||||
|
|
||||||
|
- **G1 — walk through on flat ground.** Approach a formation on level ground
|
||||||
|
and walk into its face. Observable: blocked, `blocked ≥ 1`.
|
||||||
|
- **G2 — the boundary between two formations.** Walk along the seam where two
|
||||||
|
formations meet — the exact geometry the report calls out. Observable:
|
||||||
|
continuously blocked across the seam; before the fix `inCell=2 exempt=2` with
|
||||||
|
no rock row, after it a rock row in every cell along the seam.
|
||||||
|
- **G3 — jump over and land inside.** Jump onto/over a formation. Observable:
|
||||||
|
lands **on** the geometry, does not fall through; a walkable contact plane is
|
||||||
|
reported at the landing tick.
|
||||||
|
- **G4 — a formation the fix should not change.** Any 1×1-extent prop nearby:
|
||||||
|
its cell set must be unchanged (visual P2 corroboration).
|
||||||
|
|
||||||
|
**Regression gates:** Release build (after deleting all `bin`/`obj` — four
|
||||||
|
stale-DLL incidents this session, one under `-t:Rebuild`); complete solution
|
||||||
|
suite; the exact-binary lifecycle/reconnect route; the canonical nine-stop
|
||||||
|
route; the native-Linux Headless multi-session run, since §5.2 puts Headless in
|
||||||
|
scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Traps
|
||||||
|
|
||||||
|
1. **AP-158 masks the fix in far cells.** §3.3. The most likely way this lands
|
||||||
|
green and changes nothing the user can see.
|
||||||
|
2. **Cardinality change.** The sphere overload runs its whole body *per
|
||||||
|
sphere*; the parts overload computes **one** rectangle over all parts and
|
||||||
|
runs **once**. Reusing the sphere loop's per-item structure produces N
|
||||||
|
rectangles and silently breaks T2.
|
||||||
|
3. **`0x00518160` is not the extent walk.** It is a 3-instruction vtable thunk.
|
||||||
|
The extent walk is `0x00533360`. #334's issue body cites the former as "a
|
||||||
|
walk over the object's extent" (§9.2).
|
||||||
|
4. **Two functions named `calc_cross_cells_static`.**
|
||||||
|
`CPhysicsObj::` @`0x00515160` is a *caller* of `find_bbox_cell_list`;
|
||||||
|
`CPartArray::` @`0x00518160` is the thunk *below* it. They are not on the
|
||||||
|
same level and confusing them inverts the call graph.
|
||||||
|
5. **`floor`, not truncation.** Retail calls `floor` then `_ftol2`. C#
|
||||||
|
`(int)(v / 24f)` truncates toward zero and is wrong for every negative
|
||||||
|
block-local coordinate. T9.
|
||||||
|
6. **The base gid comes from the FIRST NON-NULL PART's
|
||||||
|
`adjust_to_outside`,** not from the object's position (`0x005333a2`).
|
||||||
|
`DeriveOutdoorSeed` clamps to the seed block; the rectangle must not
|
||||||
|
inherit that clamp.
|
||||||
|
7. **Global vs within-landblock indices in the same expression.**
|
||||||
|
`gid_to_lcoord` returns global coords; `baseX`/`baseY` are within-block
|
||||||
|
0..7. The deltas bridge them. Mixing the two frames is the single most
|
||||||
|
likely arithmetic error, and BN's own output already drops the
|
||||||
|
`and eax,0xffff` that makes `baseX` within-block (§9.4).
|
||||||
|
8. **Do not touch `AddOutsideCell`.** It is already correct and already
|
||||||
|
landblock-crossing; the new path composes it.
|
||||||
|
9. **The `isStatic` prune is indoor-seeded only.** The outdoor rectangle is
|
||||||
|
deliberately unpruned. Extending the prune to it would re-create #334 in a
|
||||||
|
new form.
|
||||||
|
10. **Don't leave `BuildFloodSpheres`' BSP arm unreachable-but-plausible.**
|
||||||
|
§2.6.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Claims found FALSE or STALE at `f0588725`
|
||||||
|
|
||||||
|
### 9.1 "`find_bbox_cell_list` forms no bounding box at all" — MISLEADING; refuted as a characterisation
|
||||||
|
|
||||||
|
Literally true of that one function (§1.2 — it is a worklist driver). False as a
|
||||||
|
description of the mechanism: the boxes are formed in
|
||||||
|
`CLandCell::add_all_outside_cells` (§1.4, `GetBoundingBox` +
|
||||||
|
`BBox::LocalToGlobal`) and `CEnvCell::find_transit_cells` (§1.7,
|
||||||
|
`BBox::LocalToLocal` + `Plane::intersect_box`), one and two levels below. The
|
||||||
|
name is accurate. Reading only the top frame and stopping is what produced the
|
||||||
|
claim.
|
||||||
|
|
||||||
|
### 9.2 `docs/ISSUES.md` #334 — address chain imprecise
|
||||||
|
|
||||||
|
The issue reads *"`find_bbox_cell_list` @0x00510fc0 → `calc_cross_cells_static`
|
||||||
|
@0x00518160, i.e. a walk over the object's extent."* The **routing is correct**
|
||||||
|
and the **conclusion is correct**, but `0x00518160` is `CPartArray::`'s vtable
|
||||||
|
thunk (§1.3), not an extent walk, and the similarly named
|
||||||
|
`CPhysicsObj::calc_cross_cells_static` @`0x00515160` is a *caller* of
|
||||||
|
`find_bbox_cell_list`, not a callee. Correct chain:
|
||||||
|
`0x00515230` → `0x00510fc0` → `0x00518160` → `[vtbl+0x7c]` →
|
||||||
|
`0x00533840` → `0x00533360` → `0x005331d0`.
|
||||||
|
|
||||||
|
### 9.3 #334's stated cause is right but understates the mechanism — and this is what kills "widen the sphere"
|
||||||
|
|
||||||
|
The issue attributes the loss to the sphere's radius (69.471 m) being smaller
|
||||||
|
than a landblock (192 m). The operative cap is not the radius at all.
|
||||||
|
`CellTransit.AddAllOutsideCells` computes `minRad = radius`,
|
||||||
|
`maxRad = 24 − radius`, and adds at most the **eight** neighbours of the
|
||||||
|
sphere's own cell. **For any radius ≥ 12 m both boundary tests are
|
||||||
|
unconditionally true and the result is exactly the 3×3 — a larger radius cannot
|
||||||
|
add a tenth cell.** Outdoor reach is hard-capped at ±24 m for every object in
|
||||||
|
the game.
|
||||||
|
|
||||||
|
Consequence: widening the radius, adding a supplementary sphere at another
|
||||||
|
point (it would produce its own 3×3, not a joined region), or tuning any
|
||||||
|
constant is *mechanically* incapable of fixing this, not merely disallowed.
|
||||||
|
|
||||||
|
**Independent confirmation against the measured data.** Global lcoords
|
||||||
|
(lbx=0x87, lby=0x64): present `(1081,801)`, `(1082,801)`; absent
|
||||||
|
`(1082,800)`, `(1083,800)`, `(1082,799)`. A 3×3 centred at `(1082,802)` — cell
|
||||||
|
`0x87640013`, x=2, y=2 — contains both present cells and excludes all three
|
||||||
|
absent ones. Every other candidate centre contradicts at least one observation.
|
||||||
|
The player's own logged positions (`currPos ≈ (52.4, 216.0)` while in
|
||||||
|
`0x87640012`) independently constrain the landblock origin to the same
|
||||||
|
solution. **The 3×3 hypothesis explains the measured evidence with zero
|
||||||
|
contradictions.**
|
||||||
|
|
||||||
|
### 9.4 Binary Ninja artifacts in `acclient_2013_pseudo_c.txt` — four, all in the load-bearing function
|
||||||
|
|
||||||
|
Anyone porting §1.4 from the pseudo-C alone gets these wrong:
|
||||||
|
|
||||||
|
1. `add_all_outside_cells` pc:317343 renders `baseX` as
|
||||||
|
`((uint32_t)esi_4 - 1) >> 3`. **BN dropped the `and eax, 0xffff`**
|
||||||
|
(`0x0053343a`). Without it `baseX` includes the landblock bits and every
|
||||||
|
delta is garbage.
|
||||||
|
2. pc:317330 renders the base-gid select as `((esi_2 - esi_2) & var_58)`,
|
||||||
|
which is identically **zero**. The real code is the standard
|
||||||
|
`neg esi / sbb esi,esi / and esi,eax` conditional select
|
||||||
|
(`0x005333eb`) = `retval ? outsideCellId : 0`.
|
||||||
|
3. `add_cell_block` pc:317219 renders
|
||||||
|
`LScape::get_landcell(landscape, edx_2)` with `edx_2 = i & 7`. The real
|
||||||
|
argument is `esi`, the full computed cell id (`0x00533230 push esi`).
|
||||||
|
Same artifact in `add_all_outside_cells` (`..., added_outside)` where
|
||||||
|
`added_outside == 0`).
|
||||||
|
4. Both `x87` flag tests in the min/max accumulation appear as
|
||||||
|
`unimplemented {test ah, ...}` / bit-shuffled `FCMP_UO` expressions. The
|
||||||
|
real comparisons are plain integer `jge`/`jle` on `_ftol2` results
|
||||||
|
(`0x005335a6`, `0x005335b8`, `0x005335c8`, `0x005335d9`) — the fifth
|
||||||
|
confirmed instance this campaign of BN dropping flag semantics.
|
||||||
|
|
||||||
|
### 9.5 AP-156's Risk column is FALSE for the outdoor half
|
||||||
|
|
||||||
|
Recorded as *"extra broadphase candidates, never a missed one."* #334 is a
|
||||||
|
missed one. §3.1.
|
||||||
|
|
||||||
|
### 9.6 `ShadowObjectRegistry.cs:436-442` XML doc becomes false on landing
|
||||||
|
|
||||||
|
*"A BSP part contributes its ROOT BOUNDING SPHERE placed at its real center."*
|
||||||
|
True at `f0588725`; false the moment §2.5 lands. Delete with the BSP arm
|
||||||
|
(§2.6).
|
||||||
|
|
||||||
|
### 9.7 Stale, not false
|
||||||
|
|
||||||
|
`CellTransit.BuildShadowCellSet`'s XML calls itself *"the sphere-overlap portal
|
||||||
|
flood retail runs at SHADOW REGISTRATION time"* — accurate for the branch it
|
||||||
|
ports, but it is presented as **the** registration flood when it is one of two.
|
||||||
|
Narrow the wording when §2.1 lands.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Size estimate and split call
|
||||||
|
|
||||||
|
~600–800 production lines (two `CellTransit` ports, one value type, the
|
||||||
|
`ShadowShape` field and its resolvers, the `RegisterMultiPart` dispatch, the
|
||||||
|
`BuildFloodSpheres` BSP-arm deletion) plus ~400 test lines.
|
||||||
|
|
||||||
|
**Split: THREE commits, sequential, one agent, no parallelism** (shared files —
|
||||||
|
`CellTransit.cs`, `ShadowShape.cs`, `ShadowObjectRegistry.cs` — are touched by
|
||||||
|
every slice).
|
||||||
|
|
||||||
|
| slice | content | gate |
|
||||||
|
|---|---|---|
|
||||||
|
| **S0** | §4.5 measurement only. No repo change. | numbers reported; the §4.5 stop-gate evaluated |
|
||||||
|
| **S1** | `ShadowPartBox`, `ShadowShape` bounds + resolvers, package/serializer read-through. No behaviour change. | full suite green; P2 golden cell sets bit-identical |
|
||||||
|
| **S2** | `AddAllOutsideCellsFromParts` + `BuildShadowCellSetFromParts` + `FindTransitCellsParts` + the §2.5 dispatch + BSP-arm deletion | T1–T10; P1–P5; Release; both hosts; §7 connected gate |
|
||||||
|
|
||||||
|
S0 is not optional. It is the slice that can still say "this is too expensive"
|
||||||
|
before anything is written, which is the only honest way to make that call.
|
||||||
|
|
||||||
|
**Rollback:** each slice reverts independently; S2 alone restores `f0588725`
|
||||||
|
behaviour.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. What I could not establish
|
||||||
|
|
||||||
|
1. **The installed distribution of physics-BSP GfxObj bounding boxes** — §4.5.
|
||||||
|
Not derivable from the repo; every existing figure is a sphere statistic.
|
||||||
|
S0 exists to close this.
|
||||||
|
2. **Whether `0x010046D8`'s box actually reaches `0x87640011` / `0x87640019`**
|
||||||
|
— §6.3. The *absence* is proven and its cause is proven; the *presence
|
||||||
|
after the fix* is a prediction until the box is read. The contract makes
|
||||||
|
reading it a precondition rather than an assumption, because a contract
|
||||||
|
asserting a mechanism that does not exist is how this campaign produced
|
||||||
|
three defects.
|
||||||
|
3. **Whether `0x010046D8` is one object or several instances sharing a gfx
|
||||||
|
id.** The log shows one entity id (`0xC8764000`) across all 1,356 rows, so
|
||||||
|
one instance is the working assumption; a second instance elsewhere in the
|
||||||
|
landblock would not change the diagnosis but would change T10's expected
|
||||||
|
set.
|
||||||
|
4. **Whether `CCellPortal::GetOtherCell` takes `do_not_load_cells` as a third
|
||||||
|
argument.** BN reads the field at `0x0052cc2a` but shows a two-argument
|
||||||
|
call. Only matters for indoor static registration (§2.7); resolve by
|
||||||
|
disassembly during S2 rather than porting BN's shape.
|
||||||
|
|
@ -47,7 +47,7 @@ internal sealed record LiveEntityCollisionRegistration(
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed class LiveEntityCollisionBuilder
|
internal sealed class LiveEntityCollisionBuilder
|
||||||
{
|
{
|
||||||
private readonly Func<uint, FlatCollisionSphere?> _physicsBspBounds;
|
private readonly Func<uint, ShadowPartGeometry?> _physicsBspBounds;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The dispatch gate, derived from <see cref="_physicsBspBounds"/> so the
|
/// The dispatch gate, derived from <see cref="_physicsBspBounds"/> so the
|
||||||
/// two can never disagree (AP-156), and cached once so <see cref="Build"/>
|
/// two can never disagree (AP-156), and cached once so <see cref="Build"/>
|
||||||
|
|
@ -62,11 +62,13 @@ internal sealed class LiveEntityCollisionBuilder
|
||||||
: this(
|
: this(
|
||||||
id =>
|
id =>
|
||||||
{
|
{
|
||||||
FlatPhysicsBsp? flat =
|
FlatGfxObjCollisionAsset? asset = physicsData.GetFlatGfxObj(id);
|
||||||
physicsData.GetFlatGfxObj(id)?.PhysicsBsp;
|
FlatPhysicsBsp? flat = asset?.PhysicsBsp;
|
||||||
return flat is { RootIndex: >= 0 }
|
return flat is { RootIndex: >= 0 }
|
||||||
? flat.Nodes[flat.RootIndex].BoundingSphere
|
? ShadowPartGeometry.Create(
|
||||||
: (FlatCollisionSphere?)null;
|
flat.Nodes[flat.RootIndex].BoundingSphere,
|
||||||
|
asset!.VisualBounds)
|
||||||
|
: (ShadowPartGeometry?)null;
|
||||||
},
|
},
|
||||||
defaultPose)
|
defaultPose)
|
||||||
{
|
{
|
||||||
|
|
@ -74,14 +76,15 @@ internal sealed class LiveEntityCollisionBuilder
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <param name="physicsBspBounds">The part GfxObj's physics-BSP root
|
/// <param name="physicsBspBounds">The part GfxObj's physics-BSP root
|
||||||
/// bounding sphere, or null when it has none. ONE resolver answers both
|
/// bounding sphere AND its authored vertex-array box, or null when it has
|
||||||
/// questions the builder asks — "does this part dispatch as BSP?" and
|
/// no physics BSP. ONE resolver answers every question the builder asks —
|
||||||
/// "where and how big is its flood sphere?" — so the dispatch gate and
|
/// "does this part dispatch as BSP?", "where and how big is its flood
|
||||||
/// the emitted geometry cannot disagree, and the sphere's radius cannot
|
/// sphere?", and "what is its outdoor extent?" — so the dispatch gate and
|
||||||
/// be carried while its origin is dropped. That split is what produced
|
/// the emitted geometry cannot disagree, the sphere's radius cannot be
|
||||||
/// the AP-156 mis-placed flood.</param>
|
/// carried while its origin is dropped (AP-156), and the outdoor extent
|
||||||
|
/// walk cannot be left without a box (#334).</param>
|
||||||
internal LiveEntityCollisionBuilder(
|
internal LiveEntityCollisionBuilder(
|
||||||
Func<uint, FlatCollisionSphere?> physicsBspBounds,
|
Func<uint, ShadowPartGeometry?> physicsBspBounds,
|
||||||
LiveEntityDefaultPoseResolver defaultPose)
|
LiveEntityDefaultPoseResolver defaultPose)
|
||||||
{
|
{
|
||||||
_physicsBspBounds = physicsBspBounds
|
_physicsBspBounds = physicsBspBounds
|
||||||
|
|
|
||||||
|
|
@ -372,6 +372,146 @@ public static class CellTransit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Outdoor extent walk for a physics-BSP part array — the OTHER outdoor
|
||||||
|
/// expansion retail has, and the one #334 was missing entirely. Verbatim
|
||||||
|
/// port of <c>CLandCell::add_all_outside_cells</c> @0x00533360 (pc:317289)
|
||||||
|
/// plus <c>CLandCell::add_cell_block</c> @0x005331d0 (pc:317202),
|
||||||
|
/// disassembled from the PDB-paired 2013-09-06 binary rather than read
|
||||||
|
/// from Binary Ninja's pseudo-C, which mis-renders four separate
|
||||||
|
/// constructs inside this one function
|
||||||
|
/// (<c>docs/research/2026-08-06-334-contract.md</c> §9.4).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Shape, byte-verified:
|
||||||
|
/// </para>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>The base landcell comes from the FIRST part's own
|
||||||
|
/// <c>adjust_to_outside</c> (<c>0x005333a2</c>-<c>0x005333dd</c>),
|
||||||
|
/// NOT from the object's position; a failed adjust selects gid 0
|
||||||
|
/// through the <c>neg/sbb/and</c> conditional select at
|
||||||
|
/// <c>0x005333eb</c>, whose <c>get_landcell</c> then returns null
|
||||||
|
/// and the walk returns (<c>0x00533417</c>).</item>
|
||||||
|
/// <item><c>baseX = ((gid & 0xFFFF) - 1) >> 3</c>
|
||||||
|
/// (<c>0x0053343a and eax,0xffff</c> — the mask BN drops) and
|
||||||
|
/// <c>baseY = (gid - 1) & 7</c> (<c>0x00533443</c>): WITHIN-BLOCK
|
||||||
|
/// 0..7, bridged to the GLOBAL lcoords from
|
||||||
|
/// <c>gid_to_lcoord</c> (<c>0x00533428</c>) by the four deltas.</item>
|
||||||
|
/// <item>Per part: <c>GetBoundingBox</c> @0x0050d600 →
|
||||||
|
/// <c>BBox::LocalToGlobal</c> @0x005b2120 (<c>0x00533527</c>), then
|
||||||
|
/// <c>floor(v / square_length)</c> on min.x, min.y, max.x, max.y
|
||||||
|
/// (stack slots <c>+0x48/+0x4c/+0x54/+0x58</c> in the entry frame —
|
||||||
|
/// the raw displacements differ only because <c>sub esp,8</c> at
|
||||||
|
/// <c>0x00533536</c> brackets the middle three). Z is never read:
|
||||||
|
/// land cells are a 2-D grid. <c>square_length</c> is
|
||||||
|
/// <c>0x7c920c</c> = <c>00 00 c0 41</c> = 24.0f, read from the
|
||||||
|
/// binary.</item>
|
||||||
|
/// <item>The four accumulators are seeded to ZERO
|
||||||
|
/// (<c>0x00533390</c>-<c>0x0053339c</c>), so the rectangle always
|
||||||
|
/// contains the base cell, and are combined with plain integer
|
||||||
|
/// <c>jge</c>/<c>jle</c> (<c>0x005335a6</c>, <c>0x005335b8</c>,
|
||||||
|
/// <c>0x005335c8</c>, <c>0x005335d9</c>) — BN renders these as
|
||||||
|
/// <c>unimplemented {test ah}</c> / <c>FCMP_UO</c>.</item>
|
||||||
|
/// <item>ONE rectangle over ALL parts, filled — not outlined, not a
|
||||||
|
/// per-part union (<c>0x00533614</c>
|
||||||
|
/// <c>add_cell_block(gx+minDX, gy+minDY, gx+maxDX, gy+maxDY,
|
||||||
|
/// cellarray)</c>, argument order recovered from the five pushes
|
||||||
|
/// at <c>0x005335f2</c>-<c>0x00533613</c>). An L-shaped object
|
||||||
|
/// claims the notch; retail's coverage is deliberately
|
||||||
|
/// conservative and lets the narrow phase reject.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Retail also has an <c>Always2D()</c> arm (<c>0x0053346b</c>) that falls
|
||||||
|
/// back to the part's sphere. It is unreachable here: this overload is
|
||||||
|
/// only ever handed physics-BSP parts, and a 2-D sprite part carries no
|
||||||
|
/// physics BSP.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="worldParts">The object's physics-BSP parts, world-placed.</param>
|
||||||
|
/// <param name="currentCellId">The flood seed cell — supplies the landblock
|
||||||
|
/// base <c>adjust_to_outside</c> measures part 0's position against.</param>
|
||||||
|
/// <param name="currentBlockOrigin">World origin of the seed cell's
|
||||||
|
/// landblock (#106 frame convention); <see cref="Vector3.Zero"/> when the
|
||||||
|
/// seed block IS the anchor.</param>
|
||||||
|
/// <returns>False when <c>adjust_to_outside</c> or <c>gid_to_lcoord</c>
|
||||||
|
/// rejects the base position (map edge / invalid id) — retail returns
|
||||||
|
/// without adding anything.</returns>
|
||||||
|
public static bool AddAllOutsideCellsFromParts(
|
||||||
|
IReadOnlyList<ShadowPartBox> worldParts,
|
||||||
|
uint currentCellId,
|
||||||
|
Vector3 currentBlockOrigin,
|
||||||
|
ICollection<uint> candidates)
|
||||||
|
{
|
||||||
|
if (worldParts is null || worldParts.Count == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// 0x005333a2-0x005333dd: the base gid is the FIRST part's landcell.
|
||||||
|
// DeriveOutdoorSeed clamps its own result to the seed block; this
|
||||||
|
// deliberately does not inherit that clamp — a part array whose first
|
||||||
|
// part sits over the neighbour block anchors there, as retail does.
|
||||||
|
Vector3 seedFramePos = worldParts[0].WorldPosition - currentBlockOrigin;
|
||||||
|
Vector3 baseFramePos = seedFramePos;
|
||||||
|
uint baseCellId = currentCellId;
|
||||||
|
if (!LandDefs.AdjustToOutside(ref baseCellId, ref baseFramePos))
|
||||||
|
return false; // gid 0 → get_landcell null → return
|
||||||
|
if (!LandDefs.GidToLcoord(baseCellId, out int gx, out int gy))
|
||||||
|
return false; // 0x00533432 je 0x53361c
|
||||||
|
|
||||||
|
int baseX = (int)(((baseCellId & 0xFFFFu) - 1u) >> 3); // 0x0053343a
|
||||||
|
int baseY = (int)((baseCellId - 1u) & 7u); // 0x00533443
|
||||||
|
|
||||||
|
// adjust_to_outside re-based part 0's position into the ADJUSTED
|
||||||
|
// block's local frame; retail's BBox::LocalToGlobal writes every part
|
||||||
|
// box into that same frame (cell0->pos). The re-basing is a pure
|
||||||
|
// translation, so applying it to the world origin is exact.
|
||||||
|
Vector3 frameOrigin =
|
||||||
|
currentBlockOrigin - (baseFramePos - seedFramePos);
|
||||||
|
|
||||||
|
int minDX = 0, minDY = 0, maxDX = 0, maxDY = 0; // 0x00533390
|
||||||
|
|
||||||
|
for (int i = 0; i < worldParts.Count; i++)
|
||||||
|
{
|
||||||
|
worldParts[i].RefitTo(frameOrigin, out Vector3 boxMin, out Vector3 boxMax);
|
||||||
|
|
||||||
|
// floor, then _ftol2 — NOT truncation. C#'s (int)(v / 24f)
|
||||||
|
// truncates toward zero and is wrong for every negative
|
||||||
|
// block-local coordinate, which is precisely the case a part
|
||||||
|
// hanging off the block's SW corner produces.
|
||||||
|
int a = (int)MathF.Floor(boxMin.X / LandDefs.CellLength);
|
||||||
|
int b = (int)MathF.Floor(boxMin.Y / LandDefs.CellLength);
|
||||||
|
int c = (int)MathF.Floor(boxMax.X / LandDefs.CellLength);
|
||||||
|
int d = (int)MathF.Floor(boxMax.Y / LandDefs.CellLength);
|
||||||
|
|
||||||
|
if (a - baseX < minDX) minDX = a - baseX; // 0x005335a2
|
||||||
|
if (b - baseY < minDY) minDY = b - baseY; // 0x005335b4
|
||||||
|
if (c - baseX > maxDX) maxDX = c - baseX; // 0x005335c2
|
||||||
|
if (d - baseY > maxDY) maxDY = d - baseY; // 0x005335d5
|
||||||
|
}
|
||||||
|
|
||||||
|
AddCellBlock(gx + minDX, gy + minDY, gx + maxDX, gy + maxDY, candidates);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>CLandCell::add_cell_block</c> @0x005331d0 (pc:317202): both loops are
|
||||||
|
/// INCLUSIVE (<c>0x0053324d</c> / <c>0x00533246 jle</c>) and the rectangle
|
||||||
|
/// is FILLED. Coordinates are GLOBAL lcoords, so the landblock prefix is
|
||||||
|
/// re-derived per cell and the rectangle crosses landblock boundaries
|
||||||
|
/// freely — that re-derivation is <see cref="AddOutsideCell"/>'s
|
||||||
|
/// <see cref="LandDefs.LcoordToGid"/>, whose
|
||||||
|
/// <see cref="LandDefs.InBounds"/> rejection IS retail's
|
||||||
|
/// <c>0 <= v < 0x7f8</c> clamp at <c>0x005331f0</c>-<c>0x00533206</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static void AddCellBlock(
|
||||||
|
int x0, int y0, int x1, int y1,
|
||||||
|
ICollection<uint> candidates)
|
||||||
|
{
|
||||||
|
for (int x = x0; x <= x1; x++)
|
||||||
|
for (int y = y0; y <= y1; y++)
|
||||||
|
AddOutsideCell(candidates, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
private static void AddOutsideCell(ICollection<uint> candidates, int lx, int ly)
|
private static void AddOutsideCell(ICollection<uint> candidates, int lx, int ly)
|
||||||
{
|
{
|
||||||
// CLandCell::add_outside_cell (pc:317056 @0x00532ec0): map-bounds check,
|
// CLandCell::add_outside_cell (pc:317056 @0x00532ec0): map-bounds check,
|
||||||
|
|
@ -500,8 +640,13 @@ public static class CellTransit
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// BR-7 / A6.P4 (2026-06-11). Registration-side cell-set builder — the
|
/// BR-7 / A6.P4 (2026-06-11). Registration-side cell-set builder for an
|
||||||
/// sphere-overlap portal flood retail runs at SHADOW REGISTRATION time.
|
/// object with NO physics BSP — the sphere-overlap portal flood retail
|
||||||
|
/// runs at SHADOW REGISTRATION time for the cylsphere and sorting-sphere
|
||||||
|
/// branches. It is ONE of TWO registration floods: a BSP-bearing object
|
||||||
|
/// takes <see cref="BuildShadowCellSetFromParts"/> instead
|
||||||
|
/// (<c>CPhysicsObj::calc_cross_cells</c> @0x00515230 dispatches on
|
||||||
|
/// <c>HAS_PHYSICS_BSP_PS</c> at <c>0x00515285</c>).
|
||||||
/// Verbatim port of <c>CObjCell::find_cell_list</c> (Ghidra 0x0052b4e0,
|
/// Verbatim port of <c>CObjCell::find_cell_list</c> (Ghidra 0x0052b4e0,
|
||||||
/// pc:308742) as invoked by <c>CPhysicsObj::calc_cross_cells</c> /
|
/// pc:308742) as invoked by <c>CPhysicsObj::calc_cross_cells</c> /
|
||||||
/// <c>calc_cross_cells_static</c> (Ghidra 0x00515230 / 0x00515160):
|
/// <c>calc_cross_cells_static</c> (Ghidra 0x00515230 / 0x00515160):
|
||||||
|
|
@ -657,6 +802,184 @@ public static class CellTransit
|
||||||
return candidates.OrderedIds;
|
return candidates.OrderedIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #334 (2026-08-06). Registration-side cell-set builder for a
|
||||||
|
/// PHYSICS-BSP-BEARING object — retail's OTHER cross-cell algorithm, which
|
||||||
|
/// acdream had never implemented. Port of
|
||||||
|
/// <c>CPhysicsObj::find_bbox_cell_list</c> @0x00510fc0 (pc:279006), the
|
||||||
|
/// branch <c>CPhysicsObj::calc_cross_cells</c> @0x00515230 takes at
|
||||||
|
/// <c>0x00515285 test dword [esi+0xa8],0x10000</c> /
|
||||||
|
/// <c>0x0051528f jne 0x515305</c> — <c>HAS_PHYSICS_BSP_PS</c>
|
||||||
|
/// (<c>acclient.h:2833</c>). <see cref="BuildShadowCellSet"/> ports the
|
||||||
|
/// branches BELOW that jump (cylspheres, then the sorting sphere) and
|
||||||
|
/// remains correct for them.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <c>find_bbox_cell_list</c> forms no bounding box itself — it is a
|
||||||
|
/// worklist. It seeds the array with the object's OWN cell
|
||||||
|
/// (<c>0x00510fe2 CELLARRAY::add_cell</c>) and walks it while it grows,
|
||||||
|
/// re-reading <c>num_cells</c> each iteration
|
||||||
|
/// (<c>0x00510ff8</c> / <c>0x00511017</c> / <c>0x0051101d jb</c>),
|
||||||
|
/// dispatching each array cell through
|
||||||
|
/// <c>CPartArray::calc_cross_cells_static</c> @0x00518160 — a forwarding
|
||||||
|
/// thunk to <c>cell->vtable[0x7c]</c> (<c>0x00518176</c>;
|
||||||
|
/// <c>CObjCell</c>'s vftable base <c>0x007c8b20</c> + <c>0x7c</c> =
|
||||||
|
/// <c>0x007c8b9c</c>, holding <c>0x0052b080</c>, the four-argument
|
||||||
|
/// part-array <c>find_transit_cells</c>). The boxes are formed one and two
|
||||||
|
/// levels down: outdoors in
|
||||||
|
/// <see cref="AddAllOutsideCellsFromParts"/>
|
||||||
|
/// (<c>CLandCell::find_transit_cells</c> @0x00533840 =
|
||||||
|
/// <c>add_all_outside_cells</c> @0x00533360 + the <c>CSortCell</c>
|
||||||
|
/// @0x00534080 building bridge), indoors in
|
||||||
|
/// <c>CEnvCell::find_transit_cells</c> @0x0052cae0.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Note the difference from <see cref="BuildShadowCellSet"/>'s seed: the
|
||||||
|
/// sphere overload calls <c>add_all_outside_cells</c> AT SEED TIME for an
|
||||||
|
/// outdoor id (<c>CObjCell::find_cell_list</c> <c>0x0052b53f</c>);
|
||||||
|
/// <c>find_bbox_cell_list</c> does not — the outdoor expansion happens
|
||||||
|
/// only when the walk reaches a landcell, under the same once-per-flood
|
||||||
|
/// <c>CELLARRAY::added_outside</c> latch (<c>0x0053336c</c>). And it is
|
||||||
|
/// ONE rectangle over all parts, run ONCE, not a per-part loop.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// DIVERGENCE (registered, AP-159): the INDOOR half of retail's part-array
|
||||||
|
/// overload — box-vs-portal-plane
|
||||||
|
/// (<c>BBox::LocalToLocal</c> @0x005b1e60 + <c>Plane::intersect_box</c>
|
||||||
|
/// @0x005aa170 at <c>0x0052cbf9</c>/<c>0x0052cc05</c>) and
|
||||||
|
/// <c>CCellStruct::box_intersects_cell</c> @0x00533910 — is NOT ported
|
||||||
|
/// here. Indoor candidates keep the sphere-vs-portal traversal
|
||||||
|
/// <see cref="FindTransitCellsSphere"/> already runs, from the same
|
||||||
|
/// per-part BSP root spheres, which is byte-for-byte the behaviour every
|
||||||
|
/// BSP object had before #334. AP-156's row already names that port as its
|
||||||
|
/// open residual; #334 is the OUTDOOR half of it.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="worldParts">Per-part world-placed authored boxes — the
|
||||||
|
/// outdoor extent walk's input.</param>
|
||||||
|
/// <param name="worldPartSpheres">Per-part world-placed BSP root spheres —
|
||||||
|
/// the indoor residual's and the building bridge's input. Same parts, same
|
||||||
|
/// order, from the same <see cref="ShadowPartGeometry"/> values.</param>
|
||||||
|
public static IReadOnlyList<uint> BuildShadowCellSetFromParts(
|
||||||
|
PhysicsDataCache cache,
|
||||||
|
uint seedCellId,
|
||||||
|
IReadOnlyList<ShadowPartBox> worldParts,
|
||||||
|
IReadOnlyList<Sphere> worldPartSpheres,
|
||||||
|
bool isStatic)
|
||||||
|
{
|
||||||
|
var candidates = new CellArray();
|
||||||
|
if (seedCellId == 0u || worldParts is null || worldParts.Count == 0)
|
||||||
|
return candidates.OrderedIds;
|
||||||
|
|
||||||
|
int sphereCount =
|
||||||
|
EffectiveSphereCount(worldPartSpheres, worldPartSpheres?.Count ?? 0);
|
||||||
|
|
||||||
|
uint seedLow = seedCellId & 0xFFFFu;
|
||||||
|
cache.CellGraph.TryGetTerrainOrigin(seedCellId, out var blockOrigin);
|
||||||
|
|
||||||
|
// SEED. 0x00510fd5-0x00510fe2 adds the object's own cell by id;
|
||||||
|
// 0x00510fed / 0x00510ff6 then skip the walk when the cell or the part
|
||||||
|
// array is null.
|
||||||
|
//
|
||||||
|
// DEVIATION (registered, AD-40): the outdoor rectangle runs at seed
|
||||||
|
// time here, not only from the walk. Retail can gate everything on
|
||||||
|
// obj->cell because a placed CPhysicsObj always has a resident
|
||||||
|
// CObjCell; acdream's CellGraph residency is transiently false during
|
||||||
|
// streaming (#168 / #169), and deferring the rectangle to the walk
|
||||||
|
// would drop a static or a live entity to a single cell for the window
|
||||||
|
// before its landblock publishes. This is the SAME residency policy
|
||||||
|
// BuildShadowCellSet already applies to its outdoor seed
|
||||||
|
// (CObjCell::find_cell_list 0x0052b53f, ahead of the arg4 walk gate),
|
||||||
|
// so the two registration floods differ only in sphere-vs-box — which
|
||||||
|
// is the whole of #334 — and the direction is over-inclusive.
|
||||||
|
bool outdoorAdded = false; // CELLARRAY::added_outside
|
||||||
|
bool seedLoaded;
|
||||||
|
if (seedLow >= 0x0100u)
|
||||||
|
{
|
||||||
|
candidates.Add(seedCellId);
|
||||||
|
seedLoaded = cache.GetCellStruct(seedCellId) is not null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
candidates.Add(seedCellId);
|
||||||
|
outdoorAdded = AddAllOutsideCellsFromParts(
|
||||||
|
worldParts, seedCellId, blockOrigin, candidates);
|
||||||
|
seedLoaded = cache.CellGraph.GetVisible(seedCellId) is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seedLoaded)
|
||||||
|
return candidates.OrderedIds;
|
||||||
|
|
||||||
|
for (int i = 0; i < candidates.Count; i++)
|
||||||
|
{
|
||||||
|
uint cellId = candidates.OrderedIds[i];
|
||||||
|
if ((cellId & 0xFFFFu) >= 0x0100u)
|
||||||
|
{
|
||||||
|
var cell = cache.GetCellStruct(cellId);
|
||||||
|
if (cell is null) continue; // 0x00511009 null cell pointer
|
||||||
|
|
||||||
|
if (sphereCount == 0) continue;
|
||||||
|
FindTransitCellsSphere(
|
||||||
|
cache, cell, cellId, worldPartSpheres!, sphereCount,
|
||||||
|
candidates, out bool exitStraddle);
|
||||||
|
|
||||||
|
if (exitStraddle && !outdoorAdded)
|
||||||
|
{
|
||||||
|
outdoorAdded = AddAllOutsideCellsFromParts(
|
||||||
|
worldParts, seedCellId, blockOrigin, candidates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (cache.CellGraph.GetVisible(cellId) is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// CLandCell::find_transit_cells @0x00533840:
|
||||||
|
// add_all_outside_cells (added_outside-guarded) then the
|
||||||
|
// CSortCell building bridge for this landcell's building.
|
||||||
|
if (!outdoorAdded)
|
||||||
|
{
|
||||||
|
outdoorAdded = AddAllOutsideCellsFromParts(
|
||||||
|
worldParts, seedCellId, blockOrigin, candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
var building = cache.GetBuilding(cellId);
|
||||||
|
if (building is not null && sphereCount > 0)
|
||||||
|
{
|
||||||
|
CheckBuildingTransit(
|
||||||
|
cache, building, worldPartSpheres!, sphereCount,
|
||||||
|
candidates, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static prune (do_not_load_cells, 0x0052b66e) — indoor-seeded ONLY.
|
||||||
|
// The outdoor rectangle is deliberately unpruned: pruning it would
|
||||||
|
// re-create #334 in a new form.
|
||||||
|
if (isStatic && seedLow >= 0x0100u)
|
||||||
|
{
|
||||||
|
var seedCell = cache.GetCellStruct(seedCellId);
|
||||||
|
if (seedCell is not null)
|
||||||
|
{
|
||||||
|
var keep = new List<uint>(candidates.Count);
|
||||||
|
foreach (uint id in candidates.OrderedIds)
|
||||||
|
{
|
||||||
|
if (id == seedCellId || seedCell.VisibleCellIds.Contains(id))
|
||||||
|
keep.Add(id);
|
||||||
|
}
|
||||||
|
if (keep.Count != candidates.Count)
|
||||||
|
{
|
||||||
|
candidates.Clear();
|
||||||
|
foreach (uint id in keep) candidates.Add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates.OrderedIds;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verbatim port of <c>CEnvCell::find_visible_child_cell</c>
|
/// Verbatim port of <c>CEnvCell::find_visible_child_cell</c>
|
||||||
/// (<c>acclient_2013_pseudo_c.txt:311397</c>). Returns the cell whose cell-BSP
|
/// (<c>acclient_2013_pseudo_c.txt:311397</c>). Returns the cell whose cell-BSP
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,10 @@ public sealed class PhysicsDataCache
|
||||||
{
|
{
|
||||||
_visualBounds[gfxObjId] = ComputeVisualBounds(gfxObj.VertexArray);
|
_visualBounds[gfxObjId] = ComputeVisualBounds(gfxObj.VertexArray);
|
||||||
}
|
}
|
||||||
|
GfxObjVisualBounds? parsedBounds =
|
||||||
|
_visualBounds.TryGetValue(gfxObjId, out var cachedBounds)
|
||||||
|
? cachedBounds
|
||||||
|
: null;
|
||||||
|
|
||||||
if (_gfxObj.TryGetValue(gfxObjId, out GfxObjPhysics? existing))
|
if (_gfxObj.TryGetValue(gfxObjId, out GfxObjPhysics? existing))
|
||||||
{
|
{
|
||||||
|
|
@ -217,6 +221,10 @@ public sealed class PhysicsDataCache
|
||||||
Vertices = gfxObj.VertexArray,
|
Vertices = gfxObj.VertexArray,
|
||||||
Resolved = ResolvePolygons(gfxObj.PhysicsPolygons, gfxObj.VertexArray),
|
Resolved = ResolvePolygons(gfxObj.PhysicsPolygons, gfxObj.VertexArray),
|
||||||
FlatPhysicsBsp = prepared?.PhysicsBsp,
|
FlatPhysicsBsp = prepared?.PhysicsBsp,
|
||||||
|
VisualBounds = prepared?.VisualBounds ?? (parsedBounds is { } pb
|
||||||
|
? new FlatGfxObjVisualBounds(
|
||||||
|
pb.Min, pb.Max, pb.Center, pb.Radius, pb.HalfExtents)
|
||||||
|
: null),
|
||||||
};
|
};
|
||||||
_gfxObj[gfxObjId] = physics;
|
_gfxObj[gfxObjId] = physics;
|
||||||
|
|
||||||
|
|
@ -285,6 +293,7 @@ public sealed class PhysicsDataCache
|
||||||
Radius = root.Radius,
|
Radius = root.Radius,
|
||||||
},
|
},
|
||||||
FlatPhysicsBsp = prepared.PhysicsBsp,
|
FlatPhysicsBsp = prepared.PhysicsBsp,
|
||||||
|
VisualBounds = prepared.VisualBounds,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1426,6 +1435,16 @@ public sealed class GfxObjPhysics
|
||||||
/// omit it.
|
/// omit it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public FlatPhysicsBsp? FlatPhysicsBsp { get; internal set; }
|
public FlatPhysicsBsp? FlatPhysicsBsp { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>CGfxObj::gfx_bound_box</c> — the AABB of this GfxObj's vertex
|
||||||
|
/// array, filled by <c>CGfxObj::init_end</c> @0x00534200 and returned by
|
||||||
|
/// <c>CPhysicsPart::GetBoundingBox</c> @0x0050d600. Cached beside
|
||||||
|
/// <see cref="BoundingSphere"/> so the single
|
||||||
|
/// <c>ShadowShapeBuilder.FromLandblockBspParts</c> resolver answers both of
|
||||||
|
/// retail's cell-membership questions from one lookup (#334).
|
||||||
|
/// </summary>
|
||||||
|
public FlatGfxObjVisualBounds? VisualBounds { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Cached collision shape data for a Setup (character/creature capsule).</summary>
|
/// <summary>Cached collision shape data for a Setup (character/creature capsule).</summary>
|
||||||
|
|
|
||||||
|
|
@ -430,12 +430,29 @@ public sealed class ShadowObjectRegistry
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// BR-7: the cell set is ONE flood for the whole entity (retail floods
|
/// BR-7: the cell set is ONE flood for the whole entity (retail floods
|
||||||
/// per OBJECT with its full sphere set, not per part). The flood spheres
|
/// per OBJECT, not per part). WHICH flood is retail's own exclusive
|
||||||
/// follow <c>CPhysicsObj::calc_cross_cells</c>' own EXCLUSIVE priority —
|
/// dispatch on <c>HAS_PHYSICS_BSP_PS</c>
|
||||||
/// physics-BSP parts, else CylSpheres, else the remaining shapes — see
|
/// (<c>CPhysicsObj::calc_cross_cells</c> @0x00515230,
|
||||||
/// <see cref="BuildFloodSpheres"/> for the disassembly. A BSP part
|
/// <c>0x00515285 test dword [esi+0xa8],0x10000</c> /
|
||||||
/// contributes its ROOT BOUNDING SPHERE placed at its real center
|
/// <c>0x0051528f jne 0x515305</c>):
|
||||||
/// (<see cref="ShadowShape.BoundsCenter"/>), not at the part origin.
|
/// </para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>BSP-bearing → <c>find_bbox_cell_list</c> @0x00510fc0, ported as
|
||||||
|
/// <see cref="CellTransit.BuildShadowCellSetFromParts"/>. Each part
|
||||||
|
/// contributes its authored BOUNDING BOX
|
||||||
|
/// (<see cref="ShadowShape.LocalBoundsMin"/>/<c>Max</c>), and the
|
||||||
|
/// outdoor expansion is the FILLED CELL RECTANGLE that box spans —
|
||||||
|
/// crossing landblock boundaries freely. Before #334 these objects
|
||||||
|
/// were routed through the sphere flood below, whose outdoor reach
|
||||||
|
/// is a fixed 3×3 (±24 m) regardless of radius, so any formation
|
||||||
|
/// wider than one land cell simply was not registered in its outer
|
||||||
|
/// cells.</item>
|
||||||
|
/// <item>otherwise → <see cref="BuildFloodSpheres"/> +
|
||||||
|
/// <see cref="CellTransit.BuildShadowCellSet"/>, retail's
|
||||||
|
/// cylsphere and sorting-sphere branches, byte-identical to before
|
||||||
|
/// #334 for every object that legitimately is spherical.</item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>
|
||||||
/// Every shape row is then written into every flooded cell, mirroring
|
/// Every shape row is then written into every flooded cell, mirroring
|
||||||
/// add_shadows_to_cells (0x00514ae0) + CPartArray::AddPartsShadow.
|
/// add_shadows_to_cells (0x00514ae0) + CPartArray::AddPartsShadow.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
|
@ -460,9 +477,35 @@ public sealed class ShadowObjectRegistry
|
||||||
: DeriveOutdoorSeed(entityWorldPos, worldOffsetX, worldOffsetY, landblockId);
|
: DeriveOutdoorSeed(entityWorldPos, worldOffsetX, worldOffsetY, landblockId);
|
||||||
if (seed == 0u) return;
|
if (seed == 0u) return;
|
||||||
|
|
||||||
var floodSpheres = BuildFloodSpheres(entityWorldPos, entityWorldRot, shapes);
|
// Retail's exclusive dispatch, mirrored: CPartArray::CacheHasPhysicsBSP
|
||||||
var cellSet = CellTransit.BuildShadowCellSet(
|
// (0x00518110) ORs 0x10000 on the first part whose gfxobj carries a
|
||||||
FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic);
|
// physics BSP, and calc_cross_cells (0x00515285) branches on that bit.
|
||||||
|
// AP-152 made shape emission BSP-exclusive, so "has a BSP shape" and
|
||||||
|
// "is a BSP object" coincide exactly as the cached retail flag does.
|
||||||
|
bool hasBsp = false;
|
||||||
|
for (int i = 0; i < shapes.Count; i++)
|
||||||
|
{
|
||||||
|
if (shapes[i].CollisionType == ShadowCollisionType.BSP)
|
||||||
|
{
|
||||||
|
hasBsp = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<uint> cellSet;
|
||||||
|
if (hasBsp)
|
||||||
|
{
|
||||||
|
var partBoxes = BuildFloodPartBoxes(entityWorldPos, entityWorldRot, shapes);
|
||||||
|
var partSpheres = BuildBspPartSpheres(entityWorldPos, entityWorldRot, shapes);
|
||||||
|
cellSet = CellTransit.BuildShadowCellSetFromParts(
|
||||||
|
FloodCache, seed, partBoxes, partSpheres, isStatic);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var floodSpheres = BuildFloodSpheres(entityWorldPos, entityWorldRot, shapes);
|
||||||
|
cellSet = CellTransit.BuildShadowCellSet(
|
||||||
|
FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic);
|
||||||
|
}
|
||||||
if (cellSet.Count == 0) return;
|
if (cellSet.Count == 0) return;
|
||||||
|
|
||||||
DeregisterCore(entityId, publishMutation: false);
|
DeregisterCore(entityId, publishMutation: false);
|
||||||
|
|
@ -598,25 +641,13 @@ public sealed class ShadowObjectRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retail cross-cell dispatch, <c>CPhysicsObj::calc_cross_cells</c>
|
/// Flood spheres for an object with NO physics BSP — retail's cylsphere
|
||||||
/// @0x00515230, in retail's own priority order:
|
/// and sorting-sphere branches of <c>CPhysicsObj::calc_cross_cells</c>
|
||||||
|
/// @0x00515230, both of which sit BELOW the <c>HAS_PHYSICS_BSP_PS</c> jump
|
||||||
|
/// at <c>0x0051528f jne 0x515305</c> and are unreachable from it:
|
||||||
///
|
///
|
||||||
/// <list type="number">
|
/// <list type="number">
|
||||||
/// <item>BSP-bearing (<c>0x00515285 test dword [esi+0xa8],0x10000</c> /
|
/// <item>cylspheres (<c>0x00515298 GetNumCylsphere</c> non-zero) →
|
||||||
/// <c>0x0051528f jne 0x515305</c>) → <c>CPhysicsObj::find_bbox_cell_list</c>
|
|
||||||
/// @0x00510fc0. The cylsphere and sorting-sphere branches are BOTH below
|
|
||||||
/// that jump and unreachable from it. <c>find_bbox_cell_list</c> adds the
|
|
||||||
/// object's own cell and then walks the PART ARRAY through
|
|
||||||
/// <c>CPartArray::calc_cross_cells_static</c> @0x00518160's
|
|
||||||
/// <c>[vtbl+0x7c]</c> dispatch, whose EnvCell body
|
|
||||||
/// (<c>CEnvCell::find_transit_cells</c> @0x0052cae0) tests each part's
|
|
||||||
/// <c>CGfxObj::physics_sphere</c> — the BSP root bounding sphere, center
|
|
||||||
/// transformed through the part's own Position — against the cell's
|
|
||||||
/// portal planes. acdream floods from those same per-part spheres
|
|
||||||
/// (<see cref="ShadowShape.BoundsCenter"/> + <see cref="ShadowShape.Radius"/>)
|
|
||||||
/// rather than walking portal planes per part; the sphere set is exact,
|
|
||||||
/// the traversal is the sphere-vs-portal one (AP-156).</item>
|
|
||||||
/// <item>else cylspheres (<c>0x00515298 GetNumCylsphere</c> non-zero) →
|
|
||||||
/// <c>CObjCell::find_cell_list</c> @0x0052b9f0 over the cylsphere array;
|
/// <c>CObjCell::find_cell_list</c> @0x0052b9f0 over the cylsphere array;
|
||||||
/// each contributes one sphere at its world BASE point with the cylinder
|
/// each contributes one sphere at its world BASE point with the cylinder
|
||||||
/// radius, capped at 10.</item>
|
/// radius, capped at 10.</item>
|
||||||
|
|
@ -626,15 +657,17 @@ public sealed class ShadowObjectRegistry
|
||||||
/// </list>
|
/// </list>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The BSP-first rule is redundant for every shape list acdream produces
|
/// #334: there is no BSP arm here any more, and there must not be one.
|
||||||
/// today — <see cref="ShadowShapeBuilder.FromSetup"/> dispatches at
|
/// The BSP branch is a structurally different algorithm over BOXES
|
||||||
/// emission (AP-152) and both landblock-static publishers emit
|
/// (<see cref="CellTransit.BuildShadowCellSetFromParts"/>), and
|
||||||
/// homogeneous lists — exactly as
|
/// <see cref="RegisterMultiPart"/> routes to it before this method is
|
||||||
/// <c>Transition.BspOnlyDispatch</c> is redundant at the query site. It is
|
/// reached. The arm this method used to carry — "a BSP part contributes
|
||||||
/// kept because retail genuinely dispatches here, and because a producer
|
/// its ROOT BOUNDING SPHERE placed at its real center" — described
|
||||||
/// that handed this method a mixed list would otherwise flood a
|
/// retail's INDOOR portal reject, not its outdoor expansion, and using it
|
||||||
/// BSP-bearing object from its primitive and silently place it in the
|
/// for both is what capped every BSP object's outdoor reach at a 3×3
|
||||||
/// wrong shadow cells (the #98 / #168 symptom class).
|
/// neighbourhood. A BSP shape reaching this method would be a dispatch
|
||||||
|
/// bug; it is skipped rather than flooded from, so it cannot silently
|
||||||
|
/// produce the wrong cells (the #98 / #168 symptom class).
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<DatReaderWriter.Types.Sphere> BuildFloodSpheres(
|
private static List<DatReaderWriter.Types.Sphere> BuildFloodSpheres(
|
||||||
|
|
@ -645,21 +678,16 @@ public sealed class ShadowObjectRegistry
|
||||||
const int RetailSphereCap = 10;
|
const int RetailSphereCap = 10;
|
||||||
|
|
||||||
var spheres = new List<DatReaderWriter.Types.Sphere>();
|
var spheres = new List<DatReaderWriter.Types.Sphere>();
|
||||||
bool anyBsp = false;
|
|
||||||
bool anyCyl = false;
|
bool anyCyl = false;
|
||||||
foreach (var s in shapes)
|
foreach (var s in shapes)
|
||||||
{
|
{
|
||||||
if (s.CollisionType == ShadowCollisionType.BSP) anyBsp = true;
|
if (s.CollisionType == ShadowCollisionType.Cylinder) anyCyl = true;
|
||||||
else if (s.CollisionType == ShadowCollisionType.Cylinder) anyCyl = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retail's branch, chosen once: BSP-bbox, else cylspheres, else the
|
// Retail's branch, chosen once: cylspheres, else the sorting sphere
|
||||||
// sorting sphere (which acdream approximates with the remaining
|
// (which acdream approximates with the Sphere shapes — AP-157).
|
||||||
// shapes' bounding spheres — AP-157).
|
ShadowCollisionType only =
|
||||||
ShadowCollisionType? only =
|
anyCyl ? ShadowCollisionType.Cylinder : ShadowCollisionType.Sphere;
|
||||||
anyBsp ? ShadowCollisionType.BSP
|
|
||||||
: anyCyl ? ShadowCollisionType.Cylinder
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// The 10-sphere clamp belongs to the CYLSPHERE branch alone.
|
// The 10-sphere clamp belongs to the CYLSPHERE branch alone.
|
||||||
// CObjCell::find_cell_list @0x0052b9f0 clamps the cylsphere count at
|
// CObjCell::find_cell_list @0x0052b9f0 clamps the cylsphere count at
|
||||||
|
|
@ -667,41 +695,29 @@ public sealed class ShadowObjectRegistry
|
||||||
// fixed static-buffer capacity (the destination array at
|
// fixed static-buffer capacity (the destination array at
|
||||||
// 0x844838..0x8448d8 is exactly ten 16-byte entries), not a policy.
|
// 0x844838..0x8448d8 is exactly ten 16-byte entries), not a policy.
|
||||||
//
|
//
|
||||||
// BSP branch: NO CAP, and this is a retail port. find_bbox_cell_list
|
// Sorting-sphere branch: int.MaxValue is NOT a retail port and the
|
||||||
// @0x00510fc0 -> CPartArray::calc_cross_cells_static @0x00518160 ->
|
// addresses above do not justify it. Retail's overload @0x0052b990
|
||||||
// CEnvCell::find_transit_cells @0x0052cae0 walks every part, bounded
|
// pushes a literal 1 (0x0052b9d6 push 1) and floods from ONE authored
|
||||||
// only by num_parts. Clamping it dropped parts 11..N out of the flood
|
// CSetup::sorting_sphere. acdream floods from every Sphere shape
|
||||||
// entirely: 7 installed Setups carry more than 10 physics-BSP parts
|
// instead — a different DAT field with a different cardinality, which
|
||||||
// (max 49, Setup 0x02001A91), and landblock-baked part arrays — stair
|
// is AP-157, filed and open. Capping at 1 HERE would not move toward
|
||||||
// runs, fences, rock clusters — routinely do.
|
// retail: it would take Spheres[0], which is not the sorting sphere.
|
||||||
//
|
// int.MaxValue keeps the substitution in its safe (over-inclusive)
|
||||||
// only == null (the sorting-sphere branch): int.MaxValue is NOT a
|
// direction until AP-157 ports the real field. Inert over installed
|
||||||
// retail port and the addresses above do not justify it. Retail's
|
// data — max 5 Spheres on any Setup (0x020016F7).
|
||||||
// overload @0x0052b990 pushes a literal 1 (0x0052b9d6 push 1) and
|
|
||||||
// floods from ONE authored CSetup::sorting_sphere. acdream floods from
|
|
||||||
// every Sphere shape instead — a different DAT field with a different
|
|
||||||
// cardinality, which is AP-157, filed and open. Capping at 1 HERE would
|
|
||||||
// not move toward retail: it would take Spheres[0], which is not the
|
|
||||||
// sorting sphere. int.MaxValue keeps the substitution in its safe
|
|
||||||
// (over-inclusive) direction until AP-157 ports the real field. Inert
|
|
||||||
// over installed data — max 5 Spheres on any Setup (0x020016F7).
|
|
||||||
int cap = only == ShadowCollisionType.Cylinder ? RetailSphereCap : int.MaxValue;
|
int cap = only == ShadowCollisionType.Cylinder ? RetailSphereCap : int.MaxValue;
|
||||||
|
|
||||||
foreach (var s in shapes)
|
foreach (var s in shapes)
|
||||||
{
|
{
|
||||||
if (only is { } required && s.CollisionType != required)
|
if (s.CollisionType != only)
|
||||||
continue;
|
continue;
|
||||||
if (spheres.Count >= cap)
|
if (spheres.Count >= cap)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// Place the sphere where the GEOMETRY is, not where the part
|
// A primitive's LocalPosition already IS its centre, so
|
||||||
// origin is. Composed exactly as the ShadowEntry rows below are
|
// BoundsCenter is Zero; the composition is kept identical to the
|
||||||
// (partWorldPos / partWorldRot), then offset by the shape's own
|
// emitted ShadowEntry rows so the flood and the geometry can never
|
||||||
// BoundsCenter — retail's CEnvCell::find_transit_cells @0x0052cae0
|
// disagree about where the shape is.
|
||||||
// transforms CGfxObj::physics_sphere's center through the part's
|
|
||||||
// Position at [part+0x30] before reading its radius at
|
|
||||||
// 0x0052cb65. Primitives carry BoundsCenter == Zero because their
|
|
||||||
// LocalPosition already is their center.
|
|
||||||
var partWorldPos = entityWorldPos + Vector3.Transform(s.LocalPosition, entityWorldRot);
|
var partWorldPos = entityWorldPos + Vector3.Transform(s.LocalPosition, entityWorldRot);
|
||||||
var partWorldRot = entityWorldRot * s.LocalRotation;
|
var partWorldRot = entityWorldRot * s.LocalRotation;
|
||||||
var world = partWorldPos + Vector3.Transform(s.BoundsCenter, partWorldRot);
|
var world = partWorldPos + Vector3.Transform(s.BoundsCenter, partWorldRot);
|
||||||
|
|
@ -715,6 +731,66 @@ public sealed class ShadowObjectRegistry
|
||||||
return spheres;
|
return spheres;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #334: the per-part world-placed authored boxes retail's
|
||||||
|
/// <c>CLandCell::add_all_outside_cells</c> @0x00533360 divides by
|
||||||
|
/// <c>square_length</c>. Composed exactly as the emitted
|
||||||
|
/// <see cref="ShadowEntry"/> rows are, so the flood rectangle and the
|
||||||
|
/// collision geometry describe the same placement.
|
||||||
|
/// </summary>
|
||||||
|
private static List<ShadowPartBox> BuildFloodPartBoxes(
|
||||||
|
Vector3 entityWorldPos,
|
||||||
|
Quaternion entityWorldRot,
|
||||||
|
System.Collections.Generic.IReadOnlyList<ShadowShape> shapes)
|
||||||
|
{
|
||||||
|
var boxes = new List<ShadowPartBox>(shapes.Count);
|
||||||
|
foreach (var s in shapes)
|
||||||
|
{
|
||||||
|
if (s.CollisionType != ShadowCollisionType.BSP)
|
||||||
|
continue;
|
||||||
|
boxes.Add(ShadowPartBox.FromShape(s, entityWorldPos, entityWorldRot));
|
||||||
|
}
|
||||||
|
return boxes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The per-part BSP ROOT bounding spheres retail's part-array
|
||||||
|
/// <c>CEnvCell::find_transit_cells</c> @0x0052cae0 loads at
|
||||||
|
/// <c>0x0052cb36 mov esi,[ecx+0x74]</c>, transforms through the part's own
|
||||||
|
/// Position (<c>0x0052cb4c</c> / <c>Position::localtolocal</c>) and reads
|
||||||
|
/// the radius from at <c>0x0052cb65 fadd [esi+0xc]</c>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// These drive ONLY the indoor half of the BSP flood and the outdoor
|
||||||
|
/// building bridge (<c>CEnvCell::check_building_transit</c> @0x0052c5d0),
|
||||||
|
/// which still use the sphere traversal — the AP-159 residual. The
|
||||||
|
/// outdoor expansion uses <see cref="BuildFloodPartBoxes"/> and never
|
||||||
|
/// these. No cap: <c>find_bbox_cell_list</c> walks every part, bounded
|
||||||
|
/// only by <c>num_parts</c> (7 installed Setups carry more than 10
|
||||||
|
/// physics-BSP parts, max 49 on Setup 0x02001A91).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private static List<DatReaderWriter.Types.Sphere> BuildBspPartSpheres(
|
||||||
|
Vector3 entityWorldPos,
|
||||||
|
Quaternion entityWorldRot,
|
||||||
|
System.Collections.Generic.IReadOnlyList<ShadowShape> shapes)
|
||||||
|
{
|
||||||
|
var spheres = new List<DatReaderWriter.Types.Sphere>(shapes.Count);
|
||||||
|
foreach (var s in shapes)
|
||||||
|
{
|
||||||
|
if (s.CollisionType != ShadowCollisionType.BSP)
|
||||||
|
continue;
|
||||||
|
var partWorldPos = entityWorldPos + Vector3.Transform(s.LocalPosition, entityWorldRot);
|
||||||
|
var partWorldRot = entityWorldRot * s.LocalRotation;
|
||||||
|
spheres.Add(new DatReaderWriter.Types.Sphere
|
||||||
|
{
|
||||||
|
Origin = partWorldPos + Vector3.Transform(s.BoundsCenter, partWorldRot),
|
||||||
|
Radius = s.Radius,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return spheres;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Derive the outdoor landcell id under a world position — the implicit
|
/// Derive the outdoor landcell id under a world position — the implicit
|
||||||
/// seed for landblock-baked statics registered without a cell id
|
/// seed for landblock-baked statics registered without a cell id
|
||||||
|
|
|
||||||
171
src/AcDream.Core/Physics/ShadowPartBox.cs
Normal file
171
src/AcDream.Core/Physics/ShadowPartBox.cs
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One physics-BSP part's flood geometry, resolved as ONE value: the part
|
||||||
|
/// GfxObj's physics-BSP root bounding sphere AND the axis-aligned box of its
|
||||||
|
/// vertex array, both in the GfxObj's own unscaled frame.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The pairing is the AP-156 invariant applied a second time. Retail's cell
|
||||||
|
/// membership reads BOTH — <c>CEnvCell::find_transit_cells</c> @0x0052cae0
|
||||||
|
/// takes <c>CGfxObj::physics_sphere</c> (<c>[gfxobj+0x74]</c>) for its cheap
|
||||||
|
/// portal-plane reject, and <c>CLandCell::add_all_outside_cells</c> @0x00533360
|
||||||
|
/// takes <c>CPhysicsPart::GetBoundingBox</c> @0x0050d600
|
||||||
|
/// (<c>&gfxobj->gfx_bound_box</c>) for the outdoor extent walk. A resolver
|
||||||
|
/// that answered only one of the two would leave the other call site to
|
||||||
|
/// synthesize a substitute, which is exactly how the sphere came to be placed
|
||||||
|
/// at the part origin (AP-156) and how #334's outdoor rectangle came to be a
|
||||||
|
/// fixed 3×3.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="BoxMin"/>/<see cref="BoxMax"/> come from
|
||||||
|
/// <c>FlatGfxObjVisualBounds</c>, which
|
||||||
|
/// <c>FlatCollisionAssetBuilder.FlattenGfxObj</c> computes with
|
||||||
|
/// <c>PhysicsDataCache.ComputeVisualBounds(source.VertexArray)</c> — the exact
|
||||||
|
/// <c>CGfxObj::init_end</c> @0x00534200 computation (seed min=max=vertices[0],
|
||||||
|
/// then <c>BBox::AdjustBBox</c> over every vertex of the render vertex array,
|
||||||
|
/// which is also the array the physics polygons index into).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct ShadowPartGeometry
|
||||||
|
{
|
||||||
|
private ShadowPartGeometry(
|
||||||
|
FlatCollisionSphere sphere,
|
||||||
|
Vector3 boxMin,
|
||||||
|
Vector3 boxMax)
|
||||||
|
{
|
||||||
|
Sphere = sphere;
|
||||||
|
BoxMin = boxMin;
|
||||||
|
BoxMax = boxMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Retail <c>CGfxObj::physics_sphere</c> — the physics BSP root
|
||||||
|
/// bounding sphere, origin included, unscaled.</summary>
|
||||||
|
public FlatCollisionSphere Sphere { get; }
|
||||||
|
|
||||||
|
/// <summary>Retail <c>CGfxObj::gfx_bound_box.m_vMin</c>, unscaled.</summary>
|
||||||
|
public Vector3 BoxMin { get; }
|
||||||
|
|
||||||
|
/// <summary>Retail <c>CGfxObj::gfx_bound_box.m_vMax</c>, unscaled.</summary>
|
||||||
|
public Vector3 BoxMax { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pairs the two. <paramref name="visualBounds"/> is the prepared
|
||||||
|
/// package's <c>FlatGfxObjVisualBounds</c>; when it is absent (graph-only
|
||||||
|
/// fixtures, and prepared assets baked without the field) the box falls
|
||||||
|
/// back to the sphere's own axis-aligned bound, which contains every
|
||||||
|
/// physics polygon vertex the sphere contains and keeps the substitution
|
||||||
|
/// in the over-inclusive direction retail itself uses (§1.8 of
|
||||||
|
/// <c>docs/research/2026-08-06-334-contract.md</c>). The fallback lives
|
||||||
|
/// HERE so no call site can observe a half-populated value.
|
||||||
|
/// </summary>
|
||||||
|
public static ShadowPartGeometry Create(
|
||||||
|
FlatCollisionSphere sphere,
|
||||||
|
FlatGfxObjVisualBounds? visualBounds)
|
||||||
|
{
|
||||||
|
if (visualBounds is { } bounds)
|
||||||
|
return new ShadowPartGeometry(sphere, bounds.Min, bounds.Max);
|
||||||
|
|
||||||
|
var extent = new Vector3(sphere.Radius);
|
||||||
|
return new ShadowPartGeometry(
|
||||||
|
sphere,
|
||||||
|
sphere.Origin - extent,
|
||||||
|
sphere.Origin + extent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One physics-BSP part's world-placed bounding box — the per-part input to
|
||||||
|
/// retail's outdoor extent walk.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Retail's <c>CLandCell::add_all_outside_cells</c> @0x00533360 calls
|
||||||
|
/// <c>BBox::LocalToGlobal(part->gfxobj->gfx_bound_box, part->pos,
|
||||||
|
/// cell0->pos)</c> (@0x00533527) per part, so the box it divides by
|
||||||
|
/// <c>square_length</c> is the part's authored box re-fit through the part's
|
||||||
|
/// own placement. <see cref="LocalMin"/>/<see cref="LocalMax"/> are that
|
||||||
|
/// authored box (entity-scaled); <see cref="WorldPosition"/>/
|
||||||
|
/// <see cref="WorldRotation"/> are the part placement
|
||||||
|
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> composes for the
|
||||||
|
/// <c>ShadowEntry</c> rows, so the flood and the geometry can never disagree
|
||||||
|
/// about where the part is.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// CONSTRUCTION IS BY FACTORY ONLY: min and max arrive together, from one
|
||||||
|
/// <see cref="ShadowShape"/>, so no future producer can carry one and drop the
|
||||||
|
/// other.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct ShadowPartBox
|
||||||
|
{
|
||||||
|
private ShadowPartBox(
|
||||||
|
Vector3 localMin,
|
||||||
|
Vector3 localMax,
|
||||||
|
Vector3 worldPosition,
|
||||||
|
Quaternion worldRotation)
|
||||||
|
{
|
||||||
|
LocalMin = localMin;
|
||||||
|
LocalMax = localMax;
|
||||||
|
WorldPosition = worldPosition;
|
||||||
|
WorldRotation = worldRotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Authored box minimum in the part's own frame, entity-scaled.</summary>
|
||||||
|
public Vector3 LocalMin { get; }
|
||||||
|
|
||||||
|
/// <summary>Authored box maximum in the part's own frame, entity-scaled.</summary>
|
||||||
|
public Vector3 LocalMax { get; }
|
||||||
|
|
||||||
|
/// <summary>The part's world placement — retail <c>CPhysicsPart::pos</c>.</summary>
|
||||||
|
public Vector3 WorldPosition { get; }
|
||||||
|
|
||||||
|
/// <summary>The part's world orientation — retail <c>CPhysicsPart::pos</c>.</summary>
|
||||||
|
public Quaternion WorldRotation { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composes the part's world placement exactly as
|
||||||
|
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> composes the
|
||||||
|
/// emitted <c>ShadowEntry</c>'s.
|
||||||
|
/// </summary>
|
||||||
|
public static ShadowPartBox FromShape(
|
||||||
|
in ShadowShape shape,
|
||||||
|
Vector3 entityWorldPosition,
|
||||||
|
Quaternion entityWorldRotation)
|
||||||
|
=> new(
|
||||||
|
shape.LocalBoundsMin,
|
||||||
|
shape.LocalBoundsMax,
|
||||||
|
entityWorldPosition
|
||||||
|
+ Vector3.Transform(shape.LocalPosition, entityWorldRotation),
|
||||||
|
entityWorldRotation * shape.LocalRotation);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>BBox::LocalToGlobal</c> @0x005b2120 — a proper EIGHT-CORNER
|
||||||
|
/// re-fit, not a min/max transform: transform <c>min</c>, seed both
|
||||||
|
/// corners from it, transform the other seven and <c>AdjustBBox</c> each.
|
||||||
|
/// A rotated box therefore GROWS, conservatively, which is the direction
|
||||||
|
/// retail deliberately errs in.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="frameOrigin">World origin of the destination frame —
|
||||||
|
/// retail's <c>cell0->pos</c>, i.e. the landblock the extent walk
|
||||||
|
/// anchors on.</param>
|
||||||
|
public void RefitTo(Vector3 frameOrigin, out Vector3 min, out Vector3 max)
|
||||||
|
{
|
||||||
|
Vector3 offset = WorldPosition - frameOrigin;
|
||||||
|
min = new Vector3(float.MaxValue);
|
||||||
|
max = new Vector3(float.MinValue);
|
||||||
|
for (int corner = 0; corner < 8; corner++)
|
||||||
|
{
|
||||||
|
var local = new Vector3(
|
||||||
|
(corner & 1) == 0 ? LocalMin.X : LocalMax.X,
|
||||||
|
(corner & 2) == 0 ? LocalMin.Y : LocalMax.Y,
|
||||||
|
(corner & 4) == 0 ? LocalMin.Z : LocalMax.Z);
|
||||||
|
Vector3 world = Vector3.Transform(local, WorldRotation) + offset;
|
||||||
|
min = Vector3.Min(min, world);
|
||||||
|
max = Vector3.Max(max, world);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,12 +20,13 @@ namespace AcDream.Core.Physics;
|
||||||
/// CONSTRUCTION IS BY FACTORY ONLY (<see cref="Bsp"/>, <see cref="Cylinder"/>,
|
/// CONSTRUCTION IS BY FACTORY ONLY (<see cref="Bsp"/>, <see cref="Cylinder"/>,
|
||||||
/// <see cref="Sphere"/>) and the constructor is private. That is the AP-156
|
/// <see cref="Sphere"/>) and the constructor is private. That is the AP-156
|
||||||
/// invariant expressed at the type rather than only at the producer: a BSP
|
/// invariant expressed at the type rather than only at the producer: a BSP
|
||||||
/// shape's radius and its bounding-sphere CENTRE arrive as one
|
/// shape's radius, its bounding-sphere CENTRE, and its authored bounding BOX
|
||||||
/// <see cref="FlatCollisionSphere"/> value and are scaled together inside
|
/// arrive as one <see cref="ShadowPartGeometry"/> value and are scaled together
|
||||||
/// <see cref="Bsp"/>, so no call site — present or future — can take the
|
/// inside <see cref="Bsp"/>, so no call site — present or future — can take one
|
||||||
/// radius while dropping the origin. That split is exactly what produced
|
/// while dropping another. Those splits are exactly what produced AP-156 (the
|
||||||
/// AP-156, and with the old public 7-argument constructor a new BSP producer
|
/// centre dropped) and #334 (the box never resolved at all); with a public
|
||||||
/// could have reintroduced it silently and green.
|
/// positional constructor a new BSP producer could reintroduce either silently
|
||||||
|
/// and green.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly record struct ShadowShape
|
public readonly record struct ShadowShape
|
||||||
|
|
@ -38,7 +39,9 @@ public readonly record struct ShadowShape
|
||||||
ShadowCollisionType collisionType,
|
ShadowCollisionType collisionType,
|
||||||
float radius,
|
float radius,
|
||||||
float cylHeight,
|
float cylHeight,
|
||||||
Vector3 boundsCenter)
|
Vector3 boundsCenter,
|
||||||
|
Vector3 localBoundsMin,
|
||||||
|
Vector3 localBoundsMax)
|
||||||
{
|
{
|
||||||
GfxObjId = gfxObjId;
|
GfxObjId = gfxObjId;
|
||||||
LocalPosition = localPosition;
|
LocalPosition = localPosition;
|
||||||
|
|
@ -48,6 +51,8 @@ public readonly record struct ShadowShape
|
||||||
Radius = radius;
|
Radius = radius;
|
||||||
CylHeight = cylHeight;
|
CylHeight = cylHeight;
|
||||||
BoundsCenter = boundsCenter;
|
BoundsCenter = boundsCenter;
|
||||||
|
LocalBoundsMin = localBoundsMin;
|
||||||
|
LocalBoundsMax = localBoundsMax;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Source GfxObj id, for the BSP walk and for diagnostics.</summary>
|
/// <summary>Source GfxObj id, for the BSP walk and for diagnostics.</summary>
|
||||||
|
|
@ -107,27 +112,51 @@ public readonly record struct ShadowShape
|
||||||
public Vector3 BoundsCenter { get; }
|
public Vector3 BoundsCenter { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One physics-BSP part. <paramref name="localBounds"/> is the part
|
/// The shape's AXIS-ALIGNED BOX in the same local frame as
|
||||||
/// GfxObj's physics-BSP ROOT bounding sphere in the GfxObj's OWN frame,
|
/// <see cref="BoundsCenter"/>, already entity-scaled. For a BSP shape this
|
||||||
/// unscaled — retail's <c>CGfxObj::physics_sphere</c>. Radius and centre
|
/// is retail's <c>CGfxObj::gfx_bound_box</c> — the AABB of the GfxObj's
|
||||||
/// are scaled together here, which is the whole point of taking them as
|
/// vertex array, which <c>CPhysicsPart::GetBoundingBox</c> @0x0050d600
|
||||||
/// one value.
|
/// returns and which <c>CLandCell::add_all_outside_cells</c> @0x00533360
|
||||||
|
/// divides by <c>square_length</c> to build the outdoor cell rectangle
|
||||||
|
/// (#334). Primitive shapes carry their own radius/height box; they never
|
||||||
|
/// reach that path, because <c>CPhysicsObj::calc_cross_cells</c>
|
||||||
|
/// @0x00515230 routes only <c>HAS_PHYSICS_BSP_PS</c> objects to
|
||||||
|
/// <c>find_bbox_cell_list</c>.
|
||||||
|
/// </summary>
|
||||||
|
public Vector3 LocalBoundsMin { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc cref="LocalBoundsMin"/>
|
||||||
|
public Vector3 LocalBoundsMax { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One physics-BSP part. <paramref name="localGeometry"/> carries the part
|
||||||
|
/// GfxObj's physics-BSP ROOT bounding sphere AND its vertex-array box in
|
||||||
|
/// the GfxObj's OWN frame, unscaled — retail's
|
||||||
|
/// <c>CGfxObj::physics_sphere</c> and <c>CGfxObj::gfx_bound_box</c>.
|
||||||
|
/// Sphere radius, sphere centre, and both box corners are scaled together
|
||||||
|
/// here, which is the whole point of taking them as one value: retail
|
||||||
|
/// reads the sphere for the indoor portal reject and the box for the
|
||||||
|
/// outdoor extent walk, and a producer that supplied one without the
|
||||||
|
/// other would silently force a substitute at the other call site
|
||||||
|
/// (AP-156, then #334).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static ShadowShape Bsp(
|
public static ShadowShape Bsp(
|
||||||
uint gfxObjId,
|
uint gfxObjId,
|
||||||
Vector3 localPosition,
|
Vector3 localPosition,
|
||||||
Quaternion localRotation,
|
Quaternion localRotation,
|
||||||
float scale,
|
float scale,
|
||||||
FlatCollisionSphere localBounds)
|
ShadowPartGeometry localGeometry)
|
||||||
=> new(
|
=> new(
|
||||||
gfxObjId,
|
gfxObjId,
|
||||||
localPosition,
|
localPosition,
|
||||||
localRotation,
|
localRotation,
|
||||||
scale,
|
scale,
|
||||||
ShadowCollisionType.BSP,
|
ShadowCollisionType.BSP,
|
||||||
localBounds.Radius * scale,
|
localGeometry.Sphere.Radius * scale,
|
||||||
0f,
|
0f,
|
||||||
localBounds.Origin * scale);
|
localGeometry.Sphere.Origin * scale,
|
||||||
|
localGeometry.BoxMin * scale,
|
||||||
|
localGeometry.BoxMax * scale);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One Setup CylSphere. <paramref name="localPosition"/> already IS the
|
/// One Setup CylSphere. <paramref name="localPosition"/> already IS the
|
||||||
|
|
@ -148,7 +177,9 @@ public readonly record struct ShadowShape
|
||||||
ShadowCollisionType.Cylinder,
|
ShadowCollisionType.Cylinder,
|
||||||
radius,
|
radius,
|
||||||
cylHeight,
|
cylHeight,
|
||||||
Vector3.Zero);
|
Vector3.Zero,
|
||||||
|
new Vector3(-radius, -radius, 0f),
|
||||||
|
new Vector3(radius, radius, cylHeight));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One Setup Sphere. <paramref name="localPosition"/> already IS the
|
/// One Setup Sphere. <paramref name="localPosition"/> already IS the
|
||||||
|
|
@ -168,5 +199,7 @@ public readonly record struct ShadowShape
|
||||||
ShadowCollisionType.Sphere,
|
ShadowCollisionType.Sphere,
|
||||||
radius,
|
radius,
|
||||||
0f,
|
0f,
|
||||||
Vector3.Zero);
|
Vector3.Zero,
|
||||||
|
new Vector3(-radius),
|
||||||
|
new Vector3(radius));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,21 +94,25 @@ public static class ShadowShapeBuilder
|
||||||
/// index and pose, but reads PhysicsBSP from the installed replacement.
|
/// index and pose, but reads PhysicsBSP from the installed replacement.
|
||||||
/// Null or short lists fall back to the Setup identity.</param>
|
/// Null or short lists fall back to the Setup identity.</param>
|
||||||
/// <param name="physicsBspBounds">The part GfxObj's physics-BSP ROOT
|
/// <param name="physicsBspBounds">The part GfxObj's physics-BSP ROOT
|
||||||
/// bounding sphere — retail's <c>CGfxObj::physics_sphere</c>, which is
|
/// bounding sphere AND its authored vertex-array box — retail's
|
||||||
/// literally <c>BSPTREE::GetSphere(physics_bsp)</c> @0x005397e0. Supplies
|
/// <c>CGfxObj::physics_sphere</c> (<c>BSPTREE::GetSphere(physics_bsp)</c>
|
||||||
/// BOTH the emitted <see cref="ShadowShape.Radius"/> and its
|
/// @0x005397e0) and <c>CGfxObj::gfx_bound_box</c>
|
||||||
/// <see cref="ShadowShape.BoundsCenter"/>, from one call, so the sphere's
|
/// (<c>CPhysicsPart::GetBoundingBox</c> @0x0050d600), as ONE
|
||||||
/// size can never be carried while its position is dropped. Null (or a
|
/// <see cref="ShadowPartGeometry"/>. Supplies the emitted
|
||||||
/// null result) falls back to the loose-but-safe 2 m placeholder at the
|
/// <see cref="ShadowShape.Radius"/>, <see cref="ShadowShape.BoundsCenter"/>
|
||||||
/// part origin — a fixture-only configuration; production always supplies
|
/// and <see cref="ShadowShape.LocalBoundsMin"/>/<c>Max</c> from one call,
|
||||||
/// it (<c>LiveEntityCollisionBuilder</c>).</param>
|
/// so no part of the flood geometry can be carried while another is
|
||||||
|
/// dropped (AP-156, then #334). Null (or a null result) falls back to the
|
||||||
|
/// loose-but-safe 2 m placeholder at the part origin — a fixture-only
|
||||||
|
/// configuration; production always supplies it
|
||||||
|
/// (<c>LiveEntityCollisionBuilder</c>).</param>
|
||||||
public static IReadOnlyList<ShadowShape> FromSetup(
|
public static IReadOnlyList<ShadowShape> FromSetup(
|
||||||
Setup setup,
|
Setup setup,
|
||||||
float entScale,
|
float entScale,
|
||||||
Func<uint, bool> hasPhysicsBsp,
|
Func<uint, bool> hasPhysicsBsp,
|
||||||
IReadOnlyList<Frame>? partPoseOverride = null,
|
IReadOnlyList<Frame>? partPoseOverride = null,
|
||||||
IReadOnlyList<uint>? effectivePartGfxObjIds = null,
|
IReadOnlyList<uint>? effectivePartGfxObjIds = null,
|
||||||
Func<uint, FlatCollisionSphere?>? physicsBspBounds = null)
|
Func<uint, ShadowPartGeometry?>? physicsBspBounds = null)
|
||||||
{
|
{
|
||||||
if (setup is null) throw new ArgumentNullException(nameof(setup));
|
if (setup is null) throw new ArgumentNullException(nameof(setup));
|
||||||
if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp));
|
if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp));
|
||||||
|
|
@ -210,17 +214,27 @@ public static class ShadowShapeBuilder
|
||||||
// supplies both so one cannot be taken without the other.
|
// supplies both so one cannot be taken without the other.
|
||||||
// Absent bounds keep the loose-but-safe 2 m placeholder, centred
|
// Absent bounds keep the loose-but-safe 2 m placeholder, centred
|
||||||
// on the part origin because nothing better is known.
|
// on the part origin because nothing better is known.
|
||||||
// ShadowShape.Bsp scales radius and centre together.
|
// ShadowShape.Bsp scales radius, centre and box together.
|
||||||
FlatCollisionSphere bounds =
|
//
|
||||||
|
// #334: the SAME resolver also supplies the authored vertex-array
|
||||||
|
// box. Retail's outdoor cell membership
|
||||||
|
// (CLandCell::add_all_outside_cells @0x00533360, reached from
|
||||||
|
// find_bbox_cell_list @0x00510fc0) divides that box — never the
|
||||||
|
// sphere — by square_length to build its cell rectangle, so a
|
||||||
|
// resolver that answered only the sphere would leave that walk
|
||||||
|
// with nothing to walk.
|
||||||
|
ShadowPartGeometry geometry =
|
||||||
physicsBspBounds?.Invoke(gfxId)
|
physicsBspBounds?.Invoke(gfxId)
|
||||||
?? new FlatCollisionSphere(Vector3.Zero, 2f);
|
?? ShadowPartGeometry.Create(
|
||||||
|
new FlatCollisionSphere(Vector3.Zero, 2f),
|
||||||
|
null);
|
||||||
|
|
||||||
result.Add(ShadowShape.Bsp(
|
result.Add(ShadowShape.Bsp(
|
||||||
gfxObjId: gfxId,
|
gfxObjId: gfxId,
|
||||||
localPosition: new Vector3(partFrame.Origin.X, partFrame.Origin.Y, partFrame.Origin.Z) * entScale,
|
localPosition: new Vector3(partFrame.Origin.X, partFrame.Origin.Y, partFrame.Origin.Z) * entScale,
|
||||||
localRotation: partFrame.Orientation,
|
localRotation: partFrame.Orientation,
|
||||||
scale: entScale,
|
scale: entScale,
|
||||||
localBounds: bounds));
|
localGeometry: geometry));
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -308,12 +322,21 @@ public static class ShadowShapeBuilder
|
||||||
phys.BoundingSphere?.Origin ?? Vector3.Zero,
|
phys.BoundingSphere?.Origin ?? Vector3.Zero,
|
||||||
phys.BoundingSphere?.Radius ?? 1f);
|
phys.BoundingSphere?.Radius ?? 1f);
|
||||||
|
|
||||||
|
// #334: the same cached record carries the authored vertex-array
|
||||||
|
// box (CGfxObj::gfx_bound_box), which retail's outdoor extent walk
|
||||||
|
// — CLandCell::add_all_outside_cells @0x00533360 — divides by
|
||||||
|
// square_length. Landblock-baked part arrays are exactly the
|
||||||
|
// population whose extent exceeds one 24 m land cell, so the
|
||||||
|
// sphere alone cannot describe their membership.
|
||||||
|
ShadowPartGeometry geometry =
|
||||||
|
ShadowPartGeometry.Create(localBounds, phys.VisualBounds);
|
||||||
|
|
||||||
shapes.Add(ShadowShape.Bsp(
|
shapes.Add(ShadowShape.Bsp(
|
||||||
gfxObjId: meshRef.GfxObjId,
|
gfxObjId: meshRef.GfxObjId,
|
||||||
localPosition: pPos,
|
localPosition: pPos,
|
||||||
localRotation: pRot,
|
localRotation: pRot,
|
||||||
scale: partScale,
|
scale: partScale,
|
||||||
localBounds: localBounds));
|
localGeometry: geometry));
|
||||||
}
|
}
|
||||||
|
|
||||||
return shapes;
|
return shapes;
|
||||||
|
|
|
||||||
|
|
@ -412,8 +412,10 @@ public sealed class LiveEntityCollisionBuilderTests
|
||||||
/// radius). A fixture pinned at <c>Vector3.Zero</c> cannot observe the
|
/// radius). A fixture pinned at <c>Vector3.Zero</c> cannot observe the
|
||||||
/// centre at all — which is how AP-156's discarded origin stayed green.
|
/// centre at all — which is how AP-156's discarded origin stayed green.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static FlatCollisionSphere? Bsp(float radius, float centerZ = 1.25f)
|
private static ShadowPartGeometry? Bsp(float radius, float centerZ = 1.25f)
|
||||||
=> new FlatCollisionSphere(new Vector3(0f, 0f, centerZ), radius);
|
=> ShadowPartGeometry.Create(
|
||||||
|
new FlatCollisionSphere(new Vector3(0f, 0f, centerZ), radius),
|
||||||
|
null);
|
||||||
|
|
||||||
private static LiveEntityDefaultPoseResolver PoseResolver() => new(
|
private static LiveEntityDefaultPoseResolver PoseResolver() => new(
|
||||||
_ => null,
|
_ => null,
|
||||||
|
|
|
||||||
|
|
@ -143,8 +143,10 @@ public sealed class PvpBitfieldSurvivesAppearanceRebuildTests
|
||||||
setup.Parts.Add(0x0100AB01u);
|
setup.Parts.Add(0x0100AB01u);
|
||||||
var builder = new LiveEntityCollisionBuilder(
|
var builder = new LiveEntityCollisionBuilder(
|
||||||
id => id == 0x0100AB01u
|
id => id == 0x0100AB01u
|
||||||
? new FlatCollisionSphere(new Vector3(0f, 0f, 0.5f), 1f)
|
? ShadowPartGeometry.Create(
|
||||||
: null,
|
new FlatCollisionSphere(new Vector3(0f, 0f, 0.5f), 1f),
|
||||||
|
null)
|
||||||
|
: (ShadowPartGeometry?)null,
|
||||||
new LiveEntityDefaultPoseResolver(
|
new LiveEntityDefaultPoseResolver(
|
||||||
_ => null,
|
_ => null,
|
||||||
new NullAnimationLoader(),
|
new NullAnimationLoader(),
|
||||||
|
|
|
||||||
|
|
@ -339,7 +339,9 @@ public sealed class InstalledSetupBspPrimitiveDispatchTests
|
||||||
setup,
|
setup,
|
||||||
EntScale,
|
EntScale,
|
||||||
id => Bounds(id) is not null,
|
id => Bounds(id) is not null,
|
||||||
physicsBspBounds: Bounds);
|
physicsBspBounds: id => Bounds(id) is { } sphere
|
||||||
|
? ShadowPartGeometry.Create(sphere, null)
|
||||||
|
: (ShadowPartGeometry?)null);
|
||||||
|
|
||||||
// ShadowObjectRegistry.BuildFloodSpheres' composition, at an
|
// ShadowObjectRegistry.BuildFloodSpheres' composition, at an
|
||||||
// entity placed at the world origin with identity rotation.
|
// entity placed at the world origin with identity rotation.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
using DatReaderWriter.Types;
|
||||||
|
|
||||||
|
namespace AcDream.Content.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #334, replayed against the installed DATs from the user's own live
|
||||||
|
/// evidence (<c>334-neftet-probe.log</c>, 8,401 lines, 2026-08-06).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Standing inside the Neftet rock formation the broadphase reported
|
||||||
|
/// <c>inCell=2 exempt=2 reached=0</c> — the formation was not in the player's
|
||||||
|
/// cell at all. The one object that DID block, GfxObj <c>0x010046D8</c>
|
||||||
|
/// (root bounding sphere radius 69.471 m, bounds centre 34.977 m off the part
|
||||||
|
/// origin), was measured PRESENT in cells <c>0x8764000A</c> and
|
||||||
|
/// <c>0x87640012</c> and ABSENT from <c>0x87640011</c>, <c>0x87640019</c> and
|
||||||
|
/// <c>0x87630018</c>. Those five observations have exactly one explanation:
|
||||||
|
/// a 3×3 land-cell neighbourhood centred on <c>0x87640013</c> — the cell
|
||||||
|
/// under the object's own position — which is what
|
||||||
|
/// <c>CellTransit.AddAllOutsideCells</c> produces for ANY sphere, because its
|
||||||
|
/// <c>minRad = radius</c> / <c>maxRad = 24 - radius</c> boundary tests are
|
||||||
|
/// unconditionally true above 12 m and it only ever adds the eight
|
||||||
|
/// neighbours.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// This test asserts the observed reality rather than a constant: the two
|
||||||
|
/// cells the probe measured EMPTY must be occupied, and the two it measured
|
||||||
|
/// POPULATED must stay occupied. It fails at <c>f0588725</c> (the sphere route
|
||||||
|
/// cannot reach cellY 0 from a centre at cellY 2) and passes with the box
|
||||||
|
/// route. The expected rectangle was derived from the object's own
|
||||||
|
/// <c>CGfxObj::gfx_bound_box</c> read out of <c>client_portal.dat</c>, not
|
||||||
|
/// from running the code under test: the box is 96 m × 96 m about a part
|
||||||
|
/// origin at block-local (63.78, 56.29), i.e. cell (2,2), so it spans cell
|
||||||
|
/// columns 0..4 on both axes.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Issue334NeftetFormationCellMembershipTests
|
||||||
|
{
|
||||||
|
private const uint NeftetLandblock = 0x87640000u;
|
||||||
|
private const uint NeftetLandblockInfo = 0x8764FFFEu;
|
||||||
|
private const uint FormationGfxObj = 0x010046D8u;
|
||||||
|
|
||||||
|
// The four cells named in the probe log, by their measured disposition.
|
||||||
|
private const uint MeasuredPresentA = 0x8764000Au; // lcoord (1081, 801)
|
||||||
|
private const uint MeasuredPresentB = 0x87640012u; // lcoord (1082, 801)
|
||||||
|
private const uint MeasuredAbsentA = 0x87640011u; // lcoord (1082, 800)
|
||||||
|
private const uint MeasuredAbsentB = 0x87640019u; // lcoord (1083, 800)
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NeftetFormation_RegistersInTheCellsTheProbeMeasuredEmpty()
|
||||||
|
{
|
||||||
|
string? datDir = ContentConformanceDats.ResolveDatDir();
|
||||||
|
if (datDir is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
Assert.True(
|
||||||
|
dats.Cell.TryGet<LandBlockInfo>(NeftetLandblockInfo, out LandBlockInfo? info)
|
||||||
|
&& info is not null,
|
||||||
|
"Neftet landblock info 0x8764FFFE is absent from the installed cell dat.");
|
||||||
|
|
||||||
|
// The probe's entity 0xC8764000 is stab index 0 of this landblock
|
||||||
|
// (LandblockStaticEntityIdAllocator's 0xCXXYYIII packing).
|
||||||
|
Stab formation = info!.Objects.First(o => o.Id == FormationGfxObj);
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
dats.Portal.TryGet<GfxObj>(FormationGfxObj, out GfxObj? gfx) && gfx is not null,
|
||||||
|
"GfxObj 0x010046D8 is absent from the installed portal dat.");
|
||||||
|
|
||||||
|
// Control: the fixture must be non-degenerate on the axis under test.
|
||||||
|
// A box that fits inside its own bounding sphere makes the box route
|
||||||
|
// and the sphere route agree, and proves nothing.
|
||||||
|
var cache = new PhysicsDataCache();
|
||||||
|
cache.CacheGfxObj(FormationGfxObj, gfx!);
|
||||||
|
GfxObjPhysics? phys = cache.GetGfxObj(FormationGfxObj);
|
||||||
|
Assert.NotNull(phys);
|
||||||
|
Assert.NotNull(phys!.VisualBounds);
|
||||||
|
FlatGfxObjVisualBounds box = phys.VisualBounds!.Value;
|
||||||
|
float extentX = box.Max.X - box.Min.X;
|
||||||
|
float extentY = box.Max.Y - box.Min.Y;
|
||||||
|
float rootRadius = phys.BoundingSphere!.Radius;
|
||||||
|
Assert.True(
|
||||||
|
extentX > rootRadius && extentY > rootRadius,
|
||||||
|
$"Fixture is degenerate: extent ({extentX:F2}, {extentY:F2}) does not " +
|
||||||
|
$"exceed the root sphere radius {rootRadius:F3}.");
|
||||||
|
Assert.True(
|
||||||
|
extentX > 48f && extentY > 48f,
|
||||||
|
$"Fixture cannot reach two cells away: extent ({extentX:F2}, {extentY:F2}).");
|
||||||
|
|
||||||
|
IReadOnlyList<ShadowShape> shapes =
|
||||||
|
ShadowShapeBuilder.FromLandblockBspParts(
|
||||||
|
new[] { new MeshRef(FormationGfxObj, Matrix4x4.Identity) },
|
||||||
|
isBuildingShell: false,
|
||||||
|
cache.GetGfxObj);
|
||||||
|
ShadowShape only = Assert.Single(shapes);
|
||||||
|
Assert.Equal(ShadowCollisionType.BSP, only.CollisionType);
|
||||||
|
|
||||||
|
var registry = new ShadowObjectRegistry { DataCache = cache };
|
||||||
|
const uint ownerId = 0xC8764000u;
|
||||||
|
registry.RegisterMultiPart(
|
||||||
|
ownerId,
|
||||||
|
formation.Frame.Origin,
|
||||||
|
formation.Frame.Orientation,
|
||||||
|
shapes,
|
||||||
|
0u,
|
||||||
|
EntityCollisionFlags.None,
|
||||||
|
worldOffsetX: 0f,
|
||||||
|
worldOffsetY: 0f,
|
||||||
|
landblockId: NeftetLandblock,
|
||||||
|
seedCellId: 0u,
|
||||||
|
isStatic: true);
|
||||||
|
|
||||||
|
var held = new List<uint>();
|
||||||
|
for (uint index = 1u; index <= 64u; index++)
|
||||||
|
{
|
||||||
|
uint cellId = NeftetLandblock | index;
|
||||||
|
if (registry.GetObjectsInCell(cellId).Any(e => e.EntityId == ownerId))
|
||||||
|
held.Add(cellId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The measured-populated pair must stay populated.
|
||||||
|
Assert.Contains(MeasuredPresentA, held);
|
||||||
|
Assert.Contains(MeasuredPresentB, held);
|
||||||
|
|
||||||
|
// The measured-EMPTY pair is the #334 fact.
|
||||||
|
Assert.Contains(MeasuredAbsentA, held);
|
||||||
|
Assert.Contains(MeasuredAbsentB, held);
|
||||||
|
|
||||||
|
// And the rectangle is the 5×5 the 96 m box spans about cell (2,2),
|
||||||
|
// clipped to this landblock's own 8×8 grid (the two columns below 0
|
||||||
|
// land in the neighbour blocks 0x8763 / 0x8664 and are counted there).
|
||||||
|
Assert.Equal(25, held.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -926,7 +926,9 @@ public class DoorBugTrajectoryReplayTests
|
||||||
s.LocalPosition,
|
s.LocalPosition,
|
||||||
s.LocalRotation,
|
s.LocalRotation,
|
||||||
s.Scale,
|
s.Scale,
|
||||||
new FlatCollisionSphere(Vector3.Zero, bspR / s.Scale)));
|
ShadowPartGeometry.Create(
|
||||||
|
new FlatCollisionSphere(Vector3.Zero, bspR / s.Scale),
|
||||||
|
null)));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -1113,7 +1115,7 @@ public class DoorBugTrajectoryReplayTests
|
||||||
localPosition: Vector3.Zero,
|
localPosition: Vector3.Zero,
|
||||||
localRotation: Quaternion.Identity,
|
localRotation: Quaternion.Identity,
|
||||||
scale: 1f,
|
scale: 1f,
|
||||||
localBounds: new FlatCollisionSphere(Vector3.Zero, BspRadius));
|
localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, BspRadius), null));
|
||||||
|
|
||||||
var cylShape = ShadowShape.Cylinder(
|
var cylShape = ShadowShape.Cylinder(
|
||||||
gfxObjId: 0u,
|
gfxObjId: 0u,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,412 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #334: a physics-BSP object's outdoor cell membership is the FILLED
|
||||||
|
/// RECTANGLE of land cells its authored bounding box spans, not a fixed 3×3
|
||||||
|
/// neighbourhood.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Retail chain, disassembled from the PDB-paired 2013-09-06 binary:
|
||||||
|
/// <c>CPhysicsObj::calc_cross_cells</c> @0x00515230
|
||||||
|
/// (<c>0x00515285 test dword [esi+0xa8],0x10000</c>) →
|
||||||
|
/// <c>find_bbox_cell_list</c> @0x00510fc0 →
|
||||||
|
/// <c>CPartArray::calc_cross_cells_static</c> @0x00518160 →
|
||||||
|
/// <c>[vtbl+0x7c]</c> → <c>CLandCell::find_transit_cells</c> @0x00533840 →
|
||||||
|
/// <c>add_all_outside_cells</c> @0x00533360 → <c>add_cell_block</c>
|
||||||
|
/// @0x005331d0.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// EVERY fixture here has an XY extent that EXCEEDS its own bounding-sphere
|
||||||
|
/// radius. That is the axis under test: a box that fits inside its sphere
|
||||||
|
/// makes the new path and the old 3×3 agree, and proves nothing. The sphere
|
||||||
|
/// radius is deliberately kept at 1 m so no assertion below can be satisfied
|
||||||
|
/// by the sphere route — retail's outdoor sphere reach is hard-capped at ±1
|
||||||
|
/// cell for ANY radius (<c>check_add_cell_boundary</c> compares against
|
||||||
|
/// <c>radius</c> and <c>24 - radius</c>, both unconditionally true above
|
||||||
|
/// 12 m, and only ever adds the eight neighbours).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Issue334BspBoxCellMembershipTests
|
||||||
|
{
|
||||||
|
// Landblock (0xA9, 0xB4). Global lcoord origin = (0xA9*8, 0xB4*8).
|
||||||
|
private const uint LbId = 0xA9B40000u;
|
||||||
|
private const int GxBase = 0xA9 * 8; // 1352
|
||||||
|
private const int GyBase = 0xB4 * 8; // 1440
|
||||||
|
|
||||||
|
/// <summary>Full outdoor cell id from a GLOBAL lcoord, hand-derived from
|
||||||
|
/// retail's <c>add_cell_block</c> packing at <c>0x0053320a</c>-<c>0x0053322e</c>:
|
||||||
|
/// <c>(((x>>3)<<8) | (y>>3)) << 16 | ((x&7)*8 + (y&7) + 1)</c>.
|
||||||
|
/// Written out here rather than calling LandDefs so the expectation does
|
||||||
|
/// not re-encode the code under test.</summary>
|
||||||
|
private static uint Cell(int gx, int gy)
|
||||||
|
=> (uint)(((((gx >> 3) << 8) | (gy >> 3)) << 16) | ((gx & 7) * 8 + (gy & 7) + 1));
|
||||||
|
|
||||||
|
private static ShadowShape BspPart(
|
||||||
|
Vector3 boxMin,
|
||||||
|
Vector3 boxMax,
|
||||||
|
float sphereRadius = 1f,
|
||||||
|
Vector3 sphereCentre = default,
|
||||||
|
Vector3 localPosition = default,
|
||||||
|
Quaternion localRotation = default)
|
||||||
|
=> ShadowShape.Bsp(
|
||||||
|
gfxObjId: 0x010046D8u,
|
||||||
|
localPosition: localPosition,
|
||||||
|
localRotation: localRotation == default ? Quaternion.Identity : localRotation,
|
||||||
|
scale: 1f,
|
||||||
|
localGeometry: ShadowPartGeometry.Create(
|
||||||
|
new FlatCollisionSphere(sphereCentre, sphereRadius),
|
||||||
|
new FlatGfxObjVisualBounds(
|
||||||
|
boxMin,
|
||||||
|
boxMax,
|
||||||
|
(boxMin + boxMax) * 0.5f,
|
||||||
|
((boxMax - boxMin) * 0.5f).Length(),
|
||||||
|
(boxMax - boxMin) * 0.5f)));
|
||||||
|
|
||||||
|
/// <summary>The sphere-only configuration the port replaced: box collapses
|
||||||
|
/// to the sphere's own AABB. Used as the in-test control that the fixture
|
||||||
|
/// is non-degenerate.</summary>
|
||||||
|
private static ShadowShape SphereOnlyPart(
|
||||||
|
float sphereRadius,
|
||||||
|
Vector3 sphereCentre = default,
|
||||||
|
Vector3 localPosition = default)
|
||||||
|
=> ShadowShape.Bsp(
|
||||||
|
gfxObjId: 0x010046D8u,
|
||||||
|
localPosition: localPosition,
|
||||||
|
localRotation: Quaternion.Identity,
|
||||||
|
scale: 1f,
|
||||||
|
localGeometry: ShadowPartGeometry.Create(
|
||||||
|
new FlatCollisionSphere(sphereCentre, sphereRadius),
|
||||||
|
null));
|
||||||
|
|
||||||
|
private static List<uint> Rectangle(
|
||||||
|
Vector3 entityWorldPos,
|
||||||
|
uint seedCellId,
|
||||||
|
params ShadowShape[] shapes)
|
||||||
|
{
|
||||||
|
var boxes = shapes
|
||||||
|
.Select(s => ShadowPartBox.FromShape(s, entityWorldPos, Quaternion.Identity))
|
||||||
|
.ToList();
|
||||||
|
var candidates = new CellArray();
|
||||||
|
CellTransit.AddAllOutsideCellsFromParts(
|
||||||
|
boxes, seedCellId, Vector3.Zero, candidates);
|
||||||
|
return candidates.OrderedIds.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── T1 ────────────────────────────────────────────────────────────────
|
||||||
|
/// <summary>
|
||||||
|
/// A 100 m × 100 m box on a 1 m sphere spans five land cells per axis.
|
||||||
|
/// Sabotage: drop the box and flood from the sphere
|
||||||
|
/// (<see cref="SphereOnlyPart"/>) → one cell. The 5-per-axis span is
|
||||||
|
/// unreachable from ANY sphere, of any radius, through the 3×3 path.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void T1_HundredMetreBox_SpansFiveCellsPerAxis()
|
||||||
|
{
|
||||||
|
// Entity centred on cell (1,1): world (36, 36). Box ±50 m → world
|
||||||
|
// -14..86 per axis → floor(-14/24) = -1 .. floor(86/24) = 3, i.e.
|
||||||
|
// block-local cell columns -1..3, five per axis.
|
||||||
|
var shape = BspPart(new Vector3(-50f, -50f, -3f), new Vector3(50f, 50f, 3f));
|
||||||
|
List<uint> cells = Rectangle(new Vector3(36f, 36f, 0f), LbId | 10u, shape);
|
||||||
|
|
||||||
|
var expected = new List<uint>();
|
||||||
|
for (int x = GxBase - 1; x <= GxBase + 3; x++)
|
||||||
|
for (int y = GyBase - 1; y <= GyBase + 3; y++)
|
||||||
|
expected.Add(Cell(x, y));
|
||||||
|
|
||||||
|
Assert.Equal(25, cells.Count);
|
||||||
|
Assert.Equal(expected.OrderBy(v => v), cells.OrderBy(v => v));
|
||||||
|
|
||||||
|
// Control: the same part described only by its 1 m sphere collapses.
|
||||||
|
List<uint> sphereOnly = Rectangle(
|
||||||
|
new Vector3(36f, 36f, 0f), LbId | 10u, SphereOnlyPart(1f));
|
||||||
|
Assert.Single(sphereOnly);
|
||||||
|
Assert.Equal(Cell(GxBase + 1, GyBase + 1), sphereOnly[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── T2 ────────────────────────────────────────────────────────────────
|
||||||
|
/// <summary>
|
||||||
|
/// The rectangle is FILLED and unioned ACROSS PARTS, not per part. Retail
|
||||||
|
/// combines the four DELTA accumulators over every part and calls
|
||||||
|
/// <c>add_cell_block</c> ONCE (<c>0x00533614</c>), so an L-shaped object
|
||||||
|
/// registers in the cells that close its L — cells its geometry never
|
||||||
|
/// enters.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The fixture is an L on purpose: one arm along +X, one along +Y. A
|
||||||
|
/// DIAGONAL fixture cannot detect the per-part sabotage, because retail
|
||||||
|
/// seeds the accumulators to ZERO (<c>0x00533390</c>), so each part's own
|
||||||
|
/// rectangle already spans from the base cell to that part — and for a
|
||||||
|
/// diagonal pair the two per-part rectangles union back to the same square.
|
||||||
|
/// Sabotage: emit one rectangle per part → the corner (3,3) disappears.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void T2_LShapedPartArray_ClaimsTheCornerThatClosesTheL()
|
||||||
|
{
|
||||||
|
var box = (Min: new Vector3(-5f, -5f, -2f), Max: new Vector3(5f, 5f, 2f));
|
||||||
|
var anchor = BspPart(box.Min, box.Max);
|
||||||
|
var eastArm = BspPart(box.Min, box.Max, localPosition: new Vector3(48f, 0f, 0f));
|
||||||
|
var northArm = BspPart(box.Min, box.Max, localPosition: new Vector3(0f, 48f, 0f));
|
||||||
|
|
||||||
|
var entity = new Vector3(36f, 36f, 0f);
|
||||||
|
List<uint> cells = Rectangle(entity, LbId | 10u, anchor, eastArm, northArm);
|
||||||
|
|
||||||
|
uint corner = Cell(GxBase + 3, GyBase + 3);
|
||||||
|
Assert.Contains(corner, cells);
|
||||||
|
Assert.Equal(9, cells.Count);
|
||||||
|
|
||||||
|
// Control: the corner is not reachable from any part's own rectangle,
|
||||||
|
// so the containment above cannot be satisfied by a per-part union.
|
||||||
|
Assert.DoesNotContain(corner, Rectangle(entity, LbId | 10u, anchor));
|
||||||
|
Assert.DoesNotContain(corner, Rectangle(entity, LbId | 10u, anchor, eastArm));
|
||||||
|
Assert.DoesNotContain(corner, Rectangle(entity, LbId | 10u, anchor, northArm));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── T3 ────────────────────────────────────────────────────────────────
|
||||||
|
/// <summary>
|
||||||
|
/// The rectangle crosses landblock boundaries freely: <c>add_cell_block</c>
|
||||||
|
/// works in GLOBAL lcoords and re-derives the block prefix per cell
|
||||||
|
/// (<c>0x0053320a</c>), so cells beyond column 7 carry the NEIGHBOUR
|
||||||
|
/// landblock's id. Sabotage: clamp the rectangle to the seed landblock →
|
||||||
|
/// the 0xAAB4 / 0xA9B5 rows vanish.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void T3_BoxPastTheBlockEdge_ProducesNeighbourLandblockCellIds()
|
||||||
|
{
|
||||||
|
// Entity on cell (7,7): world (180, 180). Box ±30 m → world 150..210
|
||||||
|
// → cell columns 6..8; column 8 is the neighbour block's column 0.
|
||||||
|
var shape = BspPart(new Vector3(-30f, -30f, -2f), new Vector3(30f, 30f, 2f));
|
||||||
|
List<uint> cells = Rectangle(new Vector3(180f, 180f, 0f), LbId | 64u, shape);
|
||||||
|
|
||||||
|
Assert.Equal(9, cells.Count);
|
||||||
|
Assert.Contains(Cell(GxBase + 7, GyBase + 7), cells); // 0xA9B40040
|
||||||
|
Assert.Contains(Cell(GxBase + 8, GyBase + 7), cells); // 0xAAB4xxxx
|
||||||
|
Assert.Contains(Cell(GxBase + 7, GyBase + 8), cells); // 0xA9B5xxxx
|
||||||
|
Assert.Contains(Cell(GxBase + 8, GyBase + 8), cells); // 0xAAB5xxxx
|
||||||
|
|
||||||
|
Assert.Contains(cells, id => (id & 0xFFFF0000u) == 0xAAB40000u);
|
||||||
|
Assert.Contains(cells, id => (id & 0xFFFF0000u) == 0xA9B50000u);
|
||||||
|
Assert.Contains(cells, id => (id & 0xFFFF0000u) == 0xAAB50000u);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── T4 ────────────────────────────────────────────────────────────────
|
||||||
|
/// <summary>
|
||||||
|
/// Map bounds. <c>add_cell_block</c> rejects any coordinate outside
|
||||||
|
/// <c>[0, 0x7f8)</c> (<c>0x005331f0</c>-<c>0x00533206</c>). Sabotage: drop
|
||||||
|
/// the clamp → cells wrap into the far corner of the map or produce id 0.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void T4_RectangleAtTheMapCorners_EmitsNothingOutsideTheMap()
|
||||||
|
{
|
||||||
|
// SW corner: landblock (0,0), entity on cell (0,0), box ±50 m reaches
|
||||||
|
// three cells into negative lcoords on both axes.
|
||||||
|
var box = BspPart(new Vector3(-50f, -50f, -2f), new Vector3(50f, 50f, 2f));
|
||||||
|
List<uint> sw = Rectangle(new Vector3(12f, 12f, 0f), 0x00000001u, box);
|
||||||
|
Assert.All(sw, id => Assert.NotEqual(0u, id));
|
||||||
|
Assert.Equal(9, sw.Count); // x 0..2 × y 0..2 survive
|
||||||
|
Assert.Contains(0x00000001u, sw);
|
||||||
|
|
||||||
|
// NE corner: landblock (254,254) — lcoords 2032..2039, the last legal
|
||||||
|
// row before 0x7f8 = 2040.
|
||||||
|
const uint neLb = 0xFEFE0000u;
|
||||||
|
int neGx = 254 * 8, neGy = 254 * 8;
|
||||||
|
List<uint> ne = Rectangle(new Vector3(180f, 180f, 0f), neLb | 64u, box);
|
||||||
|
Assert.All(ne, id => Assert.NotEqual(0u, id));
|
||||||
|
Assert.Equal(9, ne.Count); // x 2035..2037+ y likewise
|
||||||
|
Assert.Contains(Cell(neGx + 7, neGy + 7), ne);
|
||||||
|
Assert.DoesNotContain(Cell(2040 & 0x7FF, 2040 & 0x7FF), ne);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── T5 ────────────────────────────────────────────────────────────────
|
||||||
|
/// <summary>
|
||||||
|
/// <c>BBox::LocalToGlobal</c> @0x005b2120 re-fits through ALL EIGHT
|
||||||
|
/// corners, so a rotated box grows. Sabotage: transform only
|
||||||
|
/// <c>min</c> and <c>max</c> → the −X overhang of a yawed asymmetric box
|
||||||
|
/// is lost and its westernmost cell disappears.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void T5_RotatedAsymmetricBox_KeepsTheCornerOverhangMinMaxWouldLose()
|
||||||
|
{
|
||||||
|
// Asymmetric box: X 0..60, Y 0..4. Yawed 37 degrees about Z the four
|
||||||
|
// XY corners land at (0,0), (47.92,36.11), (-2.41,3.19), (45.52,39.30);
|
||||||
|
// the true AABB therefore starts at x = -2.41, which is the corner a
|
||||||
|
// min/max-only transform (which sees only (0,0) and (45.52,39.30))
|
||||||
|
// cannot produce.
|
||||||
|
Quaternion yaw37 = Quaternion.CreateFromAxisAngle(
|
||||||
|
Vector3.UnitZ, 37f * MathF.PI / 180f);
|
||||||
|
var shape = BspPart(
|
||||||
|
new Vector3(0f, 0f, 0f), new Vector3(60f, 4f, 2f),
|
||||||
|
localRotation: yaw37);
|
||||||
|
|
||||||
|
// Entity at world x = 48 → the true box spans 45.59..95.92, crossing
|
||||||
|
// into cell column 1; the min/max-only box starts at exactly 48.0,
|
||||||
|
// which is column 2.
|
||||||
|
List<uint> cells = Rectangle(new Vector3(48f, 12f, 0f), LbId | 17u, shape);
|
||||||
|
|
||||||
|
Assert.Contains(Cell(GxBase + 1, GyBase + 0), cells);
|
||||||
|
Assert.Contains(Cell(GxBase + 3, GyBase + 2), cells);
|
||||||
|
|
||||||
|
// Control: unrotated, the same box starts at exactly x = 48 and never
|
||||||
|
// reaches column 1 — so the containment above is the rotation's doing.
|
||||||
|
var unrotated = BspPart(new Vector3(0f, 0f, 0f), new Vector3(60f, 4f, 2f));
|
||||||
|
List<uint> flat = Rectangle(new Vector3(48f, 12f, 0f), LbId | 17u, unrotated);
|
||||||
|
Assert.DoesNotContain(Cell(GxBase + 1, GyBase + 0), flat);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── T9 ────────────────────────────────────────────────────────────────
|
||||||
|
/// <summary>
|
||||||
|
/// <c>floor</c>, not truncation: retail calls <c>floor</c> then
|
||||||
|
/// <c>_ftol2</c> (<c>0x0053353c</c> / <c>0x00533542</c>). Sabotage:
|
||||||
|
/// <c>(int)(v / 24f)</c> → for a box overhanging the block's SW corner,
|
||||||
|
/// <c>-8/24</c> truncates to 0 and the previous landblock's column 7 is
|
||||||
|
/// silently dropped.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void T9_BoxOverhangingTheBlockOrigin_ReachesTheNegativeColumn()
|
||||||
|
{
|
||||||
|
var shape = BspPart(new Vector3(-20f, -20f, -2f), new Vector3(20f, 20f, 2f));
|
||||||
|
// Entity at world (12, 12): the box spans -8..32, whose floor is -1.
|
||||||
|
List<uint> cells = Rectangle(new Vector3(12f, 12f, 0f), LbId | 1u, shape);
|
||||||
|
|
||||||
|
Assert.Contains(Cell(GxBase - 1, GyBase - 1), cells); // 0xA8B3, cell 64
|
||||||
|
Assert.Contains(Cell(GxBase - 1, GyBase + 0), cells);
|
||||||
|
Assert.Contains(Cell(GxBase + 0, GyBase - 1), cells);
|
||||||
|
// -8..32 → floor gives columns -1..1, three per axis.
|
||||||
|
Assert.Equal(9, cells.Count);
|
||||||
|
Assert.Contains(Cell(GxBase + 1, GyBase + 1), cells);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── T8 ────────────────────────────────────────────────────────────────
|
||||||
|
/// <summary>
|
||||||
|
/// <c>adjust_to_outside</c> failing (map edge / invalid id) makes retail
|
||||||
|
/// return before <c>get_landcell</c> and add nothing
|
||||||
|
/// (<c>0x005333eb</c> select → gid 0 → <c>0x00533417 je</c>). Sabotage:
|
||||||
|
/// drop the null check → an exception or a bogus rectangle at lcoord 0.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void T8_BasePositionOffTheMap_AddsNothingAndDoesNotThrow()
|
||||||
|
{
|
||||||
|
var shape = BspPart(new Vector3(-5f, -5f, -2f), new Vector3(5f, 5f, 2f));
|
||||||
|
var boxes = new List<ShadowPartBox>
|
||||||
|
{
|
||||||
|
ShadowPartBox.FromShape(
|
||||||
|
shape, new Vector3(-100000f, -100000f, 0f), Quaternion.Identity),
|
||||||
|
};
|
||||||
|
var candidates = new CellArray();
|
||||||
|
|
||||||
|
bool added = CellTransit.AddAllOutsideCellsFromParts(
|
||||||
|
boxes, 0x00000001u, Vector3.Zero, candidates);
|
||||||
|
|
||||||
|
Assert.False(added);
|
||||||
|
Assert.Empty(candidates.OrderedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── P2 / T6 ───────────────────────────────────────────────────────────
|
||||||
|
/// <summary>
|
||||||
|
/// Non-BSP invariance. A cylinder-only owner must still take retail's
|
||||||
|
/// cylsphere branch — <c>CObjCell::find_cell_list</c> @0x0052b9f0 — and
|
||||||
|
/// produce exactly the sphere flood's cell set. Sabotage: route every
|
||||||
|
/// owner through the box path → the sets diverge (the cylinder's box is
|
||||||
|
/// its own ±radius extent, which spans a different rectangle).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void T6_CylinderOnlyOwner_MatchesTheUntouchedSphereFlood()
|
||||||
|
{
|
||||||
|
var cylinder = ShadowShape.Cylinder(
|
||||||
|
gfxObjId: 0u,
|
||||||
|
localPosition: Vector3.Zero,
|
||||||
|
localRotation: Quaternion.Identity,
|
||||||
|
scale: 1f,
|
||||||
|
// r = 12 at the exact centre of cell (1,1) is the configuration in
|
||||||
|
// which the two routes DISAGREE: check_add_cell_boundary's tests
|
||||||
|
// are STRICT (pointX > 24-r, pointX < r), so 12 > 12 and 12 < 12
|
||||||
|
// both fail and the sphere claims exactly one cell — while the
|
||||||
|
// same extent as a BOX spans 24..48, whose floor is columns 1 AND
|
||||||
|
// 2. A fixture at any other radius makes the routes agree and
|
||||||
|
// proves nothing.
|
||||||
|
radius: 12f,
|
||||||
|
cylHeight: 24f);
|
||||||
|
|
||||||
|
var reg = new ShadowObjectRegistry();
|
||||||
|
const uint ownerId = 0x334001u;
|
||||||
|
var worldPos = new Vector3(36f, 36f, 50f);
|
||||||
|
reg.RegisterMultiPart(
|
||||||
|
ownerId, worldPos, Quaternion.Identity,
|
||||||
|
new[] { cylinder }, 0u, EntityCollisionFlags.None,
|
||||||
|
0f, 0f, LbId);
|
||||||
|
|
||||||
|
IReadOnlyList<uint> expected = CellTransit.BuildShadowCellSet(
|
||||||
|
new PhysicsDataCache(),
|
||||||
|
LbId | 10u,
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new DatReaderWriter.Types.Sphere { Origin = worldPos, Radius = 12f },
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
isStatic: false);
|
||||||
|
|
||||||
|
// Control: the golden must be the SINGLE cell only the sphere route
|
||||||
|
// produces, so the equality below cannot be satisfied by the box route.
|
||||||
|
Assert.Equal(new[] { LbId | 10u }, expected);
|
||||||
|
|
||||||
|
var actual = new List<uint>();
|
||||||
|
foreach (uint id in expected)
|
||||||
|
{
|
||||||
|
if (reg.GetObjectsInCell(id).Any(e => e.EntityId == ownerId))
|
||||||
|
actual.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.NotEmpty(expected);
|
||||||
|
Assert.Equal(expected.OrderBy(v => v), actual.OrderBy(v => v));
|
||||||
|
|
||||||
|
// And nothing outside it: the cylinder claims no cell the sphere
|
||||||
|
// flood did not.
|
||||||
|
for (uint index = 1u; index <= 64u; index++)
|
||||||
|
{
|
||||||
|
uint cellId = LbId | index;
|
||||||
|
bool held = reg.GetObjectsInCell(cellId).Any(e => e.EntityId == ownerId);
|
||||||
|
Assert.Equal(expected.Contains(cellId), held);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── P1 / dispatch ─────────────────────────────────────────────────────
|
||||||
|
/// <summary>
|
||||||
|
/// The dispatch itself: a BSP-bearing owner registered through
|
||||||
|
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> lands in EVERY cell
|
||||||
|
/// of its box rectangle — not the nine of the sphere neighbourhood. This
|
||||||
|
/// is the end-to-end #334 fact at the production entry point.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void RegisterMultiPart_BspBearingOwner_OccupiesTheFullBoxRectangle()
|
||||||
|
{
|
||||||
|
var shape = BspPart(new Vector3(-50f, -50f, -3f), new Vector3(50f, 50f, 3f));
|
||||||
|
var reg = new ShadowObjectRegistry();
|
||||||
|
const uint ownerId = 0x334002u;
|
||||||
|
|
||||||
|
reg.RegisterMultiPart(
|
||||||
|
ownerId, new Vector3(36f, 36f, 0f), Quaternion.Identity,
|
||||||
|
new[] { shape }, 0u, EntityCollisionFlags.None,
|
||||||
|
0f, 0f, LbId, seedCellId: LbId | 10u);
|
||||||
|
|
||||||
|
int held = 0;
|
||||||
|
for (int x = GxBase - 1; x <= GxBase + 3; x++)
|
||||||
|
for (int y = GyBase - 1; y <= GyBase + 3; y++)
|
||||||
|
{
|
||||||
|
uint cellId = Cell(x, y);
|
||||||
|
Assert.Contains(
|
||||||
|
reg.GetObjectsInCell(cellId),
|
||||||
|
e => e.EntityId == ownerId);
|
||||||
|
held++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(25, held);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,19 +26,19 @@ public class ShadowObjectRegistryMultiPartTests
|
||||||
localPosition: Vector3.Zero,
|
localPosition: Vector3.Zero,
|
||||||
localRotation: Quaternion.Identity,
|
localRotation: Quaternion.Identity,
|
||||||
scale: 1.0f,
|
scale: 1.0f,
|
||||||
localBounds: new FlatCollisionSphere(Vector3.Zero, 2.0f)),
|
localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 2.0f), null)),
|
||||||
ShadowShape.Bsp(
|
ShadowShape.Bsp(
|
||||||
gfxObjId: 0x010044B6u,
|
gfxObjId: 0x010044B6u,
|
||||||
localPosition: Vector3.Zero,
|
localPosition: Vector3.Zero,
|
||||||
localRotation: Quaternion.Identity,
|
localRotation: Quaternion.Identity,
|
||||||
scale: 1.0f,
|
scale: 1.0f,
|
||||||
localBounds: new FlatCollisionSphere(Vector3.Zero, 2.0f)),
|
localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 2.0f), null)),
|
||||||
ShadowShape.Bsp(
|
ShadowShape.Bsp(
|
||||||
gfxObjId: 0x010044B6u,
|
gfxObjId: 0x010044B6u,
|
||||||
localPosition: Vector3.Zero,
|
localPosition: Vector3.Zero,
|
||||||
localRotation: Quaternion.Identity,
|
localRotation: Quaternion.Identity,
|
||||||
scale: 1.0f,
|
scale: 1.0f,
|
||||||
localBounds: new FlatCollisionSphere(Vector3.Zero, 2.0f))
|
localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 2.0f), null))
|
||||||
};
|
};
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -260,9 +260,11 @@ public class ShadowObjectRegistryMultiPartTests
|
||||||
localPosition: localPosition,
|
localPosition: localPosition,
|
||||||
localRotation: localRotation == default ? Quaternion.Identity : localRotation,
|
localRotation: localRotation == default ? Quaternion.Identity : localRotation,
|
||||||
scale: 1f,
|
scale: 1f,
|
||||||
localBounds: new FlatCollisionSphere(
|
localGeometry: ShadowPartGeometry.Create(
|
||||||
boundsCenter == default ? new Vector3(0f, 6f, 0f) : boundsCenter,
|
new FlatCollisionSphere(
|
||||||
radius));
|
boundsCenter == default ? new Vector3(0f, 6f, 0f) : boundsCenter,
|
||||||
|
radius),
|
||||||
|
null));
|
||||||
|
|
||||||
private static List<uint> FloodCellsFor(params ShadowShape[] shapes)
|
private static List<uint> FloodCellsFor(params ShadowShape[] shapes)
|
||||||
{
|
{
|
||||||
|
|
@ -444,7 +446,9 @@ public class ShadowObjectRegistryMultiPartTests
|
||||||
setup,
|
setup,
|
||||||
entScale: 1f,
|
entScale: 1f,
|
||||||
hasPhysicsBsp: id => id == part,
|
hasPhysicsBsp: id => id == part,
|
||||||
physicsBspBounds: id => id == part ? bounds : null);
|
physicsBspBounds: id => id == part
|
||||||
|
? ShadowPartGeometry.Create(bounds, null)
|
||||||
|
: (ShadowPartGeometry?)null);
|
||||||
|
|
||||||
ShadowShape only = Assert.Single(shapes);
|
ShadowShape only = Assert.Single(shapes);
|
||||||
Assert.Equal(ShadowCollisionType.BSP, only.CollisionType);
|
Assert.Equal(ShadowCollisionType.BSP, only.CollisionType);
|
||||||
|
|
|
||||||
|
|
@ -973,7 +973,7 @@ public class ShadowObjectRegistryTests
|
||||||
Vector3.Zero,
|
Vector3.Zero,
|
||||||
Quaternion.Identity,
|
Quaternion.Identity,
|
||||||
scale: 1f,
|
scale: 1f,
|
||||||
localBounds: new FlatCollisionSphere(Vector3.Zero, radius));
|
localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, radius), null));
|
||||||
|
|
||||||
private static CellPhysics BuildShadowCellSetTests_MakeLeafCell(Matrix4x4 worldTransform)
|
private static CellPhysics BuildShadowCellSetTests_MakeLeafCell(Matrix4x4 worldTransform)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -467,5 +467,5 @@ public sealed class ShadowSetPositionCommitTests
|
||||||
local,
|
local,
|
||||||
Quaternion.Identity,
|
Quaternion.Identity,
|
||||||
scale: 1f,
|
scale: 1f,
|
||||||
localBounds: new FlatCollisionSphere(Vector3.Zero, 0.25f));
|
localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 0.25f), null));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue