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.
|
||||
- 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
|
||||
|
||||
**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
|
||||
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`
|
||||
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
|
||||
|
|
@ -10556,7 +10603,19 @@ missing is the plugin-API surface.
|
|||
|
||||
## #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.
|
||||
**Filed:** 2026-08-06, user-reported in live play.
|
||||
**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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue