merge: #333/#334 Neftet collision — the query-site broadphase is deleted
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Closes #333, #334 and #337. The Neftet plateau defect — wedged at the
top, jumps sinking into the mesh, corpses falling through — was one
mechanism presenting as three symptoms: Transition.FindObjCollisionsInCell
measured the mover's distance to a shadow entry's part ORIGIN and compared
it against the BSP ROOT BOUNDING SPHERE's radius, two points 23.556 m apart
for that rock. Geometry deep inside the real sphere was discarded before
BSPQuery ever ran.
Deleted rather than re-centred: retail has no distance pre-filter at all,
disassembled from the PDB-paired binary rather than read from Binary Ninja.
Cell membership is retail's broad phase.
Also lands #334's outdoor extent walk (retail's find_bbox_cell_list),
retires AP-158, files #338, and opens Campaign S for the twelve remaining
collision-domain items — now the active work ahead of the M4 vendor
slices per user direction that physics and collision come first.
The ACDREAM_PROBE_SUPPORT / ACDREAM_WIRE_MESH / ACDREAM_PROBE_REACH probe
families are deliberately retained: C5c's connected gates still owe the
instrumentation they provide.
Gate: clean-room full solution suite at a0690947 with all 43 bin/obj
directories deleted first — 11,231 passed / 4 skipped / 0 failed across
all nine projects. User accepted the fix in live play at Neftet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
0ce54a5c4a
32 changed files with 5937 additions and 160 deletions
23
CLAUDE.md
23
CLAUDE.md
|
|
@ -1475,6 +1475,29 @@ via `PlayerMovementController.ApplyServerRunRate`) or from
|
|||
delta), `[sticky-snap-skip]` at the suppressed NPC UP-snap site.
|
||||
Heavy while a pack is stuck (~60 Hz × stuck count). Converged the
|
||||
#171 residuals (the deep-overlap sign pin AP-82).
|
||||
- `ACDREAM_PROBE_SUPPORT=1` — **what is holding a body up, and is the
|
||||
collision geometry where the visual geometry is?** (#337, TEMPORARY).
|
||||
`[support]`: one line per resolve **for every body, not just the player**
|
||||
(a corpse falling through geometry is the cheapest control there is on
|
||||
"movement code vs geometry data"). It samples the outdoor terrain
|
||||
independently at the body's own out-XY and prints the contact plane's own
|
||||
height at that same XY, so `support=terrain` / `object` / `none` is a
|
||||
measurement rather than an inference; `cpSrc=` names the site that wrote
|
||||
the plane so provenance cross-checks the classification. Edge-eager,
|
||||
throttled to 4 Hz per body, and emits every 10 cm of vertical movement.
|
||||
`[geom]`: once per GfxObj near the mover — the object's physics-BSP vertex
|
||||
cloud against its visual mesh AABB in the same frame, with a verdict
|
||||
(`coincident` REFUTES "collision isn't where the visual is";
|
||||
`no-physics-bsp` / `empty-physics-bsp` / `displaced` / `extent-mismatch`
|
||||
each name a data defect). `ACDREAM_PROBE_RESOLVE` alone cannot separate
|
||||
those cases — it carries no plane normal, no plane height, no terrain
|
||||
sample and no provenance.
|
||||
- `ACDREAM_WIRE_MESH=1` — upgrades the existing **F2** collision overlay from
|
||||
a broadphase proxy cylinder to the real physics-BSP polygon edges (cyan)
|
||||
beside the same objects' visual mesh boxes (magenta) and the terrain
|
||||
surface (yellow). Settles "visual versus collision" by eye instead of by
|
||||
log. `ACDREAM_WIRE_RADIUS=<metres>` sets the window (default 30).
|
||||
TEMPORARY, with the #337 probe family.
|
||||
- `ACDREAM_CAPTURE_RESOLVE=<path>` — live capture of every player-side
|
||||
`PhysicsEngine.ResolveWithTransition` call. Each call appends one
|
||||
JSON Lines record with full inputs, PhysicsBody snapshot before AND
|
||||
|
|
|
|||
313
docs/ISSUES.md
313
docs/ISSUES.md
|
|
@ -24,10 +24,223 @@ 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.
|
||||
|
||||
## #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
|
||||
## #338 — The player resolves with stepUp/stepDown 0.400 where Setup 0x02000001 authors 0.600 / 1.500
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** high for the tall-prop population. It is the gate immediately
|
||||
**Severity:** unknown until measured, plausibly medium. A 1.5 m step-down is
|
||||
what keeps a mover attached to a descending slope; 0.4 m is not, so this is a
|
||||
candidate contributor to descent/edge feel — but that link is NOT established
|
||||
and must not be assumed.
|
||||
**Filed:** 2026-08-06, spotted in the #337 `[support]` capture while chasing a
|
||||
different defect. Deliberately not chased there: it does not cause the Neftet
|
||||
wedge, and folding it in would have made that fix unfalsifiable.
|
||||
**Component:** physics / movement.
|
||||
|
||||
### The observation
|
||||
|
||||
The human Setup `0x02000001` authors `StepUpHeight = 0.600` and
|
||||
`StepDownHeight = 1.500`. The live `[support]` probe lines show the player
|
||||
resolving with `stepUp=0.400 stepDown=0.400`.
|
||||
|
||||
### What is NOT yet established
|
||||
|
||||
- Whether the authored Setup values are what retail feeds `CPhysicsObj`, or
|
||||
whether retail also substitutes constants at this seam. **Grep
|
||||
`named-retail` for `get_stepup_height` / `get_stepdown_height` and their
|
||||
callers before touching anything** — the authored DAT field being ignored is
|
||||
only a defect if retail reads it.
|
||||
- Where the 0.400 comes from: a hardcoded default, a stance/posture-dependent
|
||||
value, or a value that never got wired from the Setup.
|
||||
- Whether it has any observable consequence. Do not open this by reasoning
|
||||
from the source; the #337 lineage already burned two diagnoses that way.
|
||||
|
||||
### Related
|
||||
|
||||
Sits next to #32 (local-player cliff edge-slide), which has its own research
|
||||
at `38db9fff` and needs a live `ACDREAM_DUMP_EDGE_SLIDE=1` capture before a
|
||||
fix. If both turn out to touch descent feel, do NOT bundle them — they have
|
||||
different mechanisms and need separate gates.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## #337 — Neftet rock plateaus: wedged at the top, jumps sink into the mesh, corpses fall through — FIXED, awaiting live acceptance
|
||||
|
||||
**Status:** FIXED 2026-08-06 by #333's fix — the query-site broadphase reach
|
||||
filter is **deleted**, because retail has none. Awaiting the user's live
|
||||
acceptance at the Neftet plateau; the offline gate is
|
||||
`Issue333BroadphaseReachFilterTests.OffCentreBspFloorStopsAFallingMover`,
|
||||
sabotage-verified (restore the filter and it falls straight through to the
|
||||
unobstructed height while the centred control keeps passing).
|
||||
|
||||
The mechanism, proven offline 2026-08-06, is **#333**: the per-object broadphase in
|
||||
`Transition.FindObjCollisionsInCell` measures to the shadow entry's part
|
||||
ORIGIN and compares against the BSP ROOT BOUNDING SPHERE's radius. Those are
|
||||
23.6 m apart for `0xC8766009` / `gfx=0x01004751`, so a mover on the plateau is
|
||||
rejected before the query it would have passed. Retail has no such filter
|
||||
(`CPartArray::FindObjCollisions` @0x00518180 and
|
||||
`CPhysicsPart::find_obj_collisions` @0x0050d8d0 verified instruction-by-
|
||||
instruction on the PDB-paired binary). `0xC8766002`, the owner with 11,014
|
||||
`tested-ok` and zero hits, is **innocent** — its geometry is 22.8 m away.
|
||||
Full evidence + the proposed fix:
|
||||
[`docs/research/2026-08-06-337-neftet-wedge-mechanism.md`](research/2026-08-06-337-neftet-wedge-mechanism.md).
|
||||
Reproducer + offline replay:
|
||||
`tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs`.
|
||||
Its installed-DAT evidence row is
|
||||
`TheOldBroadphaseMeasuredToTheOriginAndSoRejectedGeometryItStoodOn`, which pins
|
||||
BOTH halves of the diagnosis for this rock — origin-measured distance outside
|
||||
the old budget, centre-measured distance comfortably inside the same radius.
|
||||
The production gate is the separate DAT-free
|
||||
`Issue333BroadphaseReachFilterTests`.
|
||||
|
||||
**Historical framing below is superseded by that document** — in particular
|
||||
"the mesh never collides" was true only of the innocent neighbour, and both
|
||||
the wrong-transform and BSP-traversal-hole hypotheses are refuted by
|
||||
measurement.
|
||||
**Severity:** HIGH — walk-through, fall-through, and a hard movement stop on world geometry.
|
||||
**Filed:** 2026-08-06, user-reported in live play after #334's fix landed.
|
||||
**Component:** physics / collision — possibly geometry data rather than movement code.
|
||||
|
||||
### Symptoms, all from the user in live play
|
||||
|
||||
1. Walks **up** a rock face onto a plateau fine, then **cannot pass at the top** — wedged, position frozen.
|
||||
2. Jumping is **"swallowed half way by the rock"** — the body sinks into the visual geometry.
|
||||
3. **A monster corpse falls straight through the rock.**
|
||||
|
||||
Symptom 3 is the load-bearing one. A corpse is a plain physics body with no
|
||||
player-specific movement logic, so a fall-through there cannot be explained by
|
||||
anything in the player's controller.
|
||||
|
||||
### What is already RULED OUT
|
||||
|
||||
`ACDREAM_PROBE_REACH` (`334-fix-gate.log`, and the earlier
|
||||
`334-neftet-probe.log`). At the frozen position: `blocked=0`, and **every**
|
||||
candidate returns `tested-ok` — including the landblock's own rock mesh
|
||||
`gfx=0x010046DE` in cell `0x8766002B`. **No object is blocking the player.**
|
||||
That probe can only see shadow objects, so it has ruled out its own domain and
|
||||
can say nothing about terrain or the transition.
|
||||
|
||||
Two diagnoses have already been refuted by measurement on this defect's
|
||||
lineage: the broadphase reach filter (#333/AP-158) and the edge-slide family.
|
||||
Do not open a third by reasoning from the source.
|
||||
|
||||
### The remaining candidates — and what is NOT yet established
|
||||
|
||||
- **(a) terrain** is what supports/blocks the body (walkable slope limit,
|
||||
step-up refusal, terrain Z).
|
||||
- **(b)** a **collision mesh placed somewhere other than its visual**, so the
|
||||
body interacts with geometry that is not where the rock is drawn.
|
||||
- **(c)** the **transition wedging** despite an unobstructed path.
|
||||
|
||||
(b) is the current working hypothesis and is **NOT ESTABLISHED**. It is
|
||||
plausible — a corpse falling through and a jump sinking in are both what
|
||||
absent-or-displaced collision looks like — but no measurement supports it yet,
|
||||
and the instruments below were built to REFUTE it, not to confirm it.
|
||||
|
||||
Possibly relevant, possibly coincidence: landblock `0x8766` carries the
|
||||
**largest single collision owner in the game**, an 81-cell (9×9) footprint
|
||||
measured during #334 — larger than anything else by a wide margin.
|
||||
|
||||
### Instruments (2026-08-06 — TEMPORARY, strip with the physics-probe family)
|
||||
|
||||
`ACDREAM_PROBE_RESOLVE` alone does **not** separate (a), (b) and (c): it prints
|
||||
a three-value contact-plane token, no plane normal, no plane height, no terrain
|
||||
sample and no plane provenance, so all three candidates produce the same line.
|
||||
Two additions close that:
|
||||
|
||||
- **`ACDREAM_PROBE_SUPPORT=1`** → `[support]` + `[geom]`.
|
||||
- `[support]`, one per resolve **per body** (players AND corpses): samples the
|
||||
outdoor terrain independently at the body's own out-XY and prints the
|
||||
contact plane's own height at that same XY. `support=terrain` /
|
||||
`support=object` / `support=none` is then a measurement, not an inference,
|
||||
and `cpSrc=` names the code site that wrote the plane so provenance and
|
||||
classification cross-check each other.
|
||||
- `[geom]`, once per GfxObj that comes near the mover: compares the object's
|
||||
physics-BSP vertex cloud against its visual mesh AABB in the same local
|
||||
frame. `verdict=coincident` **refutes (b)** for that object outright;
|
||||
`no-physics-bsp` / `empty-physics-bsp` / `displaced` / `extent-mismatch`
|
||||
each name a specific data defect.
|
||||
- **`ACDREAM_WIRE_MESH=1`** upgrades the existing F2 collision overlay from a
|
||||
broadphase proxy cylinder to the objects' real physics-BSP polygon edges
|
||||
(cyan) beside their visual mesh boxes (magenta) and the terrain surface
|
||||
(yellow). Settles "visual versus collision" by eye.
|
||||
|
||||
### How to read the capture
|
||||
|
||||
| Observation | What it means |
|
||||
|---|---|
|
||||
| `[geom] verdict=no-physics-bsp` or `empty-physics-bsp` on the rock | The rock has **no collision geometry**. All three symptoms follow; nothing on the movement side needs explaining. |
|
||||
| `[geom] verdict=displaced` | **(b) confirmed.** Fix the placement/registration transform. |
|
||||
| `[geom] verdict=coincident` on every nearby object | **(b) refuted.** The cause is (a) or (c); read `[support]`. |
|
||||
| `[support] support=terrain` while standing on the visible plateau | (a): terrain, not the rock, is the support — terrain Z near the plateau top is the thing to look at. |
|
||||
| `[support] support=object` with `cpAboveTerr` ≈ the plateau height | The rock IS supporting the body; the wedge is (c). |
|
||||
| `[support] stalled=true ok=true` with `cpWalkable=true` | (c): the transition accepts the move and advances nothing. |
|
||||
| `[support] support=none` on the corpse throughout its fall | Nothing ever contacts it — consistent with absent collision, and `[geom]` says whose. |
|
||||
| **No `[support]` line at all** for the corpse's guid while it visibly falls | The client is not simulating that body — the descent is server-driven or presentational, and the client-side collision path is not the place to look. An absence here is a real answer, not a gap in the capture. |
|
||||
| `[support] cpWalkable=false` at the freeze | Slope-limit refusal — compare `cpNz` against `floorZ` on the same line. |
|
||||
|
||||
## #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:** FIXED 2026-08-06 — **the filter is deleted, not re-centred.**
|
||||
Re-centring it would have kept an invention retail does not have; the
|
||||
disassembly below establishes that retail walks the cell's shadow list
|
||||
unconditionally. Cell membership IS retail's broad phase, and the BSP walk's
|
||||
own root-node bounding-sphere test — correctly centred, which is exactly what
|
||||
this filter was not — is the early-out that made a second one unnecessary.
|
||||
**AP-158 is retired** by the same commit.
|
||||
|
||||
This closes **#337** (the Neftet plateau: wedged at the top, jumps sink in,
|
||||
corpses fall through), whose mechanism this is.
|
||||
|
||||
**Gates.** `Issue333BroadphaseReachFilterTests` drives the production path
|
||||
end-to-end (`ResolveWithTransition` → `FindObjCollisionsInCell` →
|
||||
`CollisionTraversal`) on a DAT-free fixture so it runs everywhere, and is
|
||||
sabotage-verified as a discriminating pair: restore the `maxReach` pre-check
|
||||
and `OffCentreBspFloorStopsAFallingMover` falls straight through to the
|
||||
unobstructed 37.800 while `CentredBspFloorStopsAFallingMover` keeps passing —
|
||||
so it cannot pass for the trivial reason that the fixture is unable to fall.
|
||||
|
||||
**Perf, measured rather than assumed.** Deleting a filter costs whatever the
|
||||
candidates it used to reject now cost. Measured in Release on a synthetic
|
||||
all-BSP cell, per `ResolveWithTransition`:
|
||||
|
||||
| candidates in cell | with filter | without | delta |
|
||||
|---|---|---|---|
|
||||
| 38 (the live max) | 10.61 µs | 16.68 µs | +6.07 µs (1.57×) |
|
||||
| 200 (5× worse than anything observed) | 17.34 µs | 39.48 µs | +22.1 µs (2.28×) |
|
||||
|
||||
≈0.16 µs per additional candidate actually tested. The live population is the
|
||||
bound that matters: over 19,701 `[reach-q]` samples in the Neftet and outdoor
|
||||
captures (`334-fix-gate.log`, `334-neftet-probe.log`, `334-neftet.log`) the
|
||||
in-cell candidate count is **p50 = 9, p99 = 32, max 38**. The 200-object row is
|
||||
included only to show the curve is linear, not to suggest it is reachable.
|
||||
|
||||
**Severity (when open):** high for the tall-prop population. It was the gate immediately
|
||||
downstream of the AP-156 membership fix, so that fix alone may not be enough to
|
||||
make the worst objects block.
|
||||
**Filed:** 2026-08-06 at the AP-156 fix (commit `b52967de`), which surfaced it.
|
||||
|
|
@ -69,7 +282,12 @@ offset above the filter's roughly 2.5 m walking budget, and **46** above 5 m. At
|
|||
a test scale of 1.75 those offsets become 4.4 m and 8.75 m against an unchanged
|
||||
budget.
|
||||
|
||||
### Consequence for the AP-156 connected gate — read this before running it
|
||||
### Consequence for the AP-156 connected gate — SUPERSEDED by the fix above
|
||||
|
||||
*The caveat below applied while this issue was open. It no longer holds: the
|
||||
filter is gone, so AP-156's connected gate is now expected to show its benefit
|
||||
on tall props, and a null result there IS evidence against AP-156. Retained for
|
||||
the record.*
|
||||
|
||||
**Tall props may show NO VISIBLE CHANGE at all until this issue is fixed, and a
|
||||
null result there is EXPECTED rather than evidence against AP-156.** AP-156 puts
|
||||
|
|
@ -1741,6 +1959,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 +10796,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
|
||||
|
|
@ -10575,7 +10827,58 @@ did **not** resolve it either, which is itself a diagnostic clue (see below).
|
|||
formations, and no floor above it — jumping over lands you inside/through.
|
||||
"Solid near the middle, absent at the edges" is the shape to reason from.
|
||||
|
||||
### Leading candidate — AP-158 / #333, the broadphase reach filter
|
||||
### MEASURED IN GAME 2026-08-06 — cause found, and it is NOT the reach filter
|
||||
|
||||
**The reach-filter theory is REFUTED.** A live probe (`ACDREAM_PROBE_REACH`,
|
||||
commit `b61f5fd4`) was run with the user standing in front of, and then inside,
|
||||
a Neftet formation. Evidence: `334-neftet-probe.log`, 8,401 lines.
|
||||
|
||||
Standing inside the formation the collision system reported:
|
||||
|
||||
```
|
||||
[reach-q] cell=0x87640019 inCell=2 exempt=2 reached=0 rejectedReach=0 tested=0 blocked=0
|
||||
```
|
||||
|
||||
**Two candidates, both the player's own body spheres.** Nothing was rejected
|
||||
because there was nothing to reject — the formation is not in the collision
|
||||
candidate set at all. `rejectedReach=0` kills AP-158 as the cause here.
|
||||
|
||||
**The blocking part the user found is the discriminator.** One object does
|
||||
collide: `gfx=0x010046D8`, a BSP with `objR=69.471` — the landblock's baked
|
||||
rock geometry, one object spanning a large area. Its coverage:
|
||||
|
||||
| Cell | Rock object present? |
|
||||
|---|---|
|
||||
| `0x8764000A`, `0x87640012` | **YES** — `tested-collided` / `tested-slid`, player blocked |
|
||||
| `0x87640011`, `0x87640019`, `0x87630018` | **NO** — `inCell=2`, both entries the player |
|
||||
|
||||
**Same object, same landblock, present in some cells and absent from directly
|
||||
adjacent ones.** `0x87640012` is cell index 17 → grid (2,1); `0x87640011` is
|
||||
index 16 → grid (2,0), immediately beside it and empty.
|
||||
|
||||
### Cause: a landblock-spanning object is registered by a single bounding sphere
|
||||
|
||||
`ShadowObjectRegistry.BuildFloodSpheres` derives cell membership from one
|
||||
sphere per part. This object's own radius is **69.471 m** while a landblock is
|
||||
**192 m** across, so a single sphere cannot geometrically reach every cell the
|
||||
mesh occupies. Cells beyond its reach receive no registration and the player
|
||||
walks through.
|
||||
|
||||
Retail does not use a sphere here. `CPhysicsObj::calc_cross_cells` @`0x00515230`
|
||||
routes BSP-bearing objects to `find_bbox_cell_list` @`0x00510fc0` →
|
||||
`calc_cross_cells_static` @`0x00518160`, i.e. a walk over the object's
|
||||
**extent**, not a single enclosing sphere.
|
||||
|
||||
**Relationship to AP-156 (`b52967de`), which did not fix this and was not
|
||||
expected to:** AP-156 moved the flood sphere to the right *place* (it was
|
||||
centred on the part origin while sized from the geometry). That was necessary
|
||||
and is confirmed correct — but the sphere is also the wrong *shape* for objects
|
||||
whose extent exceeds their own radius. AP-156 fixed position; this issue is
|
||||
about coverage. Sequential, not alternative.
|
||||
|
||||
### Superseded — the original leading candidate, retained for provenance
|
||||
|
||||
### Superseded detail — AP-158 / #333, the broadphase reach filter
|
||||
|
||||
`TransitionTypes.cs:3898`-ish measures `currPos - obj.Position` (the part
|
||||
**origin**) against `obj.Radius`, admitting a contact only when the geometry
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
247
docs/plans/2026-08-06-collision-fidelity-campaign.md
Normal file
247
docs/plans/2026-08-06-collision-fidelity-campaign.md
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
# Campaign S — collision shape & response fidelity
|
||||
|
||||
**Opened:** 2026-08-06, immediately after #333/#337 closed (`ea83b043`).
|
||||
**Status:** PLANNED, not started.
|
||||
**Scope:** the twelve remaining collision-domain items — five shape/membership
|
||||
divergences, three resolution-math divergences, two undecodable-math rows, and
|
||||
three open bugs.
|
||||
**SSOT while active:** this file. Digest:
|
||||
`claude-memory/project_physics_collision_digest.md`.
|
||||
|
||||
---
|
||||
|
||||
## Why a campaign and not twelve tickets
|
||||
|
||||
Three of these rows edit the same two functions. `AP-157` and the `AP-156`
|
||||
residual both live in `ShadowObjectRegistry.BuildFloodSpheres`; `AP-159`/`#335`
|
||||
lives one call away in `CellTransit.BuildShadowCellSetFromParts` and in
|
||||
`ShadowObjectRegistry.BuildBspPartSpheres`. Shipping them as separate tickets
|
||||
means three review cycles over the same code and three chances to reintroduce
|
||||
each other's bugs. This project already has a written rule about exactly that
|
||||
shape of work: **shared-file slices are ONE agent against a pinned contract**
|
||||
(`feedback_dont_parallelize_coupled_plan_slices`).
|
||||
|
||||
The second reason is ordering. **Membership gaps mask query gaps.** We just
|
||||
watched it happen: AP-156 put geometry into the right cell and AP-158 threw it
|
||||
away one layer down, so AP-156's entire visible benefit was invisible until
|
||||
#333 landed. Anything upstream of the query has to be correct before a gate on
|
||||
the downstream math means anything.
|
||||
|
||||
---
|
||||
|
||||
## The governing lesson from the last campaign
|
||||
|
||||
**The register rows are leads, not specifications. Measure before you fix.**
|
||||
The evidence, all from the last two weeks:
|
||||
|
||||
- **AP-155(b) recorded the flood approximation as OVER-inclusive**, and used
|
||||
that direction as the reason it was safe to defer. Measured, it was
|
||||
UNDER-inclusive for 428 of 530 Setups — the opposite, and the dangerous
|
||||
direction.
|
||||
- **AP-156's risk column was wrong**, and its wrongness is precisely why #334
|
||||
— a user-visible loss of collision — sat inside it unnoticed.
|
||||
- **AP-22 described an unreachable branch.** 0 of 5,935 installed Setups could
|
||||
satisfy its guard. The correct fix was deletion, not a port.
|
||||
- **#331's headline claim was refuted outright.** The behaviour was already
|
||||
retail-faithful.
|
||||
|
||||
So one row in four, in this exact domain, was materially wrong about its own
|
||||
population, direction, or existence. **Every slice below opens with a
|
||||
measurement that can cancel it.** A slice that measures its population at zero
|
||||
closes as "row deleted", and that is a success, not a wasted slice.
|
||||
|
||||
---
|
||||
|
||||
## Slice order
|
||||
|
||||
### Pre-work — two cheap unblocks, before the campaign proper
|
||||
|
||||
Both are the user's own outstanding reports and both are blocked on a
|
||||
measurement that costs far less than the fix. Neither is a campaign slice.
|
||||
|
||||
**PW-1 — #338, the step heights.** Setup `0x02000001` authors
|
||||
`StepUpHeight = 0.600` / `StepDownHeight = 1.500`; the client resolves with
|
||||
`0.400` / `0.400`. **First question is whether retail reads the authored field
|
||||
at all** — grep `named-retail` for the step-height getters and their callers.
|
||||
If retail substitutes its own constants, 0.4 is correct and #338 closes as a
|
||||
non-defect. ~30 minutes. Do not touch code before that answer.
|
||||
|
||||
**PW-2 — #32, local-player cliff edge-slide.** This is the *other half of the
|
||||
original two-bug report* and the thing the user will feel most, so it does not
|
||||
sit behind six slices. Research is already done (`38db9fff`):
|
||||
`CollisionInfo.SetContactPlane` latches last-known at all 13 call sites where
|
||||
retail's `COLLISIONINFO::set_contact_plane` @0x00509d80 — 22 bytes — never
|
||||
does. Fix is ~20–25 lines, mostly deletion, in 2 files. **Blocked on a live
|
||||
`ACDREAM_DUMP_EDGE_SLIDE=1` capture**: the report's six-row decision table has
|
||||
three rows that redirect the fix entirely. Needs the user at the client.
|
||||
|
||||
---
|
||||
|
||||
### S1 — The flood / membership pipeline
|
||||
|
||||
**Rows:** AP-157, AP-156 residual, AP-159 / #335.
|
||||
**Files:** `ShadowObjectRegistry.cs` (`BuildFloodSpheres`, `BuildBspPartSpheres`),
|
||||
`CellTransit.cs` (`BuildShadowCellSetFromParts` indoor arm),
|
||||
`ShadowShapeBuilder.cs`.
|
||||
**One agent. Pinned contract. Not parallelised.**
|
||||
|
||||
This is the walk-through direction and the largest single win in the list.
|
||||
|
||||
- **AP-159 / #335** — indoors we admit an EnvCell neighbour on a *sphere* test
|
||||
where retail hands the part array to each cell's own `find_transit_cells` and
|
||||
tests every part's sphere against that cell's portal planes in cell-local
|
||||
space. Port the part-array overload. This is the last of AP-156's traversal
|
||||
residual; the outdoor half already closed with #334.
|
||||
- **AP-157** — retail's third `calc_cross_cells` branch floods from ONE
|
||||
`CPartArray::GetSortingSphere`; we flood from every Sphere shape. Our
|
||||
cylinder flood also ignores `CylHeight`.
|
||||
- **AP-156 residual** — we scale the flood sphere by entity/part scale; retail's
|
||||
`CEnvCell::find_transit_cells` reads only `CPhysicsPart::pos` and never
|
||||
`gfxobj_scale`. Note the asymmetry before changing anything: retail's cross-cell
|
||||
walk is itself under-inclusive for scaled parts and ours is not, so "match
|
||||
retail" here means **deliberately adopting a retail bug**. That is a decision
|
||||
to make explicitly with the user, not silently — over-inclusive is safe,
|
||||
under-inclusive is the walk-through direction.
|
||||
|
||||
**Opens with:** an installed-DAT sweep giving each row its true population and
|
||||
direction, measured against a DAT field that is not the one being fixed (the
|
||||
non-circular-oracle rule that caught AP-156's identically-zero assertion).
|
||||
|
||||
**Gate:** offline differential over installed DATs, plus one live indoor run —
|
||||
a dungeon with tight rooms and a door.
|
||||
|
||||
---
|
||||
|
||||
### S2 — Static publication shape fidelity
|
||||
|
||||
**Row:** AP-155. **Files:** `LandblockPhysicsPublisher.cs`,
|
||||
`LandblockPhysicsContentBuilder.cs`.
|
||||
|
||||
The static-load paths emit an authored Setup Sphere as a height-capped
|
||||
Cylinder. Different files from S1, so it is separable — but it must land
|
||||
**after** S1, because S1's flood consumes what these paths produce, and
|
||||
measuring S2's effect while S1 is in flight would confound both.
|
||||
|
||||
**Gate:** shares S1's live indoor run if S1 and S2 land together; otherwise
|
||||
offline only, since a shape substitution's population is fully measurable from
|
||||
the DATs.
|
||||
|
||||
---
|
||||
|
||||
### S3 — Animated collision pose
|
||||
|
||||
**Row:** AP-84. **Files:** `LiveEntityDefaultPoseResolver.cs`,
|
||||
`LiveEntityCollisionBuilder.cs`, `ShadowShapeBuilder.cs` (`partPoseOverride`).
|
||||
|
||||
Server entities with a MotionTableId register their BSP part shapes at the
|
||||
default style's first-cycle LowFrame pose and never update them; retail uses
|
||||
the live `CPhysicsPart` pose. A door's collision therefore stays where the shut
|
||||
door was.
|
||||
|
||||
**Why not folded into S1:** different files, different subsystem (animation, not
|
||||
membership), and it needs a *visual* gate that S1 does not — you have to watch
|
||||
a door open and then walk through the doorway.
|
||||
|
||||
**Gate:** live. Open a door, walk through, close it, walk into it.
|
||||
|
||||
---
|
||||
|
||||
### S4 — Push-out math
|
||||
|
||||
**Rows:** AD-65 + AD-66. **File:** `TransitionTypes.cs` (`AdjustOffset`) — both
|
||||
in the same function, so one slice.
|
||||
|
||||
- **AD-65** — the `collisionAngle > 0` arm substitutes `result -= N * angle`
|
||||
for retail's `Plane::snap_to_plane`. Recorded effect: downhill XY travel short
|
||||
by cos²θ (25% at 30°, 50% at 45°).
|
||||
- **AD-66** — the safety push-out substitutes `radius * ContactPlane.Normal.Z`
|
||||
for retail's bare `radius`, in both the trigger comparison and the `zDist`
|
||||
numerator.
|
||||
|
||||
**Correction carried in from the closeout:** AD-65 was previously described as
|
||||
"a live lead for #269". That framing is **retracted** — #269 closed 2026-07-31
|
||||
on the user's own live gate, and AD-65's sign is *opposite* to that symptom.
|
||||
AD-65 stands on its own merits. (#269's do-not-retry covers friction and jump
|
||||
chains, which are byte-exonerated; `AdjustOffset` is a different function and
|
||||
is not covered by it.)
|
||||
|
||||
This is feel, not pass-through. It cannot be gated by a test asserting "did I
|
||||
fall through" — it needs a movement-feel gate.
|
||||
|
||||
---
|
||||
|
||||
### S5 — The Sledding flatness constant
|
||||
|
||||
**Row:** AD-55. **File:** `PhysicsBody.cs` (`calc_friction`).
|
||||
|
||||
We compare `GroundNormal.Z > 0.99999536f` (≈0.175° from flat); the raw decomp
|
||||
literally computes `__fcos(0.17453292519943295)` = cos(10°) ≈ 0.984808. One of
|
||||
the two is a decode artefact. **Resolve by byte-decoding the constant from the
|
||||
PDB-paired binary** — there is a documented method for exactly this
|
||||
(`reference_pe_byte_decode`), and it has already caught one inverted mapping
|
||||
this project inherited from ACE.
|
||||
|
||||
Cheap. **Batch its live gate with S4's** — both are movement feel on slopes,
|
||||
and asking for two separate slope-feel sessions wastes the only genuinely
|
||||
scarce resource in this campaign.
|
||||
|
||||
---
|
||||
|
||||
### S6 — Containment, NOT a fix
|
||||
|
||||
**Rows:** AP-83, AP-91.
|
||||
|
||||
**These are not portable and should not be listed as fixes.** The PerfectClip
|
||||
time-of-impact tails in `CCylSphere::collide_with_point` and
|
||||
`CSphere::collide_with_point` are x87 sequences that do not decompile legibly;
|
||||
we took them from ACE. There is no retail text to port. Pretending otherwise
|
||||
would put a "port it" ticket in the backlog forever.
|
||||
|
||||
The honest deliverable is **containment**: prove no production mover sets
|
||||
PerfectClip, and add a guard or test that fails loudly if one ever does. Then
|
||||
the rows describe a branch we can show is unreachable, which is the same
|
||||
resolution AP-22 got.
|
||||
|
||||
---
|
||||
|
||||
### Parallel track — #330, headless live-entity collision
|
||||
|
||||
The headless host registers no live-entity collision at all: a bot walks
|
||||
through every NPC and every server-spawned object. The graphical client is
|
||||
unaffected.
|
||||
|
||||
**Genuinely parallel.** Different host, and — unlike everything above — it
|
||||
needs **no live gate from the user at all**, because the headless suite can
|
||||
assert it directly. It is the one item that can proceed while the user is
|
||||
unavailable, which makes it the right thing to pick up whenever a live gate is
|
||||
blocking.
|
||||
|
||||
---
|
||||
|
||||
## Gate economics
|
||||
|
||||
The user's time at the client is the only scarce resource here. Live gates are
|
||||
therefore **batched, not per-slice**:
|
||||
|
||||
| Session | Covers | What to do in-world |
|
||||
|---|---|---|
|
||||
| A | PW-2 capture | drive the cliff edges that misbehave; capture only, no fix yet |
|
||||
| B | S1 + S2 | a tight dungeon with a door; walk boundaries, jump, drop a corpse |
|
||||
| C | S3 | doors: open, walk through, close, walk into |
|
||||
| D | S4 + S5 | slopes: run down, run across, sled, land on inclines |
|
||||
|
||||
Four sessions for the whole campaign. Everything else is offline.
|
||||
|
||||
---
|
||||
|
||||
## Definition of done
|
||||
|
||||
- Each row either **retired** with its evidence, or **rewritten** with a
|
||||
corrected population/direction, or **deleted** as describing something that
|
||||
does not exist. All three are acceptable outcomes.
|
||||
- No row is closed on a test that re-computes the production expression as its
|
||||
own oracle. Use an independent DAT field or an independent implementation.
|
||||
- Every discriminating test is **sabotage-verified**: break the production line
|
||||
and watch the test redden, in the same session it is written.
|
||||
- `docs/ISSUES.md` and the divergence register updated in the **same commit**
|
||||
as the code, per the register's own two binding rules.
|
||||
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.
|
||||
279
docs/research/2026-08-06-337-neftet-wedge-mechanism.md
Normal file
279
docs/research/2026-08-06-337-neftet-wedge-mechanism.md
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
# #337 — the Neftet plateau wedge: mechanism, measured
|
||||
|
||||
**Date:** 2026-08-06
|
||||
**Status:** mechanism proven offline; **fix LANDED 2026-08-06 — the preferred
|
||||
option below was taken, the filter is deleted.** Awaiting the user's live
|
||||
acceptance at the Neftet plateau.
|
||||
**Reproducer:** `tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs`
|
||||
**Related:** #333 (filed: the broadphase reach filter has AP-156's defect at
|
||||
the query site), #334 (`13fcf381`, registration extent walk), AP-156
|
||||
(`b52967de`, flood sphere placement).
|
||||
|
||||
---
|
||||
|
||||
## Verdict in one paragraph
|
||||
|
||||
The collision mesh is present, correctly shaped, correctly placed in the
|
||||
world, and the BSP traversal reaches every part of it. **The mover never gets
|
||||
as far as the query.** `Transition.FindObjCollisionsInCell`'s per-object
|
||||
broadphase measures the mover's distance to the shadow entry's `Position` —
|
||||
the part **origin** — and compares it against `obj.Radius`, which is the
|
||||
physics-BSP **root bounding sphere's radius**. For this rock those two points
|
||||
are 23.6 m apart, so a mover standing on the plateau is inside the real
|
||||
bounding sphere by ~20 m of margin and is still rejected. Retail has no such
|
||||
filter at all. Everything else in the symptom set — the wedge, the sink, the
|
||||
corpse fall-through, ACE's spawn refusal, the 16,278 m/s rejection, the
|
||||
character vanishing from a retail observer's view — is downstream of that one
|
||||
rejection.
|
||||
|
||||
---
|
||||
|
||||
## What was refuted, and by what measurement
|
||||
|
||||
### 1. "The rock's own mesh never collides" — TRUE for `0xC8766002`, and it is INNOCENT
|
||||
|
||||
`0xC8766002` / `gfx=0x010046DE` really does report 11,014 `tested-ok` with
|
||||
zero `tested-adjusted`, `tested-collided` or `tested-slid` across the whole
|
||||
capture. That is correct behaviour: **its geometry is 22.8 m away from the
|
||||
wedge point.** Over a 30 × 30 × 12 m lattice covering the whole plateau
|
||||
(12,493 points) it has **zero** brute-force hits — it simply is not there.
|
||||
|
||||
It is a candidate in that cell only because it is a 130 × 147 m owner whose
|
||||
registration legitimately spans the cell. The probe families that reach it are
|
||||
working exactly as designed. This object was a red herring.
|
||||
|
||||
The neighbours do collide: `0xC8766003` 767 adjusted / 26 collided,
|
||||
`0xC8766009` 1,135 adjusted / 708 slid / 58 collided. The rock the player
|
||||
actually walks on is **`0xC8766009` / `gfx=0x01004751`**.
|
||||
|
||||
### 2. Wrong world transform — REFUTED
|
||||
|
||||
The offline reconstruction from the installed DAT reproduces the runtime
|
||||
placement exactly: `0xC8766002` at `(84.699, 100.082, 13.000)` yaw −45.00°
|
||||
against the live `[geom]` line's `objPos=(84.70,100.08,13.00)` and its
|
||||
`bspCentreOffset` (which implies −45.00°). All eleven owners match. The
|
||||
transform is right.
|
||||
|
||||
### 3. BSP traversal hole / bounding-sphere early-out — REFUTED
|
||||
|
||||
A referee ran the production walk (`FlatBspQuery.SphereIntersectsPoly`,
|
||||
node bounding-sphere early-outs and all) against brute force over every
|
||||
polygon the tree indexes, at 7,770 probe points placed on both faces of every
|
||||
polygon of all eleven owners, plus 137,423 lattice points over the plateau.
|
||||
|
||||
**Mismatch = 0 everywhere.** There is no pocket the traversal cannot reach.
|
||||
|
||||
### 4. Missing / degenerate geometry, a hole in the walking surface — REFUTED
|
||||
|
||||
A 0.5 m hole map over the plateau shows continuous upward-facing physics
|
||||
coverage across the whole wedge region; the only gaps are outside the rock's
|
||||
footprint. At the wedge XY the column is closed: an up-facing polygon at
|
||||
z = 50.985 (nz = +0.9805) over a down-facing one at z = 40.597 (nz = −0.9325),
|
||||
i.e. 10.4 m of solid rock, with the player's feet at z = 50.011 — **0.975 m
|
||||
inside it.**
|
||||
|
||||
### 5. `[geom] verdict=coincident` — confirmed to mean less than it looks
|
||||
|
||||
`LogGeometry` compares `physicsMin/Max` against `visualMin/Max`, both taken
|
||||
from the same GfxObj asset in the object's **own local frame**. No world
|
||||
transform enters the comparison. `coincident` proves shape agreement only.
|
||||
It happens to be true here, but it could never have been the discriminator.
|
||||
|
||||
---
|
||||
|
||||
## The mechanism, measured
|
||||
|
||||
### The frame-by-frame (337-support.log, cell `0x8766002B`)
|
||||
|
||||
Landblock-local coordinates; the log's Y carries a +576 m live-centre offset,
|
||||
removed here. Player feet position; Setup `0x02000001` gives sphere[0] at
|
||||
feet + 0.475 with r = 0.480 and sphere[1] at feet + 1.350.
|
||||
|
||||
| t | feet z | surface z at that XY | what happened |
|
||||
|---|---|---|---|
|
||||
| …218906–219296 | 51.096 | 51.081 | standing correctly (delta +0.015). **Every horizontal move of 0.25–0.53 m returns `moved=0.000, stalled=True`** on an 11° walkable slope. This is the wedge. |
|
||||
| 219328 | 51.096 → 51.389 | | player jumps to escape |
|
||||
| …–219937 | → 54.638 | | free rise, `support=none` |
|
||||
| …–221125 | 54.638 → 50.021 | 51.183 | **falls straight through the surface.** Six consecutive resolves accept the full commanded step with no contact plane. |
|
||||
| 221156 | 50.021 | | descent finally blocked, still no contact plane |
|
||||
| 221671 | 49.908 | 51.127 | `cpSrc=ValidateTransition:6076` — retail's stationary-fall failsafe **manufactures** a flat plane at z = 49.903, 1.2 m below the real rock surface |
|
||||
|
||||
The `support=object cpNz=1.0000` readings inside the rock are not the rock.
|
||||
`ValidateTransition:6076` is retail's `FramesStationaryFall > 1` synthetic
|
||||
up-plane through the sphere bottom; `ValidateTransition:5997` is retail's
|
||||
`LastKnownContactPlane` restore, i.e. a **stale** plane retained from frames
|
||||
when the object was still admitted. Both are retail-correct responses to a
|
||||
stuck body — which is why the client believes it is standing while ACE
|
||||
believes the position is invalid.
|
||||
|
||||
### The replay: the query would have hit
|
||||
|
||||
`Issue337NeftetRockGeometryInspectionTests.ReplayTheDescentThatFellThrough…`
|
||||
runs each of those six descent steps as a swept sphere against `0xC8766009`'s
|
||||
real BSP:
|
||||
|
||||
```
|
||||
CONTROL climb step ent=0xC8766009 sphere[0] swept=True(poly 22) static=True nearest=0.070 m
|
||||
CONTROL stand step ent=0xC8766009 sphere[0] swept=True(poly 26) static=True nearest=0.003 m
|
||||
FALL 2 51.269->50.961 sphere[0] swept=True(poly 31 t=0.0000) static=True nearest=0.248 m
|
||||
FALL 3 50.961->50.670 sphere[0] swept=True(poly 31 t=0.0000) static=True nearest=0.037 m
|
||||
FALL 4 50.670->50.335 sphere[0] swept=True(poly 31 t=0.0000) static=True nearest=0.366 m
|
||||
```
|
||||
|
||||
The geometry is right there and the production primitive returns the hit.
|
||||
Path 6 of `FlatBspQuery.FindCollisionsCore` (the airborne dispatch) would have
|
||||
called `path.SetCollide(...)` and returned `Adjusted`, blocking the fall.
|
||||
**It was never called.**
|
||||
|
||||
### Why it was never called
|
||||
|
||||
`Transition.FindObjCollisionsInCell` (`src/AcDream.Core/Physics/TransitionTypes.cs`,
|
||||
the `maxReach` test in the candidate loop):
|
||||
|
||||
```csharp
|
||||
Vector3 deltaToCurr = currPos - obj.Position; // ← part ORIGIN
|
||||
...
|
||||
float maxReach = sphereRadius + obj.Radius // ← BSP ROOT SPHERE radius
|
||||
+ movement.Length() + 2f;
|
||||
if (distToCurr > maxReach) continue;
|
||||
```
|
||||
|
||||
For `0xC8766009`: origin `(159.107, 36.629, 0.005)`, root bounding sphere
|
||||
centred at local `(−1.753, 14.259, 18.667)` with radius 56.909 — i.e. the
|
||||
sphere centre sits **23.556 m** from the origin the filter measures to.
|
||||
|
||||
At the fall position, measured (reproducer output):
|
||||
|
||||
```
|
||||
distToOrigin = 60.434 m > maxReach = 59.697 m → REJECTED
|
||||
distance to the BSP bounding-sphere CENTRE = 37.083 m ≪ 56.909 m radius
|
||||
```
|
||||
|
||||
The live capture recorded the same thing 7,225 times for this owner, and the
|
||||
probe's own `wouldAcceptAtCenter` column says `True` on **every** rejection:
|
||||
|
||||
```
|
||||
currPos=(126.57,36.59,50.71) distOrigin=60.24 budget=59.64 shortfall=+0.60 acceptAtCentre=True move=0.255
|
||||
currPos=(126.57,36.59,50.71) distOrigin=60.24 budget=60.10 shortfall=+0.14 acceptAtCentre=True move=0.715
|
||||
```
|
||||
|
||||
### Why it is bounded, and why jumping over it works
|
||||
|
||||
The dead zone is the shell between `distOrigin = maxReach` and the true
|
||||
bounding sphere. Because the sphere centre is offset 23.556 m from the origin,
|
||||
that shell is up to ~23.5 m thick on the far side of the object — here it
|
||||
covers the top of the plateau and nothing else. Everywhere closer to the
|
||||
origin, the same mesh collides normally. That is the "fails in a bounded
|
||||
region, works elsewhere on the same object" property.
|
||||
|
||||
`movement.Length()` is a term in the budget. A walking step of 0.25 m gives
|
||||
shortfall +0.60 (rejected); a step of 0.72 m gives +0.14; a step of ~0.86 m
|
||||
passes. **A jump's larger per-frame movement inflates the budget and lets the
|
||||
object back through the filter.** That is why jumping over the spot works and
|
||||
walking into it does not, and why a corpse — small per-frame movement — falls
|
||||
straight through.
|
||||
|
||||
---
|
||||
|
||||
## Retail
|
||||
|
||||
There is no per-object distance filter on retail's BSP branch. Verified
|
||||
instruction-by-instruction with cdb against the PDB-paired v11.4186 binary
|
||||
(`C:\Users\erikn\Downloads\acclient.exe`, `check_exe_pdb.py` → `MATCH`,
|
||||
GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`):
|
||||
|
||||
- `acclient!CPartArray::FindObjCollisions` @ **0x00518180** — 14 instructions:
|
||||
a bare `do/while` over `parts[i]` calling `CPhysicsPart::find_obj_collisions`
|
||||
and breaking on `!= OK`. No compare, no float math.
|
||||
- `acclient!CPhysicsPart::find_obj_collisions` @ **0x0050d8d0** — 17
|
||||
instructions: null-check `gfxobj`, null-check `gfxobj->physics_bsp`
|
||||
(`[ecx+78h]`), `SPHEREPATH::cache_localspace_sphere`,
|
||||
`CGfxObj::find_obj_collisions` @ 0x00534700. No compare, no float math.
|
||||
|
||||
`CPhysicsObj::FindObjCollisions` @ 0x0050f050 reaches that pair through
|
||||
`CPartArray::FindObjCollisions` at 0x0050f192; its cylsphere/sphere loops
|
||||
(0x0050f1c4 / 0x0050f251) call the real `intersects_sphere` per primitive —
|
||||
they are tests, not pre-filters. Retail's only spatial rejection is the BSP
|
||||
node bounding-sphere test inside the walk, which is correctly centred.
|
||||
|
||||
The in-tree comment claiming the filter is "the analog of the part
|
||||
sorting-sphere early-outs inside retail's `CPhysicsObj::FindObjCollisions` —
|
||||
response-neutral, pure perf" is **wrong on both counts.**
|
||||
|
||||
---
|
||||
|
||||
## The fix, as landed
|
||||
|
||||
**Taken: the preferred option — the per-object distance pre-check is DELETED**,
|
||||
for BSP and primitive entries alike. Retail has none, and the BSP walk's own
|
||||
root-node bounding-sphere test is the correctly-centred early-out that makes a
|
||||
second one unnecessary. The comment that called the filter "the analog of the
|
||||
part sorting-sphere early-outs inside retail's `CPhysicsObj::FindObjCollisions`
|
||||
— response-neutral, pure perf" was false in both halves and is replaced by the
|
||||
disassembly that refutes it. **AP-158 is retired**; no new register row is
|
||||
created, because the code no longer diverges.
|
||||
|
||||
The fallback — measuring to the bounding-sphere centre, which would have needed
|
||||
`BoundsCenter` on `ShadowEntry` and both registration paths — was NOT taken. It
|
||||
would have kept a construct retail does not have, including a `+ 2f` slack and
|
||||
a `movement.Length()` term with no retail counterpart, and left a second reach
|
||||
budget to be tuned forever.
|
||||
|
||||
### Gates
|
||||
|
||||
`tests/AcDream.Core.Tests/Physics/Issue333BroadphaseReachFilterTests.cs` drives
|
||||
the production path end-to-end (`ResolveWithTransition` →
|
||||
`FindObjCollisionsInCell` → `CollisionTraversal`) on a DAT-free fixture, so it
|
||||
runs everywhere rather than only where the installed DATs are present. It is a
|
||||
discriminating PAIR, sabotage-verified: with the `maxReach` pre-check restored,
|
||||
`OffCentreBspFloorStopsAFallingMover` fails — the mover reaches z=37.800, which
|
||||
is exactly the unobstructed fall, with `blockedAtLeastOnce=False` — while
|
||||
`CentredBspFloorStopsAFallingMover` keeps passing. Without the control row, a
|
||||
fixture that simply could not fall would pass the first test for the wrong
|
||||
reason.
|
||||
|
||||
The installed-DAT evidence for THIS rock is
|
||||
`Issue337NeftetRockGeometryInspectionTests.TheOldBroadphaseMeasuredToTheOriginAndSoRejectedGeometryItStoodOn`
|
||||
(previously the skipped `TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn`,
|
||||
which asserted the now-deleted predicate and could never have gone green). It
|
||||
pins both halves of the diagnosis: the origin-measured distance OUTSIDE the old
|
||||
budget, and the centre-measured distance comfortably INSIDE the same radius. If
|
||||
a future DAT or transform change makes either false, the mechanism recorded here
|
||||
no longer describes this object.
|
||||
|
||||
### Perf — measured, not assumed
|
||||
|
||||
Deleting a filter costs whatever the candidates it used to reject now cost.
|
||||
Measured in Release on a synthetic all-BSP cell, per `ResolveWithTransition`:
|
||||
|
||||
| candidates in cell | with filter | without | delta |
|
||||
|---|---|---|---|
|
||||
| 38 — the live maximum | 10.61 µs | 16.68 µs | +6.07 µs (1.57×) |
|
||||
| 200 — 5× anything observed | 17.34 µs | 39.48 µs | +22.1 µs (2.28×) |
|
||||
|
||||
≈ 0.16 µs per additional candidate actually tested; the curve is linear, and
|
||||
the 200-object row is included only to show that, not to suggest it is
|
||||
reachable. The live population is the bound that matters: over **19,701**
|
||||
`[reach-q]` samples across the Neftet and outdoor captures (`334-fix-gate.log`,
|
||||
`334-neftet-probe.log`, `334-neftet.log`) the in-cell candidate count is
|
||||
**p50 = 9, p99 = 32, max 38**. Retail pays the same cost and shipped without a
|
||||
filter.
|
||||
|
||||
### Probe columns kept deliberately
|
||||
|
||||
`rejectedReach` on the `[reach-q]` line and the origin-vs-centre distance pair
|
||||
on `[reach-obj]` are RETAINED and are now structurally zero / purely
|
||||
informational. That is the point: a post-fix capture reading `rejectedReach=0`
|
||||
is directly comparable with the pre-fix capture that recorded **7,225**
|
||||
rejections on a single owner, every one of them with `wouldAcceptAtCenter=True`.
|
||||
Dropping the columns would make the two captures incomparable.
|
||||
|
||||
---
|
||||
|
||||
## Separate observation, not part of this defect — now filed as #338
|
||||
|
||||
Setup `0x02000001` authors `StepUpHeight = 0.600` and
|
||||
`StepDownHeight = 1.500`. The live `[support]` lines show the player resolving
|
||||
with `stepUp=0.400 stepDown=0.400`. A 1.5 m step-down is what keeps a mover
|
||||
attached to a descending slope; 0.4 m is not. Worth its own investigation —
|
||||
it does not cause this wedge, and it was not chased here.
|
||||
|
|
@ -5,8 +5,23 @@
|
|||
applies and is repeated in §6).
|
||||
|
||||
Branch `claude/acdream-physics-divergence-5aa784`, 21 commits from `02578441`
|
||||
to `7b3e2895`. **Nothing is pushed** — the branch does not exist on the remote,
|
||||
and there are 388+ unpushed commits ahead of `origin/main`.
|
||||
to `7b3e2895`.
|
||||
|
||||
**CORRECTED 2026-08-06 — the original line here said "Nothing is pushed — the
|
||||
branch does not exist on the remote, and there are 388+ unpushed commits ahead
|
||||
of `origin/main`". That was FALSE, and false in the direction that would most
|
||||
alarm a successor.** The campaign was merged to `main` and pushed the same day:
|
||||
`main` and `github/main` are both `d4e956b4`, and `git branch --contains
|
||||
7b3e2895` lists `main`.
|
||||
|
||||
**The measurement error, because it will recur.** This repo has TWO remotes.
|
||||
`github` (`git@github.com:eriknihlen/acdream.git`) is the live one. `origin`
|
||||
(`https://git.snakedesert.se/erik/acdream.git`) is a second remote whose cached
|
||||
ref `origin/main` still points at `f6275f45` and whose ref file has not been
|
||||
written since **April 27** — three months stale. `main` is 412 commits ahead of
|
||||
that ref, which is where "388+ unpushed" came from. **Never measure push state
|
||||
against `origin/*` in this repo without checking the ref's age first**; compare
|
||||
against `github/main`, or check `git branch --contains <sha>`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ internal sealed record LiveEntityCollisionRegistration(
|
|||
/// </remarks>
|
||||
internal sealed class LiveEntityCollisionBuilder
|
||||
{
|
||||
private readonly Func<uint, FlatCollisionSphere?> _physicsBspBounds;
|
||||
private readonly Func<uint, ShadowPartGeometry?> _physicsBspBounds;
|
||||
/// <summary>
|
||||
/// The dispatch gate, derived from <see cref="_physicsBspBounds"/> so the
|
||||
/// two can never disagree (AP-156), and cached once so <see cref="Build"/>
|
||||
|
|
@ -62,11 +62,13 @@ internal sealed class LiveEntityCollisionBuilder
|
|||
: this(
|
||||
id =>
|
||||
{
|
||||
FlatPhysicsBsp? flat =
|
||||
physicsData.GetFlatGfxObj(id)?.PhysicsBsp;
|
||||
FlatGfxObjCollisionAsset? asset = physicsData.GetFlatGfxObj(id);
|
||||
FlatPhysicsBsp? flat = asset?.PhysicsBsp;
|
||||
return flat is { RootIndex: >= 0 }
|
||||
? flat.Nodes[flat.RootIndex].BoundingSphere
|
||||
: (FlatCollisionSphere?)null;
|
||||
? ShadowPartGeometry.Create(
|
||||
flat.Nodes[flat.RootIndex].BoundingSphere,
|
||||
asset!.VisualBounds)
|
||||
: (ShadowPartGeometry?)null;
|
||||
},
|
||||
defaultPose)
|
||||
{
|
||||
|
|
@ -74,14 +76,15 @@ internal sealed class LiveEntityCollisionBuilder
|
|||
}
|
||||
|
||||
/// <param name="physicsBspBounds">The part GfxObj's physics-BSP root
|
||||
/// bounding sphere, or null when it has none. ONE resolver answers both
|
||||
/// questions the builder asks — "does this part dispatch as BSP?" and
|
||||
/// "where and how big is its flood sphere?" — so the dispatch gate and
|
||||
/// the emitted geometry cannot disagree, and the sphere's radius cannot
|
||||
/// be carried while its origin is dropped. That split is what produced
|
||||
/// the AP-156 mis-placed flood.</param>
|
||||
/// bounding sphere AND its authored vertex-array box, or null when it has
|
||||
/// no physics BSP. ONE resolver answers every question the builder asks —
|
||||
/// "does this part dispatch as BSP?", "where and how big is its flood
|
||||
/// sphere?", and "what is its outdoor extent?" — so the dispatch gate and
|
||||
/// the emitted geometry cannot disagree, the sphere's radius cannot be
|
||||
/// carried while its origin is dropped (AP-156), and the outdoor extent
|
||||
/// walk cannot be left without a box (#334).</param>
|
||||
internal LiveEntityCollisionBuilder(
|
||||
Func<uint, FlatCollisionSphere?> physicsBspBounds,
|
||||
Func<uint, ShadowPartGeometry?> physicsBspBounds,
|
||||
LiveEntityDefaultPoseResolver defaultPose)
|
||||
{
|
||||
_physicsBspBounds = physicsBspBounds
|
||||
|
|
|
|||
372
src/AcDream.App/Rendering/CollisionMeshWireframe.cs
Normal file
372
src/AcDream.App/Rendering/CollisionMeshWireframe.cs
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
using System.Collections.Immutable;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Rendering;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #337 collision-mesh wireframe (2026-08-06 — TEMPORARY, strip with the #337
|
||||
/// probe family).
|
||||
///
|
||||
/// <para>
|
||||
/// The F2 collision overlay predating this class drew, for a BSP object, a
|
||||
/// proxy cylinder sized from the object's registered BROADPHASE radius. That
|
||||
/// answers "where does the collision system think this object roughly is" and
|
||||
/// nothing more. The open question in Neftet is a different one — whether an
|
||||
/// object's collision SURFACES are where its visual mesh is drawn — and a
|
||||
/// proxy sphere cannot answer it in either direction.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// This draws the actual geometry instead, in three colours that are meant to
|
||||
/// be read against each other:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Cyan</b> — the object's real physics-BSP polygon edges, in world
|
||||
/// space. These are the surfaces a body can stand on or be stopped by.
|
||||
/// Where the cyan mesh sits away from the rock you can see, the collision
|
||||
/// is displaced; where a visible rock has no cyan on it at all, it has no
|
||||
/// collision geometry.</item>
|
||||
/// <item><b>Magenta</b> — the same object's VISUAL mesh bounding box, from
|
||||
/// the same prepared assets the renderer draws from. It is the reference
|
||||
/// the cyan is judged against, so the comparison does not depend on the
|
||||
/// eye's guess about where the visual "really" is.</item>
|
||||
/// <item><b>Yellow</b> — the outdoor terrain surface under the player, as a
|
||||
/// grid of the physics engine's own sampled heights. If a body is resting
|
||||
/// on the yellow rather than on cyan, terrain is holding it up and the
|
||||
/// object's collision is not involved at all.</item>
|
||||
/// </list>
|
||||
/// Dim orange keeps the old broadphase proxy visible so nothing the previous
|
||||
/// overlay showed has been taken away.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Geometry is resolved through the SAME prepared collision accessors the
|
||||
/// resolver queries (<c>PhysicsDataCache.GetFlatGfxObj</c> /
|
||||
/// <c>GetVisualBounds</c>) and placed with the SAME world transform the
|
||||
/// collision probes use, so this cannot draw a shape the collision system does
|
||||
/// not actually hold. Reading the geometry by a second route is how AP-156
|
||||
/// managed to report a sphere the registry never emitted.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Pure reads. Nothing here mutates physics, registry, or render state; the
|
||||
/// caller owns the <see cref="DebugLineRenderer"/> frame.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class CollisionMeshWireframe
|
||||
{
|
||||
// Colours, in the order the class comment lists them.
|
||||
private static readonly Vector3 PhysicsColor = new(0f, 1f, 1f);
|
||||
private static readonly Vector3 VisualColor = new(1f, 0f, 1f);
|
||||
private static readonly Vector3 TerrainColor = new(1f, 1f, 0f);
|
||||
private static readonly Vector3 BroadphaseColor = new(0.45f, 0.22f, 0f);
|
||||
|
||||
/// <summary>
|
||||
/// Per-frame line ceiling. Each line is 48 bytes in the debug renderer's
|
||||
/// ring allocation, so this caps the overlay at ~1.9 MB a frame. Landblock
|
||||
/// 0x8766 carries the largest single collision owner measured in the game
|
||||
/// (an 81-cell footprint), and an uncapped walk of it would be the one
|
||||
/// place this overlay falls over.
|
||||
/// </summary>
|
||||
private const int MaxLines = 40_000;
|
||||
|
||||
/// <summary>Per-object polygon ceiling, so one enormous formation cannot
|
||||
/// consume the whole budget and hide every other object near it.</summary>
|
||||
private const int MaxPolygonsPerObject = 4_000;
|
||||
|
||||
/// <summary>Half-width in metres of the terrain grid drawn under the
|
||||
/// player, and its sample spacing.</summary>
|
||||
private const float TerrainGridHalfWidth = 12f;
|
||||
private const float TerrainGridStep = 2f;
|
||||
|
||||
private readonly PhysicsEngine _physics;
|
||||
|
||||
public CollisionMeshWireframe(PhysicsEngine physics)
|
||||
=> _physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
||||
|
||||
/// <summary>
|
||||
/// Emit the overlay for everything within
|
||||
/// <see cref="RenderingDiagnostics.CollisionMeshWireframeRadius"/> of
|
||||
/// <paramref name="centre"/>. Returns what it drew so the caller can
|
||||
/// report a capped frame rather than silently showing partial geometry.
|
||||
/// </summary>
|
||||
public CollisionMeshWireframeStats Draw(DebugLineRenderer lines, Vector3 centre)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(lines);
|
||||
|
||||
float radius = RenderingDiagnostics.CollisionMeshWireframeRadius;
|
||||
float radiusSquared = radius * radius;
|
||||
var budget = new LineBudget(MaxLines);
|
||||
|
||||
int objects = 0;
|
||||
int polygons = 0;
|
||||
int withoutGeometry = 0;
|
||||
|
||||
PhysicsDataCache? cache = _physics.DataCache;
|
||||
|
||||
foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug())
|
||||
{
|
||||
// Objects register their part ORIGIN, which for a BSP part is
|
||||
// routinely nowhere near the geometry itself (376 of the 973
|
||||
// installed physics-BSP parts sit further from their own bounding
|
||||
// centre than half their radius). Admitting on origin distance
|
||||
// ALONE would drop exactly the large displaced-centre formations
|
||||
// this overlay exists to look at, so the object's own radius is
|
||||
// added to the window.
|
||||
float reach = radius + shadow.Radius;
|
||||
if (Vector3.DistanceSquared(shadow.Position, centre) > reach * reach)
|
||||
continue;
|
||||
|
||||
objects++;
|
||||
|
||||
if (shadow.CollisionType != ShadowCollisionType.BSP)
|
||||
{
|
||||
DrawBroadphaseProxy(lines, in shadow, budget);
|
||||
continue;
|
||||
}
|
||||
|
||||
FlatGfxObjCollisionAsset? asset = cache?.GetFlatGfxObj(shadow.GfxObjId);
|
||||
int drawn = DrawPhysicsPolygons(lines, in shadow, asset, centre, radiusSquared, budget);
|
||||
polygons += drawn;
|
||||
if (drawn == 0) withoutGeometry++;
|
||||
|
||||
DrawVisualBounds(lines, in shadow, cache?.GetVisualBounds(shadow.GfxObjId), budget);
|
||||
DrawBroadphaseProxy(lines, in shadow, budget);
|
||||
}
|
||||
|
||||
DrawTerrainGrid(lines, centre, budget);
|
||||
|
||||
return new CollisionMeshWireframeStats(
|
||||
ObjectsConsidered: objects,
|
||||
PolygonsDrawn: polygons,
|
||||
ObjectsWithoutPhysicsGeometry: withoutGeometry,
|
||||
LinesDrawn: budget.Used,
|
||||
Capped: budget.Capped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walk the object's physics BSP and emit one closed edge loop per polygon
|
||||
/// the tree actually indexes. Polygons the tree does not reference are NOT
|
||||
/// drawn: no query can reach them, so showing them would overstate the
|
||||
/// collision surface. Returns the polygon count emitted.
|
||||
/// </summary>
|
||||
private static int DrawPhysicsPolygons(
|
||||
DebugLineRenderer lines,
|
||||
in ShadowEntry shadow,
|
||||
FlatGfxObjCollisionAsset? asset,
|
||||
Vector3 centre,
|
||||
float radiusSquared,
|
||||
LineBudget budget)
|
||||
{
|
||||
FlatPhysicsBsp? bsp = asset?.PhysicsBsp;
|
||||
if (bsp is not { RootIndex: >= 0 } || bsp.Nodes.Length == 0)
|
||||
return 0;
|
||||
|
||||
FlatPolygonTable table = bsp.PolygonTable;
|
||||
ImmutableArray<Vector3> vertices = table.Vertices;
|
||||
int emitted = 0;
|
||||
|
||||
foreach (FlatPhysicsBspNode node in bsp.Nodes)
|
||||
{
|
||||
FlatIndexRange indices = node.PolygonIndexRange;
|
||||
for (int i = indices.Start; i < indices.EndExclusive; i++)
|
||||
{
|
||||
if (emitted >= MaxPolygonsPerObject || budget.Exhausted)
|
||||
return emitted;
|
||||
|
||||
int polygonIndex = bsp.PolygonIndexStream[i];
|
||||
if ((uint)polygonIndex >= (uint)table.Polygons.Length) continue;
|
||||
|
||||
FlatIndexRange span = table.Polygons[polygonIndex].VertexRange;
|
||||
if (span.Count < 2) continue;
|
||||
|
||||
Vector3 first = ToWorld(vertices[span.Start], in shadow);
|
||||
// Per-polygon distance rejection, AFTER the world transform:
|
||||
// a big object admitted by the object-level window still only
|
||||
// needs the faces near the player drawn.
|
||||
if (Vector3.DistanceSquared(first, centre) > radiusSquared) continue;
|
||||
|
||||
Vector3 previous = first;
|
||||
for (int v = span.Start + 1; v < span.EndExclusive; v++)
|
||||
{
|
||||
Vector3 current = ToWorld(vertices[v], in shadow);
|
||||
if (!budget.TryAdd()) return emitted;
|
||||
lines.AddLine(previous, current, PhysicsColor);
|
||||
previous = current;
|
||||
}
|
||||
|
||||
if (span.Count > 2)
|
||||
{
|
||||
if (!budget.TryAdd()) return emitted;
|
||||
lines.AddLine(previous, first, PhysicsColor);
|
||||
}
|
||||
|
||||
emitted++;
|
||||
}
|
||||
}
|
||||
|
||||
return emitted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The visual mesh box, placed with the SAME transform as the physics
|
||||
/// polygons above. It is drawn as the object's own rotated box (eight
|
||||
/// transformed corners, twelve edges) rather than as a world-axis-aligned
|
||||
/// box, so a rotated object's magenta lines still bound its actual visual.
|
||||
/// </summary>
|
||||
private static void DrawVisualBounds(
|
||||
DebugLineRenderer lines,
|
||||
in ShadowEntry shadow,
|
||||
GfxObjVisualBounds? visual,
|
||||
LineBudget budget)
|
||||
{
|
||||
if (visual is null || budget.Exhausted) return;
|
||||
|
||||
Vector3 min = visual.Min;
|
||||
Vector3 max = visual.Max;
|
||||
Span<Vector3> corners =
|
||||
[
|
||||
ToWorld(new Vector3(min.X, min.Y, min.Z), in shadow),
|
||||
ToWorld(new Vector3(max.X, min.Y, min.Z), in shadow),
|
||||
ToWorld(new Vector3(max.X, max.Y, min.Z), in shadow),
|
||||
ToWorld(new Vector3(min.X, max.Y, min.Z), in shadow),
|
||||
ToWorld(new Vector3(min.X, min.Y, max.Z), in shadow),
|
||||
ToWorld(new Vector3(max.X, min.Y, max.Z), in shadow),
|
||||
ToWorld(new Vector3(max.X, max.Y, max.Z), in shadow),
|
||||
ToWorld(new Vector3(min.X, max.Y, max.Z), in shadow),
|
||||
];
|
||||
|
||||
ReadOnlySpan<int> edges =
|
||||
[
|
||||
0, 1, 1, 2, 2, 3, 3, 0,
|
||||
4, 5, 5, 6, 6, 7, 7, 4,
|
||||
0, 4, 1, 5, 2, 6, 3, 7,
|
||||
];
|
||||
|
||||
for (int e = 0; e < edges.Length; e += 2)
|
||||
{
|
||||
if (!budget.TryAdd()) return;
|
||||
lines.AddLine(corners[edges[e]], corners[edges[e + 1]], VisualColor);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The registered broadphase shape — what the pre-#337 overlay showed, and
|
||||
/// what the collision system's reach filter measures against. Kept so this
|
||||
/// overlay is a superset of the one it replaces.
|
||||
/// </summary>
|
||||
private static void DrawBroadphaseProxy(
|
||||
DebugLineRenderer lines,
|
||||
in ShadowEntry shadow,
|
||||
LineBudget budget)
|
||||
{
|
||||
// AddCylinder emits a fixed 36 lines. Reserve them together so a
|
||||
// partial ring cannot be drawn.
|
||||
if (!budget.TryAdd(36)) return;
|
||||
|
||||
if (shadow.CollisionType == ShadowCollisionType.Cylinder)
|
||||
{
|
||||
float height = shadow.CylHeight > 0f ? shadow.CylHeight : shadow.Radius * 2f;
|
||||
lines.AddCylinder(shadow.Position, shadow.Radius, height, BroadphaseColor);
|
||||
return;
|
||||
}
|
||||
|
||||
lines.AddCylinder(
|
||||
shadow.Position - new Vector3(0f, 0f, shadow.Radius),
|
||||
shadow.Radius,
|
||||
shadow.Radius * 2f,
|
||||
BroadphaseColor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The terrain surface under the player, sampled through the physics
|
||||
/// engine's own height resolver — the same numbers the resolver grounds
|
||||
/// against, not a re-derivation. Drawn as a grid rather than as the single
|
||||
/// containing triangle so the slope around the player reads at a glance.
|
||||
/// </summary>
|
||||
private void DrawTerrainGrid(DebugLineRenderer lines, Vector3 centre, LineBudget budget)
|
||||
{
|
||||
int steps = (int)(TerrainGridHalfWidth * 2f / TerrainGridStep);
|
||||
float originX = centre.X - TerrainGridHalfWidth;
|
||||
float originY = centre.Y - TerrainGridHalfWidth;
|
||||
|
||||
for (int ix = 0; ix <= steps; ix++)
|
||||
{
|
||||
for (int iy = 0; iy <= steps; iy++)
|
||||
{
|
||||
float x = originX + ix * TerrainGridStep;
|
||||
float y = originY + iy * TerrainGridStep;
|
||||
float? z = _physics.SampleTerrainZ(x, y);
|
||||
if (z is null) continue;
|
||||
|
||||
var here = new Vector3(x, y, z.Value);
|
||||
|
||||
if (ix < steps)
|
||||
{
|
||||
float nx = x + TerrainGridStep;
|
||||
if (_physics.SampleTerrainZ(nx, y) is { } nz)
|
||||
{
|
||||
if (!budget.TryAdd()) return;
|
||||
lines.AddLine(here, new Vector3(nx, y, nz), TerrainColor);
|
||||
}
|
||||
}
|
||||
|
||||
if (iy < steps)
|
||||
{
|
||||
float ny = y + TerrainGridStep;
|
||||
if (_physics.SampleTerrainZ(x, ny) is { } nz2)
|
||||
{
|
||||
if (!budget.TryAdd()) return;
|
||||
lines.AddLine(here, new Vector3(x, ny, nz2), TerrainColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one placement formula, matching the <c>[resolve-bldg]</c> probe's
|
||||
/// world transform for a shadow part
|
||||
/// (<c>TransitionTypes.FindObjCollisionsInCell</c>): scale in the part's
|
||||
/// own frame, then rotate, then translate to the registered position.
|
||||
/// </summary>
|
||||
private static Vector3 ToWorld(Vector3 local, in ShadowEntry shadow)
|
||||
=> shadow.Position + Vector3.Transform(local * shadow.Scale, shadow.Rotation);
|
||||
|
||||
/// <summary>
|
||||
/// Mutable line counter shared across the draw. A class rather than a
|
||||
/// struct so the per-shape helpers can be static and still share it
|
||||
/// without ref-plumbing through every signature.
|
||||
/// </summary>
|
||||
private sealed class LineBudget(int limit)
|
||||
{
|
||||
public int Used { get; private set; }
|
||||
|
||||
public bool Capped { get; private set; }
|
||||
|
||||
public bool Exhausted => Used >= limit;
|
||||
|
||||
public bool TryAdd(int count = 1)
|
||||
{
|
||||
if (Used + count > limit)
|
||||
{
|
||||
Capped = true;
|
||||
return false;
|
||||
}
|
||||
Used += count;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What one <see cref="CollisionMeshWireframe.Draw"/> emitted.
|
||||
/// <paramref name="ObjectsWithoutPhysicsGeometry"/> is the interesting one: a
|
||||
/// non-zero count means objects near the player carry no reachable collision
|
||||
/// polygons at all.</summary>
|
||||
internal readonly record struct CollisionMeshWireframeStats(
|
||||
int ObjectsConsidered,
|
||||
int PolygonsDrawn,
|
||||
int ObjectsWithoutPhysicsGeometry,
|
||||
int LinesDrawn,
|
||||
bool Capped);
|
||||
|
|
@ -64,6 +64,10 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
|
|||
private readonly DebugVmRenderFactsPublisher _debugVm;
|
||||
private readonly bool _debugVmConsumerActive;
|
||||
private int _debugDrawLogCount;
|
||||
// #337 (TEMPORARY): built on first use so the ordinary overlay path and
|
||||
// every headless/no-window host pay nothing for it.
|
||||
private CollisionMeshWireframe? _meshWireframe;
|
||||
private CollisionMeshWireframeStats _lastMeshStats = new(-1, -1, -1, -1, false);
|
||||
|
||||
public WorldSceneDiagnosticsController(
|
||||
WorldRenderDiagnostics diagnostics,
|
||||
|
|
@ -215,6 +219,18 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
|
|||
if (!_state.CollisionWireframesVisible || _lines is null)
|
||||
return;
|
||||
|
||||
// #337 (2026-08-06 — TEMPORARY): ACDREAM_WIRE_MESH=1 replaces the
|
||||
// broadphase-proxy overlay below with the objects' real physics-BSP
|
||||
// polygon edges beside their visual mesh boxes and the terrain surface
|
||||
// — see CollisionMeshWireframe for why the proxy cannot answer the
|
||||
// question this mode exists for. Default off; F2 keeps its old
|
||||
// behaviour otherwise.
|
||||
if (RenderingDiagnostics.CollisionMeshWireframeEnabled)
|
||||
{
|
||||
DrawCollisionMesh(in camera);
|
||||
return;
|
||||
}
|
||||
|
||||
_lines.Begin();
|
||||
int drawn = 0;
|
||||
foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug())
|
||||
|
|
@ -255,6 +271,50 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
|
|||
_lines.Flush(camera.Camera.View, camera.Projection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #337 (2026-08-06 — TEMPORARY, strip with the probe family). Centres on
|
||||
/// the player when there is one, else on the camera, so the fly-camera
|
||||
/// mode can inspect geometry too.
|
||||
/// </summary>
|
||||
private void DrawCollisionMesh(in WorldCameraFrame camera)
|
||||
{
|
||||
Vector3 centre = _mode.IsPlayerMode && _player.Controller is { } controller
|
||||
? controller.Position
|
||||
: camera.Position;
|
||||
|
||||
_meshWireframe ??= new CollisionMeshWireframe(_physics);
|
||||
|
||||
_lines!.Begin();
|
||||
CollisionMeshWireframeStats stats = _meshWireframe.Draw(_lines, centre);
|
||||
|
||||
if (_mode.IsPlayerMode && _player.Controller is { } player)
|
||||
{
|
||||
_lines.AddCylinder(
|
||||
player.Position,
|
||||
DebugVmRenderFactsPublisher.PlayerCollisionRadius,
|
||||
1.8f,
|
||||
new Vector3(1f, 0f, 0f));
|
||||
}
|
||||
|
||||
_lines.Flush(camera.Camera.View, camera.Projection);
|
||||
|
||||
// A capped frame is showing PARTIAL geometry, which would otherwise be
|
||||
// indistinguishable from an object that has none — exactly the
|
||||
// misreading this overlay exists to prevent. Say so, throttled, rather
|
||||
// than letting the picture lie.
|
||||
if (stats != _lastMeshStats)
|
||||
{
|
||||
_lastMeshStats = stats;
|
||||
Console.WriteLine(string.Format(
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
"[wire-mesh] centre=({0:F2},{1:F2},{2:F2}) objects={3} polys={4} " +
|
||||
"noPhysicsGeometry={5} lines={6} capped={7}",
|
||||
centre.X, centre.Y, centre.Z,
|
||||
stats.ObjectsConsidered, stats.PolygonsDrawn,
|
||||
stats.ObjectsWithoutPhysicsGeometry, stats.LinesDrawn, stats.Capped));
|
||||
}
|
||||
}
|
||||
|
||||
private void LogNearbyCollisionObjects(Vector3 playerPosition, int drawn)
|
||||
{
|
||||
if (_debugDrawLogCount >= 5)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
// CLandCell::add_outside_cell (pc:317056 @0x00532ec0): map-bounds check,
|
||||
|
|
@ -500,8 +640,13 @@ public static class CellTransit
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// BR-7 / A6.P4 (2026-06-11). Registration-side cell-set builder — the
|
||||
/// sphere-overlap portal flood retail runs at SHADOW REGISTRATION time.
|
||||
/// BR-7 / A6.P4 (2026-06-11). Registration-side cell-set builder for an
|
||||
/// 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,
|
||||
/// pc:308742) as invoked by <c>CPhysicsObj::calc_cross_cells</c> /
|
||||
/// <c>calc_cross_cells_static</c> (Ghidra 0x00515230 / 0x00515160):
|
||||
|
|
@ -657,6 +802,184 @@ public static class CellTransit
|
|||
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>
|
||||
/// Verbatim port of <c>CEnvCell::find_visible_child_cell</c>
|
||||
/// (<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);
|
||||
}
|
||||
GfxObjVisualBounds? parsedBounds =
|
||||
_visualBounds.TryGetValue(gfxObjId, out var cachedBounds)
|
||||
? cachedBounds
|
||||
: null;
|
||||
|
||||
if (_gfxObj.TryGetValue(gfxObjId, out GfxObjPhysics? existing))
|
||||
{
|
||||
|
|
@ -217,6 +221,10 @@ public sealed class PhysicsDataCache
|
|||
Vertices = gfxObj.VertexArray,
|
||||
Resolved = ResolvePolygons(gfxObj.PhysicsPolygons, gfxObj.VertexArray),
|
||||
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;
|
||||
|
||||
|
|
@ -285,6 +293,7 @@ public sealed class PhysicsDataCache
|
|||
Radius = root.Radius,
|
||||
},
|
||||
FlatPhysicsBsp = prepared.PhysicsBsp,
|
||||
VisualBounds = prepared.VisualBounds,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1426,6 +1435,16 @@ public sealed class GfxObjPhysics
|
|||
/// omit it.
|
||||
/// </summary>
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -1146,6 +1146,788 @@ public static class PhysicsDiagnostics
|
|||
public static bool ProbeSweptEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_SWEPT") == "1";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// #334 broadphase / candidate-disposition probe — TEMPORARY (2026-08-06)
|
||||
//
|
||||
// STRIP THIS WHOLE REGION together with the rest of the physics probe
|
||||
// family once #334 is scored.
|
||||
//
|
||||
// #334 is "large static formations can be walked through on flat ground,
|
||||
// and jumping over them drops you inside". Three candidate mechanisms
|
||||
// survive the report, and this probe exists to DISCRIMINATE them, not to
|
||||
// confirm any one of them:
|
||||
//
|
||||
// (a) the object IS a candidate in the cell but the broadphase reach
|
||||
// filter rejects it before its BSP is consulted (AP-158 / #333).
|
||||
// CONFIRMED and CLOSED 2026-08-06: this was the cause of #337, and
|
||||
// the filter is now DELETED — retail has none. `rejectedReach` is
|
||||
// retained as a structurally-zero column so a post-fix capture is
|
||||
// directly comparable with the pre-fix one that recorded 7,225
|
||||
// rejections on one owner, every single one with
|
||||
// wouldAcceptAtCenter=True;
|
||||
// (b) the object is NOT in the cell's candidate set at all — a
|
||||
// membership / registration failure (AP-156's territory, which did
|
||||
// not fix this, or the static publication path never registered it);
|
||||
// (c) the object IS a candidate and IS NOT rejected, but resolves to no
|
||||
// usable shape — an empty shape list or an unresolved physics BSP.
|
||||
//
|
||||
// Context for (a): the filter measures |currPos - obj.Position| — the part
|
||||
// ORIGIN — against obj.Radius (the physics-BSP ROOT sphere radius) plus a
|
||||
// 2 m acdream-invented slack. The BSP root sphere's CENTRE is frequently
|
||||
// NOT the part origin (376 of 973 installed physics-BSP parts sit further
|
||||
// from it than half their own radius; worst 20.762 m). Where that offset
|
||||
// exceeds the slack, geometry well inside the sphere is rejected. So every
|
||||
// candidate line carries BOTH distances and the decisive
|
||||
// wouldAcceptAtCenter boolean.
|
||||
//
|
||||
// Context for (c): AP-152 (4abd1b5e) made us emit BSP shapes exclusively
|
||||
// where primitives were also emitted before. That did NOT cause #334 (the
|
||||
// user reproduced on a pre-AP-152 build), but the same failure mode can
|
||||
// exist independently, so no-shape is a first-class disposition here.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// #334 candidate-disposition probe (2026-08-06 — TEMPORARY, strip with
|
||||
/// the physics-probe family). Emits two line types from
|
||||
/// <c>Transition.FindObjCollisionsInCell</c>:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><c>[reach-q]</c> — one per-cell query summary: the number of
|
||||
/// shadow entries the cell yielded and the per-disposition tallies.
|
||||
/// It is emitted <b>even when the cell yields zero entries</b>, which is
|
||||
/// what makes outcome (b) visible: "cell yielded 0" at a spot where a
|
||||
/// formation is plainly in front of the player is a registration gap,
|
||||
/// and is recorded as data rather than as silence. Without this line an
|
||||
/// absence of rejection lines would be ambiguous between "nothing was
|
||||
/// rejected" and "nothing was there", which is precisely the
|
||||
/// unfalsifiable-criterion trap this campaign has already been bitten
|
||||
/// by.</item>
|
||||
/// <item><c>[reach-obj]</c> — one per candidate, carrying its identity
|
||||
/// (mover guid, target entity id, GfxObj id, cell) and its
|
||||
/// <c>disposition</c>: <c>exempt-self</c>, <c>exempt-missile</c>,
|
||||
/// <c>exempt-rule</c>
|
||||
/// (<c>rejected-reach</c> is retired — #333 deleted the filter that
|
||||
/// produced it),
|
||||
/// <c>exempt-ethereal-stepdown</c>, <c>no-shape</c>,
|
||||
/// <c>bsp-only-skip</c>, or <c>tested:<result></c>. For BSP
|
||||
/// candidates it also carries the origin-measured distance the filter
|
||||
/// used, the centre-measured distance it should have used, the budget,
|
||||
/// the shortfall, and <c>wouldAcceptAtCenter</c>.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// Volume control (this site is hot — it runs per cell per transitional
|
||||
/// insert, and one resolve performs many inserts). <c>[reach-obj]</c> is
|
||||
/// de-duplicated per (mover, target, cell) and re-emits immediately
|
||||
/// whenever the disposition changes or the shortfall crosses a 0.5 m
|
||||
/// bucket, and otherwise at most once per second. <c>[reach-q]</c> is
|
||||
/// de-duplicated per (mover, cell) on the full tally tuple, so any change
|
||||
/// in what the cell yielded emits at once, and otherwise at most twice a
|
||||
/// second. Both therefore emit eagerly on change — which is exactly when
|
||||
/// the player walks into the formation — and stay quiet when nothing is
|
||||
/// happening. Nothing is aggregated away: every distinct state the query
|
||||
/// passes through appears.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Initial state from <c>ACDREAM_PROBE_REACH=1</c>. Zero cost when off
|
||||
/// (one static bool read per query).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static bool ProbeReachEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_REACH") == "1";
|
||||
|
||||
private static readonly object _reachGate = new();
|
||||
private static readonly Dictionary<(uint Mover, uint Entity, uint Cell), (long Ms, string Disp, int Bucket)>
|
||||
_reachSeenObj = new();
|
||||
private static readonly Dictionary<(uint Mover, uint Cell), (long Ms, long Tally)>
|
||||
_reachSeenQuery = new();
|
||||
|
||||
/// <summary>
|
||||
/// One <c>[reach-obj]</c> line. Self-guards on
|
||||
/// <see cref="ProbeReachEnabled"/>.
|
||||
/// </summary>
|
||||
/// <param name="moverId">The moving entity's guid — never omitted; a
|
||||
/// per-entity probe without an identity produced a wrong root cause once
|
||||
/// already (<c>feedback_probe_identity_attribution</c>).</param>
|
||||
/// <param name="disposition">What happened to this candidate. Use the
|
||||
/// documented vocabulary on <see cref="ProbeReachEnabled"/>.</param>
|
||||
/// <param name="distOrigin">What the reach filter measured: the distance
|
||||
/// from the swept sphere's current centre to the target's part ORIGIN.
|
||||
/// Negative when not applicable.</param>
|
||||
/// <param name="distCenter">What it should have measured: the distance to
|
||||
/// the target's physics-BSP root sphere CENTRE. Negative when not
|
||||
/// applicable.</param>
|
||||
/// <param name="budget">What the deleted filter's admission threshold WOULD
|
||||
/// have been, 2 m slack included. Since #333 no live predicate reads it; it
|
||||
/// is kept so the acceptance capture shows which candidates the old filter
|
||||
/// would have thrown away.</param>
|
||||
/// <param name="centerBudget">The same threshold WITHOUT the slack — the
|
||||
/// honest conservative bound once the real centre is used.</param>
|
||||
public static void LogReachCandidate(
|
||||
uint moverId,
|
||||
uint entityId,
|
||||
uint gfxObjId,
|
||||
uint cellId,
|
||||
ShadowCollisionType shape,
|
||||
string disposition,
|
||||
bool stepDown,
|
||||
float distOrigin,
|
||||
float distCenter,
|
||||
float objRadius,
|
||||
float sphereRadius,
|
||||
float movementLen,
|
||||
float budget,
|
||||
float centerBudget,
|
||||
Vector3 objPos,
|
||||
Vector3 bspCentreOffset,
|
||||
Vector3 currPos)
|
||||
{
|
||||
if (!ProbeReachEnabled) return;
|
||||
|
||||
float shortfall = distOrigin - budget;
|
||||
bool wouldAcceptAtCenter = distCenter >= 0f && distCenter <= centerBudget;
|
||||
int bucket = distOrigin < 0f ? 0 : (int)MathF.Floor(shortfall / 0.5f);
|
||||
long now = Environment.TickCount64;
|
||||
|
||||
lock (_reachGate)
|
||||
{
|
||||
var key = (moverId, entityId, cellId);
|
||||
if (_reachSeenObj.TryGetValue(key, out var prev)
|
||||
&& string.Equals(prev.Disp, disposition, StringComparison.Ordinal)
|
||||
&& prev.Bucket == bucket
|
||||
&& now - prev.Ms < 1000)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_reachSeenObj[key] = (now, disposition, bucket);
|
||||
}
|
||||
|
||||
Console.WriteLine(string.Format(
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
"[reach-obj] mover=0x{0:X8} obj=0x{1:X8} gfx=0x{2:X8} cell=0x{3:X8} " +
|
||||
"disp={4} shape={5} stepDown={6} distOrigin={7:F3} distCenter={8:F3} " +
|
||||
"objR={9:F3} sphereR={10:F3} move={11:F3} budget={12:F3} " +
|
||||
"centerBudget={13:F3} shortfall={14:F3} wouldAcceptAtCenter={15} " +
|
||||
"objPos=({16:F2},{17:F2},{18:F2}) " +
|
||||
"bspCentreOffset=({19:F2},{20:F2},{21:F2}) |bspCentreOffset|={22:F3} " +
|
||||
"currPos=({23:F2},{24:F2},{25:F2}) t={26}",
|
||||
moverId, entityId, gfxObjId, cellId,
|
||||
disposition, shape, stepDown, distOrigin, distCenter,
|
||||
objRadius, sphereRadius, movementLen, budget,
|
||||
centerBudget, shortfall, wouldAcceptAtCenter,
|
||||
objPos.X, objPos.Y, objPos.Z,
|
||||
bspCentreOffset.X, bspCentreOffset.Y, bspCentreOffset.Z, bspCentreOffset.Length(),
|
||||
currPos.X, currPos.Y, currPos.Z, now));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One <c>[reach-q]</c> per-cell query summary. MUST be called even when
|
||||
/// the cell yields zero entries — that is the whole point of the line.
|
||||
/// Self-guards on <see cref="ProbeReachEnabled"/>.
|
||||
/// </summary>
|
||||
/// <param name="inCell">Shadow entries the cell yielded, before any
|
||||
/// exemption. Zero here at a spot with visible geometry is outcome (b).</param>
|
||||
/// <param name="reached">Candidates that survived the exemptions and went
|
||||
/// on to a shape dispatch. Before #333 this was "and were measured by the
|
||||
/// reach filter"; the filter is gone, so the two are now the same set.</param>
|
||||
/// <param name="rejectedReach">Of those, how many the reach filter
|
||||
/// rejected — outcome (a). <b>Structurally 0 since #333 deleted the
|
||||
/// filter</b>, and retained precisely so that a post-fix capture reading 0
|
||||
/// is comparable against the pre-fix capture that read 7,225.</param>
|
||||
/// <param name="noShape">Candidates that passed the filter but resolved to
|
||||
/// no usable shape — outcome (c).</param>
|
||||
/// <param name="tested">Candidates that actually reached a shape test.</param>
|
||||
public static void LogReachQuery(
|
||||
uint moverId,
|
||||
uint cellId,
|
||||
bool stepDown,
|
||||
int inCell,
|
||||
int exempt,
|
||||
int reached,
|
||||
int rejectedReach,
|
||||
int noShape,
|
||||
int tested,
|
||||
int blocked,
|
||||
Vector3 currPos)
|
||||
{
|
||||
if (!ProbeReachEnabled) return;
|
||||
|
||||
// Tally fingerprint: any change in what this cell yielded re-emits at
|
||||
// once. Deliberately includes every counter, so a state the query
|
||||
// passes through cannot be swallowed by the throttle.
|
||||
long tally = (((long)inCell * 31 + exempt) * 31 + reached) * 31;
|
||||
tally = ((tally + rejectedReach) * 31 + noShape) * 31;
|
||||
tally = ((tally + tested) * 31 + blocked) * 31 + (stepDown ? 1 : 0);
|
||||
|
||||
long now = Environment.TickCount64;
|
||||
lock (_reachGate)
|
||||
{
|
||||
var key = (moverId, cellId);
|
||||
if (_reachSeenQuery.TryGetValue(key, out var prev)
|
||||
&& prev.Tally == tally
|
||||
&& now - prev.Ms < 500)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_reachSeenQuery[key] = (now, tally);
|
||||
}
|
||||
|
||||
Console.WriteLine(string.Format(
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
"[reach-q] mover=0x{0:X8} cell=0x{1:X8} stepDown={2} inCell={3} " +
|
||||
"exempt={4} reached={5} rejectedReach={6} noShape={7} tested={8} " +
|
||||
"blocked={9} pos=({10:F2},{11:F2},{12:F2}) t={13}",
|
||||
moverId, cellId, stepDown, inCell,
|
||||
exempt, reached, rejectedReach, noShape, tested,
|
||||
blocked, currPos.X, currPos.Y, currPos.Z, now));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// [support] / [geom] — #337 "what is holding this body up, and is the
|
||||
// collision geometry where the visual geometry is?" (2026-08-06 —
|
||||
// TEMPORARY, strip with the physics-probe family).
|
||||
//
|
||||
// WHY A NEW FAMILY RATHER THAN MORE [resolve].
|
||||
// ACDREAM_PROBE_RESOLVE already prints, per resolve: in/target/out
|
||||
// position + cell, ok, groundedIn, a THREE-VALUE contact-plane token
|
||||
// (valid / lastKnown / none), the collision normal + responsible entity if
|
||||
// something was hit, and one walkable-polygon bool. That is enough to say
|
||||
// THAT the body stopped. It cannot say WHAT held it up, because it prints
|
||||
// no plane normal, no plane height, no terrain sample, and no attribution
|
||||
// for who wrote the plane. So on a "wedged on a rock" capture, (a) terrain
|
||||
// holding the body, (b) an object surface holding it somewhere other than
|
||||
// where the rock is drawn, and (c) an unobstructed transition that simply
|
||||
// fails to advance all produce the SAME [resolve] line. Two diagnoses this
|
||||
// campaign have already been refuted by measurement; a probe that cannot
|
||||
// separate the remaining three is not worth the launch.
|
||||
//
|
||||
// WHAT SEPARATES THEM.
|
||||
// [support] — per resolve, per body (players AND corpses/NPCs, which is
|
||||
// what makes the fall-through case observable at all). It samples the
|
||||
// OUTDOOR TERRAIN independently at the body's own out-XY and prints
|
||||
// the contact plane's own height at that same XY. Two independent
|
||||
// heights at one point:
|
||||
// cpZ@out == terrZ → terrain is the support, whatever set it.
|
||||
// cpZ@out >> terrZ → an object surface is the support.
|
||||
// cpValid=false → nothing is; the body is in free fall.
|
||||
// `cpSrc` names the code site that wrote the plane, so the classifier
|
||||
// and the provenance are cross-checkable rather than one inferring
|
||||
// the other.
|
||||
// [geom] — once per GfxObj that comes near the mover. Compares the
|
||||
// object's PHYSICS BSP vertex cloud against its VISUAL mesh AABB in
|
||||
// the same local frame. If the collision geometry is absent, empty,
|
||||
// displaced, or the wrong size, this line says so directly. That is
|
||||
// the working hypothesis's refutation test: `verdict=coincident`
|
||||
// kills "the collision isn't where the visual is" outright, and no
|
||||
// amount of movement-side evidence is then needed to rule it out.
|
||||
//
|
||||
// Neither line gates, orders, or mutates anything. Both are pure reads.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Initial state from <c>ACDREAM_PROBE_SUPPORT=1</c>. Enables the
|
||||
/// <c>[support]</c> and <c>[geom]</c> lines described above. Zero cost when
|
||||
/// off (one static bool read per resolve and per collision candidate).
|
||||
/// TEMPORARY — strip with the rest of the physics-probe family.
|
||||
/// </summary>
|
||||
public static bool ProbeSupportEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_SUPPORT") == "1";
|
||||
|
||||
/// <summary>Vertical agreement window, in metres, inside which the contact
|
||||
/// plane's height and the terrain's height at the same XY are called the
|
||||
/// same surface.</summary>
|
||||
public const float SupportSameSurfaceZ = 0.05f;
|
||||
|
||||
/// <summary>Straight-up-component agreement window inside which the contact
|
||||
/// plane's tilt and the terrain triangle's tilt are called the same
|
||||
/// surface. 0.02 is roughly 1 degree near flat.</summary>
|
||||
public const float SupportSameSurfaceNormalZ = 0.02f;
|
||||
|
||||
private static readonly object _supportGate = new();
|
||||
private static readonly Dictionary<uint, (long Ms, long Signature)> _supportSeen = new();
|
||||
private static readonly HashSet<uint> _geomSeen = new();
|
||||
|
||||
// Contact-plane provenance latch. Ten distinct sites call
|
||||
// CollisionInfo.SetContactPlane — terrain, object BSP (graph and flat),
|
||||
// cell BSP, three water paths, and a straight-up fallback — and the plane
|
||||
// they write is indistinguishable once stored, so [support]'s
|
||||
// classification would have no independent cross-check.
|
||||
//
|
||||
// This deliberately does NOT live on CollisionInfo. That object's stored
|
||||
// members are compared member-for-member by the flat/graph differential
|
||||
// referee and by the transition-scratch reset poison test; adding a
|
||||
// diagnostic field there makes both oracles report a difference that is
|
||||
// not a difference, and the only way to keep them green is to teach them
|
||||
// to skip a member — which is how a referee quietly stops refereeing.
|
||||
// [ThreadStatic] because a headless host ticks several sessions in
|
||||
// parallel and physics is synchronous within each.
|
||||
[ThreadStatic] private static string? _contactPlaneSourceMember;
|
||||
[ThreadStatic] private static int _contactPlaneSourceLine;
|
||||
|
||||
/// <summary>
|
||||
/// Clear the provenance latch. Call once per resolve, before the sweep, so
|
||||
/// a resolve that establishes no plane reports <c>none</c> rather than the
|
||||
/// previous resolve's answer. No-op unless
|
||||
/// <see cref="ProbeSupportEnabled"/>.
|
||||
/// </summary>
|
||||
public static void BeginContactPlaneAttribution()
|
||||
{
|
||||
if (!ProbeSupportEnabled) return;
|
||||
_contactPlaneSourceMember = null;
|
||||
_contactPlaneSourceLine = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record the site asserting a contact plane. Called by
|
||||
/// <c>CollisionInfo.SetContactPlane</c> with compiler-supplied literals;
|
||||
/// self-guarded, so it is a single flag test when the probe is off and
|
||||
/// allocates nothing when it is on.
|
||||
/// </summary>
|
||||
public static void RecordContactPlaneSource(string member, int line)
|
||||
{
|
||||
if (!ProbeSupportEnabled) return;
|
||||
_contactPlaneSourceMember = member;
|
||||
_contactPlaneSourceLine = line;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>member:line</c> of the last site to assert a contact plane since
|
||||
/// <see cref="BeginContactPlaneAttribution"/>, or <c>"none"</c>.
|
||||
/// </summary>
|
||||
public static string ContactPlaneSource =>
|
||||
_contactPlaneSourceMember is { Length: > 0 } member
|
||||
? string.Concat(
|
||||
member,
|
||||
":",
|
||||
_contactPlaneSourceLine.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture))
|
||||
: "none";
|
||||
|
||||
/// <summary>
|
||||
/// Classify what is under the body. Pure function so the live probe and any
|
||||
/// offline reader agree on the vocabulary.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>none</c> — no contact plane: the body is unsupported.</item>
|
||||
/// <item><c>terrain</c> — the contact plane sits at the terrain's height
|
||||
/// AND shares its tilt.</item>
|
||||
/// <item><c>object</c> — the contact plane sits clear of the terrain: some
|
||||
/// collision surface other than the ground is the support.</item>
|
||||
/// <item><c>coplanar-tilt-mismatch</c> — same height, different tilt.
|
||||
/// Reported as its own answer rather than folded into either, because
|
||||
/// it is exactly what a collision mesh laid flat against the ground
|
||||
/// would look like and guessing between the two would be the third
|
||||
/// unverified diagnosis this campaign.</item>
|
||||
/// <item><c>no-terrain</c> — no outdoor terrain under this XY (indoors,
|
||||
/// or the landblock is not resident): the comparison is unavailable
|
||||
/// and is said so rather than defaulted.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static string ClassifySupport(
|
||||
bool contactPlaneValid,
|
||||
bool terrainSampled,
|
||||
float contactPlaneZAtXY,
|
||||
float contactPlaneNormalZ,
|
||||
float terrainZ,
|
||||
float terrainNormalZ)
|
||||
{
|
||||
if (!contactPlaneValid) return "none";
|
||||
if (!terrainSampled) return "no-terrain";
|
||||
|
||||
bool sameHeight = MathF.Abs(contactPlaneZAtXY - terrainZ) <= SupportSameSurfaceZ;
|
||||
bool sameTilt = MathF.Abs(contactPlaneNormalZ - terrainNormalZ) <= SupportSameSurfaceNormalZ;
|
||||
|
||||
if (sameHeight && sameTilt) return "terrain";
|
||||
if (sameHeight) return "coplanar-tilt-mismatch";
|
||||
return "object";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluate a plane's height at a given XY. Returns <see langword="false"/>
|
||||
/// when the plane is near-vertical, where a height is not defined — a wall
|
||||
/// is never a floor, and reporting a huge number for one would read as a
|
||||
/// displaced surface.
|
||||
/// </summary>
|
||||
public static bool TryPlaneZAt(in Plane plane, float x, float y, out float z)
|
||||
{
|
||||
float nz = plane.Normal.Z;
|
||||
if (MathF.Abs(nz) < 1e-4f)
|
||||
{
|
||||
z = float.NaN;
|
||||
return false;
|
||||
}
|
||||
z = -(plane.D + plane.Normal.X * x + plane.Normal.Y * y) / nz;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One <c>[support]</c> line. Self-guards on
|
||||
/// <see cref="ProbeSupportEnabled"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// Volume control: per mover, the line re-emits IMMEDIATELY on any change
|
||||
/// in the state signature — the support classification, the ok / contact /
|
||||
/// walkable / stalled bits, a 0.1 m change in the body's height, or a
|
||||
/// 0.1 m change in its height above terrain — and otherwise at most once
|
||||
/// per 250 ms. A body falling through geometry therefore produces a line
|
||||
/// every 10 cm of descent, and a body standing still produces four lines a
|
||||
/// second. Nothing is aggregated away.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <paramref name="moverId"/> is never omitted:
|
||||
/// <c>feedback_probe_identity_attribution</c> — a per-entity probe without
|
||||
/// an identity produced a wrong root cause once already, and this capture
|
||||
/// deliberately covers several bodies at once.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static void LogSupport(
|
||||
uint moverId,
|
||||
bool isPlayer,
|
||||
Vector3 inPos,
|
||||
uint inCell,
|
||||
Vector3 targetPos,
|
||||
Vector3 outPos,
|
||||
uint outCell,
|
||||
bool ok,
|
||||
bool groundedIn,
|
||||
bool contact,
|
||||
bool onWalkable,
|
||||
bool contactPlaneValid,
|
||||
Plane contactPlane,
|
||||
uint contactPlaneCellId,
|
||||
bool contactPlaneIsWater,
|
||||
string contactPlaneSource,
|
||||
bool lastKnownValid,
|
||||
Plane lastKnownPlane,
|
||||
bool terrainSampled,
|
||||
float terrainZ,
|
||||
Vector3 terrainNormal,
|
||||
uint terrainCellId,
|
||||
bool terrainIsWater,
|
||||
bool walkablePolygon,
|
||||
bool lastWalkablePolygon,
|
||||
float stepUpHeight,
|
||||
float stepDownHeight,
|
||||
Vector3 velocity)
|
||||
{
|
||||
if (!ProbeSupportEnabled) return;
|
||||
|
||||
float cpZ = float.NaN;
|
||||
bool cpZDefined = contactPlaneValid
|
||||
&& TryPlaneZAt(contactPlane, outPos.X, outPos.Y, out cpZ);
|
||||
if (!cpZDefined) cpZ = float.NaN;
|
||||
|
||||
float cpNz = contactPlaneValid ? contactPlane.Normal.Z : float.NaN;
|
||||
float terrNz = terrainSampled ? terrainNormal.Z : float.NaN;
|
||||
|
||||
string support = ClassifySupport(
|
||||
contactPlaneValid && cpZDefined,
|
||||
terrainSampled,
|
||||
cpZ,
|
||||
cpNz,
|
||||
terrainZ,
|
||||
terrNz);
|
||||
|
||||
float commanded = Vector3.Distance(inPos, targetPos);
|
||||
float moved = Vector3.Distance(inPos, outPos);
|
||||
// "The body was told to move and did not." The 1 cm floor is the
|
||||
// resolver's own no-op scale, not a tuned threshold.
|
||||
bool stalled = commanded > 0.01f && moved <= 0.01f;
|
||||
|
||||
float zAboveTerrain = terrainSampled ? outPos.Z - terrainZ : float.NaN;
|
||||
float cpAboveTerrain = terrainSampled && cpZDefined ? cpZ - terrainZ : float.NaN;
|
||||
|
||||
long now = Environment.TickCount64;
|
||||
long signature = support.GetHashCode();
|
||||
signature = signature * 31 + (ok ? 1 : 0);
|
||||
signature = signature * 31 + (groundedIn ? 1 : 0);
|
||||
signature = signature * 31 + (contact ? 1 : 0);
|
||||
signature = signature * 31 + (onWalkable ? 1 : 0);
|
||||
signature = signature * 31 + (contactPlaneValid ? 1 : 0);
|
||||
signature = signature * 31 + (stalled ? 1 : 0);
|
||||
signature = signature * 31 + (long)MathF.Floor(outPos.Z * 10f);
|
||||
signature = signature * 31 + (float.IsNaN(zAboveTerrain)
|
||||
? 0
|
||||
: (long)MathF.Floor(zAboveTerrain * 10f));
|
||||
|
||||
lock (_supportGate)
|
||||
{
|
||||
if (_supportSeen.TryGetValue(moverId, out var prev)
|
||||
&& prev.Signature == signature
|
||||
&& now - prev.Ms < 250)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_supportSeen[moverId] = (now, signature);
|
||||
}
|
||||
|
||||
var ci = System.Globalization.CultureInfo.InvariantCulture;
|
||||
Console.WriteLine(string.Format(ci,
|
||||
"[support] mover=0x{0:X8} isPlayer={1} t={2} support={3} " +
|
||||
"in=({4:F3},{5:F3},{6:F3}) inCell=0x{7:X8} " +
|
||||
"tgt=({8:F3},{9:F3},{10:F3}) out=({11:F3},{12:F3},{13:F3}) outCell=0x{14:X8} " +
|
||||
"ok={15} cmd={16:F3} moved={17:F3} stalled={18} " +
|
||||
"groundedIn={19} contact={20} onWalkable={21} " +
|
||||
"cpValid={22} cpSrc={23} cpCell=0x{24:X8} cpWater={25} " +
|
||||
"cpN=({26:F4},{27:F4},{28:F4}) cpNz={29:F4} floorZ={30:F4} cpWalkable={31} " +
|
||||
"cpZatOut={32:F3} " +
|
||||
"lkcpValid={33} lkcpNz={34:F4} " +
|
||||
"terrOk={35} terrZ={36:F3} terrNz={37:F4} terrWalkable={38} " +
|
||||
"terrCell=0x{39:X8} terrWater={40} " +
|
||||
"zAboveTerr={41:F3} cpAboveTerr={42:F3} " +
|
||||
"walkPoly={43} lastWalkPoly={44} stepUp={45:F3} stepDown={46:F3} " +
|
||||
"vel=({47:F3},{48:F3},{49:F3})",
|
||||
moverId, isPlayer, now, support,
|
||||
inPos.X, inPos.Y, inPos.Z, inCell,
|
||||
targetPos.X, targetPos.Y, targetPos.Z,
|
||||
outPos.X, outPos.Y, outPos.Z, outCell,
|
||||
ok, commanded, moved, stalled,
|
||||
groundedIn, contact, onWalkable,
|
||||
contactPlaneValid, contactPlaneSource, contactPlaneCellId, contactPlaneIsWater,
|
||||
contactPlaneValid ? contactPlane.Normal.X : float.NaN,
|
||||
contactPlaneValid ? contactPlane.Normal.Y : float.NaN,
|
||||
cpNz, cpNz, PhysicsGlobals.FloorZ,
|
||||
contactPlaneValid && cpNz >= PhysicsGlobals.FloorZ,
|
||||
cpZ,
|
||||
lastKnownValid, lastKnownValid ? lastKnownPlane.Normal.Z : float.NaN,
|
||||
terrainSampled, terrainZ, terrNz,
|
||||
terrainSampled && terrNz >= PhysicsGlobals.FloorZ,
|
||||
terrainCellId, terrainIsWater,
|
||||
zAboveTerrain, cpAboveTerrain,
|
||||
walkablePolygon, lastWalkablePolygon, stepUpHeight, stepDownHeight,
|
||||
velocity.X, velocity.Y, velocity.Z));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ask whether <c>[geom]</c> has already been emitted for this GfxObj.
|
||||
/// The line is a property of the ASSET, not of any moment, so once per
|
||||
/// process is the whole story and re-emitting it would bury the
|
||||
/// <c>[support]</c> stream.
|
||||
/// </summary>
|
||||
public static bool ShouldLogGeometry(uint gfxObjId)
|
||||
{
|
||||
if (!ProbeSupportEnabled) return false;
|
||||
lock (_supportGate)
|
||||
{
|
||||
return _geomSeen.Add(gfxObjId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One <c>[geom]</c> line: is this object's collision geometry where its
|
||||
/// visual geometry is? Caller MUST have claimed the id through
|
||||
/// <see cref="ShouldLogGeometry"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// The verdict vocabulary, and what each one settles:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>no-physics-bsp</c> / <c>empty-physics-bsp</c> — the object
|
||||
/// has no collision polygons at all. Everything a body does around it
|
||||
/// follows from that one fact and no movement-side theory is needed.</item>
|
||||
/// <item><c>no-visual-bounds</c> — the comparison could not be made. Said
|
||||
/// out loud rather than silently treated as agreement.</item>
|
||||
/// <item><c>displaced</c> — collision and visual are the same size but
|
||||
/// sit in different places. This is the working hypothesis, and this
|
||||
/// token is the only thing that confirms it.</item>
|
||||
/// <item><c>extent-mismatch</c> — same place, different size.</item>
|
||||
/// <item><c>coincident</c> — collision and visual agree. This REFUTES the
|
||||
/// working hypothesis for this object, and the cause is then on the
|
||||
/// movement side (terrain support, or the transition itself).</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static void LogGeometry(
|
||||
uint gfxObjId,
|
||||
uint entityId,
|
||||
int bspNodeCount,
|
||||
int bspPolygonCount,
|
||||
int bspVertexCount,
|
||||
Vector3 rootSphereOrigin,
|
||||
float rootSphereRadius,
|
||||
bool physicsBoundsValid,
|
||||
Vector3 physicsMin,
|
||||
Vector3 physicsMax,
|
||||
bool visualBoundsValid,
|
||||
Vector3 visualMin,
|
||||
Vector3 visualMax,
|
||||
float visualRadius,
|
||||
Vector3 entityWorldPosition,
|
||||
float entityScale,
|
||||
float registeredRadius)
|
||||
{
|
||||
Vector3 physExtent = physicsBoundsValid ? physicsMax - physicsMin : Vector3.Zero;
|
||||
Vector3 visExtent = visualBoundsValid ? visualMax - visualMin : Vector3.Zero;
|
||||
Vector3 physCentre = physicsBoundsValid
|
||||
? (physicsMin + physicsMax) * 0.5f
|
||||
: Vector3.Zero;
|
||||
Vector3 visCentre = visualBoundsValid
|
||||
? (visualMin + visualMax) * 0.5f
|
||||
: Vector3.Zero;
|
||||
|
||||
float centreDelta = physicsBoundsValid && visualBoundsValid
|
||||
? Vector3.Distance(physCentre, visCentre)
|
||||
: float.NaN;
|
||||
|
||||
// Tolerances are deliberately loose: this line answers "same place,
|
||||
// same size?" at the scale of a rock formation, not to the millimetre.
|
||||
// A physics hull is a coarse stand-in for the render mesh, so a
|
||||
// half-metre of centre drift or a 2x extent ratio is normal; what this
|
||||
// is looking for is the pathological case.
|
||||
float centreTolerance = visualBoundsValid
|
||||
? MathF.Max(0.5f, visualRadius * 0.25f)
|
||||
: 0.5f;
|
||||
|
||||
bool extentMismatch = false;
|
||||
if (physicsBoundsValid && visualBoundsValid)
|
||||
{
|
||||
for (int axis = 0; axis < 3; axis++)
|
||||
{
|
||||
float p = axis == 0 ? physExtent.X : axis == 1 ? physExtent.Y : physExtent.Z;
|
||||
float v = axis == 0 ? visExtent.X : axis == 1 ? visExtent.Y : visExtent.Z;
|
||||
// Flat axes (a floor plate) legitimately have ~0 extent in one
|
||||
// dimension on both sides; only compare where the visual has
|
||||
// real size.
|
||||
if (v < 0.1f) continue;
|
||||
float ratio = p / v;
|
||||
if (ratio is < 0.5f or > 2.0f) extentMismatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
string verdict =
|
||||
bspNodeCount == 0 ? "no-physics-bsp"
|
||||
: bspPolygonCount == 0 ? "empty-physics-bsp"
|
||||
: !visualBoundsValid ? "no-visual-bounds"
|
||||
: !physicsBoundsValid ? "no-physics-bounds"
|
||||
: centreDelta > centreTolerance ? "displaced"
|
||||
: extentMismatch ? "extent-mismatch"
|
||||
: "coincident";
|
||||
|
||||
var ci = System.Globalization.CultureInfo.InvariantCulture;
|
||||
Console.WriteLine(string.Format(ci,
|
||||
"[geom] gfx=0x{0:X8} verdict={1} entity=0x{2:X8} t={3} " +
|
||||
"bspNodes={4} bspPolys={5} bspVerts={6} " +
|
||||
"rootSphere=({7:F3},{8:F3},{9:F3}) rootR={10:F3} registeredR={11:F3} " +
|
||||
"physMin=({12:F3},{13:F3},{14:F3}) physMax=({15:F3},{16:F3},{17:F3}) " +
|
||||
"physExt=({18:F3},{19:F3},{20:F3}) " +
|
||||
"visMin=({21:F3},{22:F3},{23:F3}) visMax=({24:F3},{25:F3},{26:F3}) " +
|
||||
"visExt=({27:F3},{28:F3},{29:F3}) visR={30:F3} " +
|
||||
"centreDelta={31:F3} centreTol={32:F3} extentMismatch={33} " +
|
||||
"objPos=({34:F2},{35:F2},{36:F2}) scale={37:F3} " +
|
||||
"physWorldZ=[{38:F2},{39:F2}] visWorldZ=[{40:F2},{41:F2}]",
|
||||
gfxObjId, verdict, entityId, Environment.TickCount64,
|
||||
bspNodeCount, bspPolygonCount, bspVertexCount,
|
||||
rootSphereOrigin.X, rootSphereOrigin.Y, rootSphereOrigin.Z,
|
||||
rootSphereRadius, registeredRadius,
|
||||
physicsMin.X, physicsMin.Y, physicsMin.Z,
|
||||
physicsMax.X, physicsMax.Y, physicsMax.Z,
|
||||
physExtent.X, physExtent.Y, physExtent.Z,
|
||||
visualMin.X, visualMin.Y, visualMin.Z,
|
||||
visualMax.X, visualMax.Y, visualMax.Z,
|
||||
visExtent.X, visExtent.Y, visExtent.Z, visualRadius,
|
||||
centreDelta, centreTolerance, extentMismatch,
|
||||
entityWorldPosition.X, entityWorldPosition.Y, entityWorldPosition.Z,
|
||||
entityScale,
|
||||
// Rotation is NOT applied to these two world Z ranges: an
|
||||
// axis-aligned box is not rotation-invariant, so a rotated object
|
||||
// would report a box that is merely indicative. Both sides get the
|
||||
// SAME treatment, so their AGREEMENT (the thing being measured)
|
||||
// stays exact regardless.
|
||||
entityWorldPosition.Z + physicsMin.Z * entityScale,
|
||||
entityWorldPosition.Z + physicsMax.Z * entityScale,
|
||||
entityWorldPosition.Z + visualMin.Z * entityScale,
|
||||
entityWorldPosition.Z + visualMax.Z * entityScale));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the collision-vs-visual comparison for one GfxObj straight from
|
||||
/// the SAME prepared assets the resolver itself queries, and emit its
|
||||
/// <c>[geom]</c> line. Going through the production accessors is the point:
|
||||
/// AP-156's lesson was that a probe reading geometry by a second route can
|
||||
/// report a shape the registry never emitted. Caller MUST have claimed the
|
||||
/// id through <see cref="ShouldLogGeometry"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// The physics box is measured over the vertices of the polygons the BSP
|
||||
/// actually indexes, not over the whole polygon table — a table can carry
|
||||
/// rows no node references, and including those would report collision
|
||||
/// geometry that no query can ever reach.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static void LogGeometryFromAssets(
|
||||
uint gfxObjId,
|
||||
uint entityId,
|
||||
FlatGfxObjCollisionAsset? flat,
|
||||
GfxObjVisualBounds? visual,
|
||||
Vector3 entityWorldPosition,
|
||||
float entityScale,
|
||||
float registeredRadius)
|
||||
{
|
||||
int nodeCount = 0;
|
||||
int polygonCount = 0;
|
||||
int vertexCount = 0;
|
||||
Vector3 rootOrigin = Vector3.Zero;
|
||||
float rootRadius = 0f;
|
||||
bool physBoundsValid = false;
|
||||
var physMin = new Vector3(float.PositiveInfinity);
|
||||
var physMax = new Vector3(float.NegativeInfinity);
|
||||
|
||||
FlatPhysicsBsp? bsp = flat?.PhysicsBsp;
|
||||
if (bsp is { RootIndex: >= 0 } && bsp.Nodes.Length > 0)
|
||||
{
|
||||
nodeCount = bsp.Nodes.Length;
|
||||
rootOrigin = bsp.Nodes[bsp.RootIndex].BoundingSphere.Origin;
|
||||
rootRadius = bsp.Nodes[bsp.RootIndex].BoundingSphere.Radius;
|
||||
|
||||
FlatPolygonTable table = bsp.PolygonTable;
|
||||
foreach (FlatPhysicsBspNode node in bsp.Nodes)
|
||||
{
|
||||
FlatIndexRange range = node.PolygonIndexRange;
|
||||
for (int i = range.Start; i < range.EndExclusive; i++)
|
||||
{
|
||||
int polygonIndex = bsp.PolygonIndexStream[i];
|
||||
if ((uint)polygonIndex >= (uint)table.Polygons.Length) continue;
|
||||
|
||||
polygonCount++;
|
||||
FlatIndexRange vertices = table.Polygons[polygonIndex].VertexRange;
|
||||
for (int v = vertices.Start; v < vertices.EndExclusive; v++)
|
||||
{
|
||||
Vector3 p = table.Vertices[v];
|
||||
vertexCount++;
|
||||
physMin = Vector3.Min(physMin, p);
|
||||
physMax = Vector3.Max(physMax, p);
|
||||
physBoundsValid = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!physBoundsValid)
|
||||
{
|
||||
physMin = Vector3.Zero;
|
||||
physMax = Vector3.Zero;
|
||||
}
|
||||
|
||||
LogGeometry(
|
||||
gfxObjId: gfxObjId,
|
||||
entityId: entityId,
|
||||
bspNodeCount: nodeCount,
|
||||
bspPolygonCount: polygonCount,
|
||||
bspVertexCount: vertexCount,
|
||||
rootSphereOrigin: rootOrigin,
|
||||
rootSphereRadius: rootRadius,
|
||||
physicsBoundsValid: physBoundsValid,
|
||||
physicsMin: physMin,
|
||||
physicsMax: physMax,
|
||||
visualBoundsValid: visual is not null,
|
||||
visualMin: visual?.Min ?? Vector3.Zero,
|
||||
visualMax: visual?.Max ?? Vector3.Zero,
|
||||
visualRadius: visual?.Radius ?? 0f,
|
||||
entityWorldPosition: entityWorldPosition,
|
||||
entityScale: entityScale,
|
||||
registeredRadius: registeredRadius);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Teleport-foundation timing probe (2026-06-22 — REMOVABLE diagnostic).
|
||||
/// Emits one <c>[tp-probe]</c> line per teleport-pipeline event with a
|
||||
|
|
@ -1356,6 +2138,20 @@ public static class PhysicsDiagnostics
|
|||
ProbePlacementFailEnabled = false;
|
||||
ProbeSweptEnabled = false;
|
||||
ProbeStepWalkEnabled = false;
|
||||
ProbeReachEnabled = false;
|
||||
lock (_reachGate)
|
||||
{
|
||||
_reachSeenObj.Clear();
|
||||
_reachSeenQuery.Clear();
|
||||
}
|
||||
ProbeSupportEnabled = false;
|
||||
_contactPlaneSourceMember = null;
|
||||
_contactPlaneSourceLine = 0;
|
||||
lock (_supportGate)
|
||||
{
|
||||
_supportSeen.Clear();
|
||||
_geomSeen.Clear();
|
||||
}
|
||||
ProbeTeleportEnabled = false;
|
||||
ProbeRemoteTeleportEnabled = false;
|
||||
ProbeRemoteLandingEnabled = false;
|
||||
|
|
|
|||
|
|
@ -1913,6 +1913,15 @@ public sealed class PhysicsEngine
|
|||
? PhysicsResolveCapture.Snapshot(body)
|
||||
: null;
|
||||
|
||||
// #337 (2026-08-06 — TEMPORARY): arm the [support] probe's
|
||||
// contact-plane provenance latch for this resolve, ahead of everything
|
||||
// including the carried-plane seed below. The seed is itself one of
|
||||
// the ten sites that assert a plane, so it stamps its own name and a
|
||||
// capture can read `cpSrc=ResolveWithTransition:<line>` as "carried
|
||||
// from the body, nothing re-derived it this resolve" without needing a
|
||||
// sentinel value for that case. No-op when the probe is off.
|
||||
PhysicsDiagnostics.BeginContactPlaneAttribution();
|
||||
|
||||
var transition = RentTransition();
|
||||
try
|
||||
{
|
||||
|
|
@ -2299,6 +2308,67 @@ public sealed class PhysicsEngine
|
|||
$"[resolve] ent=0x{movingEntityId:X8} in=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cell=0x{cellId:X8} tgt=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) out=({probePost.X:F3},{probePost.Y:F3},{probePost.Z:F3}) cell=0x{sp.CheckCellId:X8} ok={ok} groundedIn={isOnGround} cp={probeCp} hit={probeHit} walkable={sp.HasLastWalkablePolygon}"));
|
||||
}
|
||||
|
||||
// #337 [support] probe (2026-08-06 — TEMPORARY, strip with the
|
||||
// physics-probe family). Runs for EVERY body, not just the player:
|
||||
// a corpse sinking through geometry is a plain physics body with
|
||||
// no player-specific logic, so it is the cheapest possible control
|
||||
// on whether the movement code or the geometry is at fault, and it
|
||||
// is invisible to any player-filtered probe.
|
||||
//
|
||||
// The terrain sample below is INDEPENDENT of whatever the sweep
|
||||
// decided — it asks the landblock directly what the ground height
|
||||
// is under the body's own out-XY. Pairing that with the contact
|
||||
// plane's height at the same XY is what separates "terrain is
|
||||
// holding this body up" from "some object surface is". Read-only:
|
||||
// SampleTerrainWalkable takes no locks, mutates nothing, and is
|
||||
// not on the resolve's committed path.
|
||||
if (PhysicsDiagnostics.ProbeSupportEnabled)
|
||||
{
|
||||
Vector3 outPos = sp.CheckPos;
|
||||
TerrainWalkableSample? terrain =
|
||||
SampleTerrainWalkable(outPos.X, outPos.Y);
|
||||
|
||||
bool terrainSampled = terrain.HasValue
|
||||
&& PhysicsDiagnostics.TryPlaneZAt(
|
||||
terrain.Value.Plane, outPos.X, outPos.Y, out _);
|
||||
float terrainZ = float.NaN;
|
||||
if (terrainSampled)
|
||||
{
|
||||
PhysicsDiagnostics.TryPlaneZAt(
|
||||
terrain!.Value.Plane, outPos.X, outPos.Y, out terrainZ);
|
||||
}
|
||||
|
||||
PhysicsDiagnostics.LogSupport(
|
||||
moverId: movingEntityId,
|
||||
isPlayer: (moverFlags & ObjectInfoState.IsPlayer) != 0,
|
||||
inPos: currentPos,
|
||||
inCell: cellId,
|
||||
targetPos: targetPos,
|
||||
outPos: outPos,
|
||||
outCell: sp.CheckCellId,
|
||||
ok: ok,
|
||||
groundedIn: isOnGround,
|
||||
contact: transition.ObjectInfo.Contact,
|
||||
onWalkable: transition.ObjectInfo.OnWalkable,
|
||||
contactPlaneValid: ci.ContactPlaneValid,
|
||||
contactPlane: ci.ContactPlane,
|
||||
contactPlaneCellId: ci.ContactPlaneCellId,
|
||||
contactPlaneIsWater: ci.ContactPlaneIsWater,
|
||||
contactPlaneSource: PhysicsDiagnostics.ContactPlaneSource,
|
||||
lastKnownValid: ci.LastKnownContactPlaneValid,
|
||||
lastKnownPlane: ci.LastKnownContactPlane,
|
||||
terrainSampled: terrainSampled,
|
||||
terrainZ: terrainZ,
|
||||
terrainNormal: terrain?.Plane.Normal ?? Vector3.Zero,
|
||||
terrainCellId: terrain?.CellId ?? 0u,
|
||||
terrainIsWater: terrain?.IsWater ?? false,
|
||||
walkablePolygon: sp.HasWalkablePolygon,
|
||||
lastWalkablePolygon: sp.HasLastWalkablePolygon,
|
||||
stepUpHeight: stepUpHeight,
|
||||
stepDownHeight: stepDownHeight,
|
||||
velocity: body?.Velocity ?? Vector3.Zero);
|
||||
}
|
||||
|
||||
// Phase W Stage 0 (2026-06-02): [cell-swept] probe — swept cell vs static-derived cell.
|
||||
// Emits before the ResolveResult is built so it shows what BOTH paths would return.
|
||||
// No ResolveCellId call here (it has a CellGraph.CurrCell side effect). No behavior change.
|
||||
|
|
|
|||
|
|
@ -430,12 +430,29 @@ public sealed class ShadowObjectRegistry
|
|||
///
|
||||
/// <para>
|
||||
/// 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
|
||||
/// follow <c>CPhysicsObj::calc_cross_cells</c>' own EXCLUSIVE priority —
|
||||
/// physics-BSP parts, else CylSpheres, else the remaining shapes — see
|
||||
/// <see cref="BuildFloodSpheres"/> for the disassembly. A BSP part
|
||||
/// contributes its ROOT BOUNDING SPHERE placed at its real center
|
||||
/// (<see cref="ShadowShape.BoundsCenter"/>), not at the part origin.
|
||||
/// per OBJECT, not per part). WHICH flood is retail's own exclusive
|
||||
/// dispatch on <c>HAS_PHYSICS_BSP_PS</c>
|
||||
/// (<c>CPhysicsObj::calc_cross_cells</c> @0x00515230,
|
||||
/// <c>0x00515285 test dword [esi+0xa8],0x10000</c> /
|
||||
/// <c>0x0051528f jne 0x515305</c>):
|
||||
/// </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
|
||||
/// add_shadows_to_cells (0x00514ae0) + CPartArray::AddPartsShadow.
|
||||
/// </para>
|
||||
|
|
@ -460,9 +477,35 @@ public sealed class ShadowObjectRegistry
|
|||
: DeriveOutdoorSeed(entityWorldPos, worldOffsetX, worldOffsetY, landblockId);
|
||||
if (seed == 0u) return;
|
||||
|
||||
var floodSpheres = BuildFloodSpheres(entityWorldPos, entityWorldRot, shapes);
|
||||
var cellSet = CellTransit.BuildShadowCellSet(
|
||||
FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic);
|
||||
// Retail's exclusive dispatch, mirrored: CPartArray::CacheHasPhysicsBSP
|
||||
// (0x00518110) ORs 0x10000 on the first part whose gfxobj carries a
|
||||
// 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;
|
||||
|
||||
DeregisterCore(entityId, publishMutation: false);
|
||||
|
|
@ -598,25 +641,13 @@ public sealed class ShadowObjectRegistry
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail cross-cell dispatch, <c>CPhysicsObj::calc_cross_cells</c>
|
||||
/// @0x00515230, in retail's own priority order:
|
||||
/// Flood spheres for an object with NO physics BSP — retail's cylsphere
|
||||
/// 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">
|
||||
/// <item>BSP-bearing (<c>0x00515285 test dword [esi+0xa8],0x10000</c> /
|
||||
/// <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) →
|
||||
/// <item>cylspheres (<c>0x00515298 GetNumCylsphere</c> non-zero) →
|
||||
/// <c>CObjCell::find_cell_list</c> @0x0052b9f0 over the cylsphere array;
|
||||
/// each contributes one sphere at its world BASE point with the cylinder
|
||||
/// radius, capped at 10.</item>
|
||||
|
|
@ -626,15 +657,17 @@ public sealed class ShadowObjectRegistry
|
|||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// The BSP-first rule is redundant for every shape list acdream produces
|
||||
/// today — <see cref="ShadowShapeBuilder.FromSetup"/> dispatches at
|
||||
/// emission (AP-152) and both landblock-static publishers emit
|
||||
/// homogeneous lists — exactly as
|
||||
/// <c>Transition.BspOnlyDispatch</c> is redundant at the query site. It is
|
||||
/// kept because retail genuinely dispatches here, and because a producer
|
||||
/// that handed this method a mixed list would otherwise flood a
|
||||
/// BSP-bearing object from its primitive and silently place it in the
|
||||
/// wrong shadow cells (the #98 / #168 symptom class).
|
||||
/// #334: there is no BSP arm here any more, and there must not be one.
|
||||
/// The BSP branch is a structurally different algorithm over BOXES
|
||||
/// (<see cref="CellTransit.BuildShadowCellSetFromParts"/>), and
|
||||
/// <see cref="RegisterMultiPart"/> routes to it before this method is
|
||||
/// reached. The arm this method used to carry — "a BSP part contributes
|
||||
/// its ROOT BOUNDING SPHERE placed at its real center" — described
|
||||
/// retail's INDOOR portal reject, not its outdoor expansion, and using it
|
||||
/// for both is what capped every BSP object's outdoor reach at a 3×3
|
||||
/// 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>
|
||||
/// </summary>
|
||||
private static List<DatReaderWriter.Types.Sphere> BuildFloodSpheres(
|
||||
|
|
@ -645,21 +678,16 @@ public sealed class ShadowObjectRegistry
|
|||
const int RetailSphereCap = 10;
|
||||
|
||||
var spheres = new List<DatReaderWriter.Types.Sphere>();
|
||||
bool anyBsp = false;
|
||||
bool anyCyl = false;
|
||||
foreach (var s in shapes)
|
||||
{
|
||||
if (s.CollisionType == ShadowCollisionType.BSP) anyBsp = true;
|
||||
else if (s.CollisionType == ShadowCollisionType.Cylinder) anyCyl = true;
|
||||
if (s.CollisionType == ShadowCollisionType.Cylinder) anyCyl = true;
|
||||
}
|
||||
|
||||
// Retail's branch, chosen once: BSP-bbox, else cylspheres, else the
|
||||
// sorting sphere (which acdream approximates with the remaining
|
||||
// shapes' bounding spheres — AP-157).
|
||||
ShadowCollisionType? only =
|
||||
anyBsp ? ShadowCollisionType.BSP
|
||||
: anyCyl ? ShadowCollisionType.Cylinder
|
||||
: null;
|
||||
// Retail's branch, chosen once: cylspheres, else the sorting sphere
|
||||
// (which acdream approximates with the Sphere shapes — AP-157).
|
||||
ShadowCollisionType only =
|
||||
anyCyl ? ShadowCollisionType.Cylinder : ShadowCollisionType.Sphere;
|
||||
|
||||
// The 10-sphere clamp belongs to the CYLSPHERE branch alone.
|
||||
// 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
|
||||
// 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
|
||||
// @0x00510fc0 -> CPartArray::calc_cross_cells_static @0x00518160 ->
|
||||
// CEnvCell::find_transit_cells @0x0052cae0 walks every part, bounded
|
||||
// only by num_parts. Clamping it dropped parts 11..N out of the flood
|
||||
// entirely: 7 installed Setups carry more than 10 physics-BSP parts
|
||||
// (max 49, Setup 0x02001A91), and landblock-baked part arrays — stair
|
||||
// runs, fences, rock clusters — routinely do.
|
||||
//
|
||||
// only == null (the sorting-sphere branch): int.MaxValue is NOT a
|
||||
// retail port and the addresses above do not justify it. Retail's
|
||||
// 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).
|
||||
// Sorting-sphere branch: int.MaxValue is NOT a retail port and the
|
||||
// addresses above do not justify it. Retail's 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;
|
||||
|
||||
foreach (var s in shapes)
|
||||
{
|
||||
if (only is { } required && s.CollisionType != required)
|
||||
if (s.CollisionType != only)
|
||||
continue;
|
||||
if (spheres.Count >= cap)
|
||||
break;
|
||||
|
||||
// Place the sphere where the GEOMETRY is, not where the part
|
||||
// origin is. Composed exactly as the ShadowEntry rows below are
|
||||
// (partWorldPos / partWorldRot), then offset by the shape's own
|
||||
// BoundsCenter — retail's CEnvCell::find_transit_cells @0x0052cae0
|
||||
// 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.
|
||||
// A primitive's LocalPosition already IS its centre, so
|
||||
// BoundsCenter is Zero; the composition is kept identical to the
|
||||
// emitted ShadowEntry rows so the flood and the geometry can never
|
||||
// disagree about where the shape is.
|
||||
var partWorldPos = entityWorldPos + Vector3.Transform(s.LocalPosition, entityWorldRot);
|
||||
var partWorldRot = entityWorldRot * s.LocalRotation;
|
||||
var world = partWorldPos + Vector3.Transform(s.BoundsCenter, partWorldRot);
|
||||
|
|
@ -715,6 +731,66 @@ public sealed class ShadowObjectRegistry
|
|||
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>
|
||||
/// Derive the outdoor landcell id under a world position — the implicit
|
||||
/// 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"/>,
|
||||
/// <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
|
||||
/// shape's radius and its bounding-sphere CENTRE arrive as one
|
||||
/// <see cref="FlatCollisionSphere"/> value and are scaled together inside
|
||||
/// <see cref="Bsp"/>, so no call site — present or future — can take the
|
||||
/// radius while dropping the origin. That split is exactly what produced
|
||||
/// AP-156, and with the old public 7-argument constructor a new BSP producer
|
||||
/// could have reintroduced it silently and green.
|
||||
/// shape's radius, its bounding-sphere CENTRE, and its authored bounding BOX
|
||||
/// arrive as one <see cref="ShadowPartGeometry"/> value and are scaled together
|
||||
/// inside <see cref="Bsp"/>, so no call site — present or future — can take one
|
||||
/// while dropping another. Those splits are exactly what produced AP-156 (the
|
||||
/// centre dropped) and #334 (the box never resolved at all); with a public
|
||||
/// positional constructor a new BSP producer could reintroduce either silently
|
||||
/// and green.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public readonly record struct ShadowShape
|
||||
|
|
@ -38,7 +39,9 @@ public readonly record struct ShadowShape
|
|||
ShadowCollisionType collisionType,
|
||||
float radius,
|
||||
float cylHeight,
|
||||
Vector3 boundsCenter)
|
||||
Vector3 boundsCenter,
|
||||
Vector3 localBoundsMin,
|
||||
Vector3 localBoundsMax)
|
||||
{
|
||||
GfxObjId = gfxObjId;
|
||||
LocalPosition = localPosition;
|
||||
|
|
@ -48,6 +51,8 @@ public readonly record struct ShadowShape
|
|||
Radius = radius;
|
||||
CylHeight = cylHeight;
|
||||
BoundsCenter = boundsCenter;
|
||||
LocalBoundsMin = localBoundsMin;
|
||||
LocalBoundsMax = localBoundsMax;
|
||||
}
|
||||
|
||||
/// <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; }
|
||||
|
||||
/// <summary>
|
||||
/// One physics-BSP part. <paramref name="localBounds"/> is the part
|
||||
/// GfxObj's physics-BSP ROOT bounding sphere in the GfxObj's OWN frame,
|
||||
/// unscaled — retail's <c>CGfxObj::physics_sphere</c>. Radius and centre
|
||||
/// are scaled together here, which is the whole point of taking them as
|
||||
/// one value.
|
||||
/// The shape's AXIS-ALIGNED BOX in the same local frame as
|
||||
/// <see cref="BoundsCenter"/>, already entity-scaled. For a BSP shape this
|
||||
/// is retail's <c>CGfxObj::gfx_bound_box</c> — the AABB of the GfxObj's
|
||||
/// vertex array, which <c>CPhysicsPart::GetBoundingBox</c> @0x0050d600
|
||||
/// 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>
|
||||
public static ShadowShape Bsp(
|
||||
uint gfxObjId,
|
||||
Vector3 localPosition,
|
||||
Quaternion localRotation,
|
||||
float scale,
|
||||
FlatCollisionSphere localBounds)
|
||||
ShadowPartGeometry localGeometry)
|
||||
=> new(
|
||||
gfxObjId,
|
||||
localPosition,
|
||||
localRotation,
|
||||
scale,
|
||||
ShadowCollisionType.BSP,
|
||||
localBounds.Radius * scale,
|
||||
localGeometry.Sphere.Radius * scale,
|
||||
0f,
|
||||
localBounds.Origin * scale);
|
||||
localGeometry.Sphere.Origin * scale,
|
||||
localGeometry.BoxMin * scale,
|
||||
localGeometry.BoxMax * scale);
|
||||
|
||||
/// <summary>
|
||||
/// One Setup CylSphere. <paramref name="localPosition"/> already IS the
|
||||
|
|
@ -148,7 +177,9 @@ public readonly record struct ShadowShape
|
|||
ShadowCollisionType.Cylinder,
|
||||
radius,
|
||||
cylHeight,
|
||||
Vector3.Zero);
|
||||
Vector3.Zero,
|
||||
new Vector3(-radius, -radius, 0f),
|
||||
new Vector3(radius, radius, cylHeight));
|
||||
|
||||
/// <summary>
|
||||
/// One Setup Sphere. <paramref name="localPosition"/> already IS the
|
||||
|
|
@ -168,5 +199,7 @@ public readonly record struct ShadowShape
|
|||
ShadowCollisionType.Sphere,
|
||||
radius,
|
||||
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.
|
||||
/// Null or short lists fall back to the Setup identity.</param>
|
||||
/// <param name="physicsBspBounds">The part GfxObj's physics-BSP ROOT
|
||||
/// bounding sphere — retail's <c>CGfxObj::physics_sphere</c>, which is
|
||||
/// literally <c>BSPTREE::GetSphere(physics_bsp)</c> @0x005397e0. Supplies
|
||||
/// BOTH the emitted <see cref="ShadowShape.Radius"/> and its
|
||||
/// <see cref="ShadowShape.BoundsCenter"/>, from one call, so the sphere's
|
||||
/// size can never be carried while its position is dropped. 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>
|
||||
/// bounding sphere AND its authored vertex-array box — retail's
|
||||
/// <c>CGfxObj::physics_sphere</c> (<c>BSPTREE::GetSphere(physics_bsp)</c>
|
||||
/// @0x005397e0) and <c>CGfxObj::gfx_bound_box</c>
|
||||
/// (<c>CPhysicsPart::GetBoundingBox</c> @0x0050d600), as ONE
|
||||
/// <see cref="ShadowPartGeometry"/>. Supplies the emitted
|
||||
/// <see cref="ShadowShape.Radius"/>, <see cref="ShadowShape.BoundsCenter"/>
|
||||
/// and <see cref="ShadowShape.LocalBoundsMin"/>/<c>Max</c> from one call,
|
||||
/// 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(
|
||||
Setup setup,
|
||||
float entScale,
|
||||
Func<uint, bool> hasPhysicsBsp,
|
||||
IReadOnlyList<Frame>? partPoseOverride = 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 (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.
|
||||
// Absent bounds keep the loose-but-safe 2 m placeholder, centred
|
||||
// on the part origin because nothing better is known.
|
||||
// ShadowShape.Bsp scales radius and centre together.
|
||||
FlatCollisionSphere bounds =
|
||||
// ShadowShape.Bsp scales radius, centre and box together.
|
||||
//
|
||||
// #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)
|
||||
?? new FlatCollisionSphere(Vector3.Zero, 2f);
|
||||
?? ShadowPartGeometry.Create(
|
||||
new FlatCollisionSphere(Vector3.Zero, 2f),
|
||||
null);
|
||||
|
||||
result.Add(ShadowShape.Bsp(
|
||||
gfxObjId: gfxId,
|
||||
localPosition: new Vector3(partFrame.Origin.X, partFrame.Origin.Y, partFrame.Origin.Z) * entScale,
|
||||
localRotation: partFrame.Orientation,
|
||||
scale: entScale,
|
||||
localBounds: bounds));
|
||||
localGeometry: geometry));
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -308,12 +322,21 @@ public static class ShadowShapeBuilder
|
|||
phys.BoundingSphere?.Origin ?? Vector3.Zero,
|
||||
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(
|
||||
gfxObjId: meshRef.GfxObjId,
|
||||
localPosition: pPos,
|
||||
localRotation: pRot,
|
||||
scale: partScale,
|
||||
localBounds: localBounds));
|
||||
localGeometry: geometry));
|
||||
}
|
||||
|
||||
return shapes;
|
||||
|
|
|
|||
|
|
@ -443,8 +443,32 @@ public sealed class CollisionInfo
|
|||
/// </summary>
|
||||
internal int ContactPlaneWriteCount { get; private set; }
|
||||
|
||||
public void SetContactPlane(Plane plane, uint cellId, bool isWater = false)
|
||||
public void SetContactPlane(
|
||||
Plane plane,
|
||||
uint cellId,
|
||||
bool isWater = false,
|
||||
// #337 [support] attribution — recorded on PhysicsDiagnostics, not on
|
||||
// this object; see the comment in the body for why. Compiler-supplied
|
||||
// literals: no call site passes these explicitly and none needs to.
|
||||
[System.Runtime.CompilerServices.CallerMemberName] string sourceMember = "",
|
||||
[System.Runtime.CompilerServices.CallerLineNumber] int sourceLine = 0)
|
||||
{
|
||||
// #337 attribution (2026-08-06 — TEMPORARY, strip with the probe
|
||||
// family). Recorded ABOVE the no-op guard on purpose: the meaning is
|
||||
// "the last site that ASSERTED this plane", not "the site that first
|
||||
// differed from the previous value" — a sweep that re-derives the
|
||||
// identical plane it was seeded with has still told you where the
|
||||
// plane comes from, and that is the fact the capture needs.
|
||||
//
|
||||
// It lives on PhysicsDiagnostics, NOT on this object. CollisionInfo's
|
||||
// stored members are compared member-for-member by the flat/graph
|
||||
// differential referee and by the scratch-reset poison test; a
|
||||
// diagnostic field there is state those oracles must then be taught to
|
||||
// ignore, which is how a referee stops refereeing. The latch is
|
||||
// [ThreadStatic] and is armed once per resolve — see
|
||||
// PhysicsDiagnostics.BeginContactPlaneAttribution.
|
||||
PhysicsDiagnostics.RecordContactPlaneSource(sourceMember, sourceLine);
|
||||
|
||||
// A6.P3 slice 2 (2026-05-22): no-op-if-unchanged guard. Closes
|
||||
// issue #96 (per-tick CP-write blowup) without removing the
|
||||
// PhysicsEngine.cs L622 seed that step_up depends on. When the
|
||||
|
|
@ -3695,9 +3719,41 @@ public sealed class Transition
|
|||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The per-object distance pre-check below is the analog of the part
|
||||
/// sorting-sphere early-outs inside retail's
|
||||
/// <c>CPhysicsObj::FindObjCollisions</c> — response-neutral, pure perf.
|
||||
/// <b>There is no per-object distance pre-check here, because retail has
|
||||
/// none.</b> An earlier comment on this method claimed the filter that used
|
||||
/// to sit in the candidate loop was "the analog of the part sorting-sphere
|
||||
/// early-outs inside retail's <c>CPhysicsObj::FindObjCollisions</c> —
|
||||
/// response-neutral, pure perf". Both halves were false, and it cost
|
||||
/// issues #333 and #337. Disassembled from the PDB-paired v11.4186 binary
|
||||
/// (CodeView GUID <c>9e847e2f-777c-4bd9-886c-22256bb87f32</c>):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>CObjCell::find_obj_collisions</c> @0x0052b750 walks
|
||||
/// <c>shadow_object_list</c> and calls <c>CPhysicsObj::FindObjCollisions</c>
|
||||
/// (@0x0050f050, <c>0x0052b78b call</c>) UNCONDITIONALLY; its only
|
||||
/// early-out is <c>insert_type == INITIAL_PLACEMENT_INSERT</c>
|
||||
/// (<c>0x0052b759 cmp dword [ebx+0x174],2</c>).</item>
|
||||
/// <item><c>CPhysicsObj::FindObjCollisions</c> @0x0050f050 contains no
|
||||
/// float compare at all. Its BSP branch reaches
|
||||
/// <c>CPartArray::FindObjCollisions</c> (<c>0x0050f18d call 0x518180</c>)
|
||||
/// directly; the cylsphere and sphere loops are bounded only by
|
||||
/// <c>GetNumCylsphere</c>/<c>GetNumSphere</c> counts and call the real
|
||||
/// <c>CCylSphere::intersects_sphere</c> / <c>CSphere::intersects_sphere</c>
|
||||
/// per primitive — tests, not pre-filters. There are no "sorting-sphere
|
||||
/// early-outs" in it.</item>
|
||||
/// <item><c>CPartArray::FindObjCollisions</c> @0x00518180 is a bare
|
||||
/// do/while over <c>parts[i]</c>, and
|
||||
/// <c>CPhysicsPart::find_obj_collisions</c> @0x0050d8d0 does two null
|
||||
/// checks (<c>gfxobj</c>, then <c>gfxobj->physics_bsp</c> at
|
||||
/// <c>[ecx+0x78]</c>), <c>SPHEREPATH::cache_localspace_sphere</c>, and
|
||||
/// <c>CGfxObj::find_obj_collisions</c> @0x00534700. Neither contains a
|
||||
/// compare or any float math.</item>
|
||||
/// </list>
|
||||
/// Retail's only spatial rejection is the BSP node bounding-sphere test
|
||||
/// INSIDE the walk, which is centred on the node's own sphere origin and
|
||||
/// is therefore correct where the deleted filter was not: it measured to
|
||||
/// the part ORIGIN and compared against the BSP ROOT SPHERE's radius, two
|
||||
/// points that are 23.556 m apart for the Neftet rock of #337. See
|
||||
/// <c>docs/research/2026-08-06-337-neftet-wedge-mechanism.md</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private TransitionState FindObjCollisionsInCell(PhysicsEngine engine, uint cellId)
|
||||
|
|
@ -3705,12 +3761,30 @@ public sealed class Transition
|
|||
if (engine.DataCache is null) return TransitionState.OK;
|
||||
|
||||
var objsInCell = engine.ShadowObjects.GetObjectsInCell(cellId);
|
||||
if (objsInCell.Count == 0) return TransitionState.OK;
|
||||
|
||||
var sp = SpherePath;
|
||||
var oi = ObjectInfo;
|
||||
var ci = CollisionInfo;
|
||||
|
||||
// #334 candidate-disposition probe (2026-08-06 — TEMPORARY, strip with
|
||||
// the physics-probe family). Filtered to the player mover so NPC /
|
||||
// remote dead-reckoning resolves do not pollute the capture, matching
|
||||
// PhysicsResolveCapture's filter. The zero-entry query below is
|
||||
// reported EXPLICITLY: "this cell yielded nothing" is outcome (b) —
|
||||
// a registration gap — and must appear as data, never as silence.
|
||||
bool reachProbe = PhysicsDiagnostics.ProbeReachEnabled && oi.IsPlayer;
|
||||
|
||||
if (objsInCell.Count == 0)
|
||||
{
|
||||
if (reachProbe)
|
||||
PhysicsDiagnostics.LogReachQuery(
|
||||
oi.SelfEntityId, cellId, sp.StepDown,
|
||||
inCell: 0, exempt: 0, reached: 0, rejectedReach: 0,
|
||||
noShape: 0, tested: 0, blocked: 0,
|
||||
currPos: sp.GlobalCurrCenter[0].Origin);
|
||||
return TransitionState.OK;
|
||||
}
|
||||
|
||||
// #42 diagnostic (2026-05-05): identify which static object causes
|
||||
// the airborne first-frame ~1m push.
|
||||
bool airborneDiag = !oi.Contact
|
||||
|
|
@ -3730,8 +3804,39 @@ public sealed class Transition
|
|||
// (DoStepUp → TransitionalInsert → this) must not observe
|
||||
// registry mutations through the same reference mid-iteration.
|
||||
using var nearbyObjs = ShadowEntrySnapshot.Capture(objsInCell);
|
||||
|
||||
// #334 probe tallies — see LogReachQuery for what each one decides.
|
||||
// `rejectedReach` is deliberately still REPORTED and structurally 0
|
||||
// since #333 deleted the filter: a run of the acceptance capture whose
|
||||
// [reach-q] lines read rejectedReach=0 where the pre-fix capture read
|
||||
// 7,225 rejections is the evidence the filter is gone, and dropping the
|
||||
// column would make the two captures incomparable.
|
||||
const int rRejected = 0;
|
||||
int rExempt = 0, rReached = 0, rNoShape = 0,
|
||||
rTested = 0, rBlocked = 0;
|
||||
|
||||
foreach (ShadowEntry obj in nearbyObjs.Entries)
|
||||
{
|
||||
// #337 [geom] probe (2026-08-06 — TEMPORARY, strip with the
|
||||
// physics-probe family). Emitted here, at the TOP of the candidate
|
||||
// loop, so it covers every object the mover comes near regardless
|
||||
// of what the exemptions and the reach filter later do with it —
|
||||
// an object whose collision geometry is absent or displaced must
|
||||
// be reported even when nothing ever tests it. Once per GfxObj per
|
||||
// process: the line describes an ASSET, not a moment.
|
||||
if (obj.CollisionType == ShadowCollisionType.BSP
|
||||
&& PhysicsDiagnostics.ShouldLogGeometry(obj.GfxObjId))
|
||||
{
|
||||
PhysicsDiagnostics.LogGeometryFromAssets(
|
||||
gfxObjId: obj.GfxObjId,
|
||||
entityId: obj.EntityId,
|
||||
flat: engine.DataCache.GetFlatGfxObj(obj.GfxObjId),
|
||||
visual: engine.DataCache.GetVisualBounds(obj.GfxObjId),
|
||||
entityWorldPosition: obj.Position,
|
||||
entityScale: obj.Scale,
|
||||
registeredRadius: obj.Radius);
|
||||
}
|
||||
|
||||
// Self-skip — fix #42 (2026-05-05). Mirrors retail
|
||||
// CObjCell::find_obj_collisions at acclient_2013_pseudo_c.txt
|
||||
// 308931: `physobj != arg2->object_info.object` rejects the
|
||||
|
|
@ -3745,24 +3850,36 @@ public sealed class Transition
|
|||
// horizontal push (validated by [SWEEP-OBJ] traces with
|
||||
// gfxObj=0x02000001 at exactly the entity's own position).
|
||||
if (oi.SelfEntityId != 0 && obj.EntityId == oi.SelfEntityId)
|
||||
{
|
||||
if (reachProbe)
|
||||
{
|
||||
rExempt++;
|
||||
ProbeReachCandidate(engine, oi, sp, cellId, in obj,
|
||||
"exempt-self", sphereRadius, movement.Length(), currPos);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// OBJECTINFO::missile_ignore 0x0050CEB0 is an all-shapes
|
||||
// exemption. Retail computes it before the BSP-vs-cyl/sphere
|
||||
// dispatch; a true result reaches neither branch.
|
||||
if (oi.MissileIgnore(obj.EntityId, obj.State, obj.Flags))
|
||||
{
|
||||
if (reachProbe)
|
||||
{
|
||||
rExempt++;
|
||||
ProbeReachCandidate(engine, oi, sp, cellId, in obj,
|
||||
"exempt-missile", sphereRadius, movement.Length(), currPos);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Broad-phase: can the moving sphere reach this object?
|
||||
Vector3 deltaToCurr = currPos - obj.Position;
|
||||
float distToCurr;
|
||||
if (obj.CollisionType == ShadowCollisionType.Cylinder)
|
||||
distToCurr = MathF.Sqrt(deltaToCurr.X * deltaToCurr.X + deltaToCurr.Y * deltaToCurr.Y);
|
||||
else
|
||||
distToCurr = deltaToCurr.Length();
|
||||
float maxReach = sphereRadius + obj.Radius + movement.Length() + 2f;
|
||||
if (distToCurr > maxReach)
|
||||
continue;
|
||||
// NO BROAD-PHASE DISTANCE FILTER — see the method's remarks. Retail
|
||||
// walks the cell's shadow list unconditionally (#333, closing #337).
|
||||
// Cell membership IS the broad phase, and the BSP walk's own root
|
||||
// node bounding-sphere test — correctly centred, unlike the deleted
|
||||
// filter — is the early-out that made this one unnecessary.
|
||||
if (reachProbe) rReached++;
|
||||
|
||||
// Commit C 2026-04-29 — retail exemption block at the top of
|
||||
// CPhysicsObj::FindObjCollisions
|
||||
|
|
@ -3773,7 +3890,15 @@ public sealed class Transition
|
|||
// landblock entries register with State=0 and Flags=None,
|
||||
// so this is a cheap fall-through for them.
|
||||
if (CollisionExemption.ShouldSkip(obj.State, obj.Flags, ObjectInfo.State))
|
||||
{
|
||||
if (reachProbe)
|
||||
{
|
||||
rExempt++;
|
||||
ProbeReachCandidate(engine, oi, sp, cellId, in obj,
|
||||
"exempt-rule", sphereRadius, movement.Length(), currPos);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Retail CPhysicsObj::FindObjCollisions ethereal branch
|
||||
// (pc:276795-276806 / 0x0050f0a2-0x0050f0c9). A target counts as
|
||||
|
|
@ -3799,7 +3924,16 @@ public sealed class Transition
|
|||
bool etherealForTest = (obj.State & 0x4u) != 0
|
||||
|| (ObjectInfo.Ethereal && (obj.State & 0x1u) == 0);
|
||||
if (etherealForTest && sp.StepDown)
|
||||
continue; // retail pc:276799 — ethereal target not tested in step-down
|
||||
{
|
||||
// retail pc:276799 — ethereal target not tested in step-down
|
||||
if (reachProbe)
|
||||
{
|
||||
rExempt++;
|
||||
ProbeReachCandidate(engine, oi, sp, cellId, in obj,
|
||||
"exempt-ethereal-stepdown", sphereRadius, movement.Length(), currPos);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
sp.ObstructionEthereal = etherealForTest;
|
||||
|
||||
// L.2a slice 3 (2026-05-12): snapshot collision-normal state so
|
||||
|
|
@ -3848,6 +3982,15 @@ public sealed class Transition
|
|||
// clear (pc:276989) fires after shape tests; we clear early here to
|
||||
// leave the flag clean for the next iteration.
|
||||
sp.ObstructionEthereal = false;
|
||||
// #334 outcome (c): the entry IS a candidate, the reach
|
||||
// filter DID admit it, and it still contributes nothing
|
||||
// because no usable physics BSP resolved for its GfxObj.
|
||||
if (reachProbe)
|
||||
{
|
||||
rNoShape++;
|
||||
ProbeReachCandidate(engine, oi, sp, cellId, in obj,
|
||||
"no-shape", sphereRadius, movement.Length(), currPos);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -3915,6 +4058,12 @@ public sealed class Transition
|
|||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[sph-skip-bsp] obj=0x{obj.EntityId:X8} state=0x{obj.State:X8} — HAS_PHYSICS_BSP_PS dispatches BSP-only"));
|
||||
}
|
||||
if (reachProbe)
|
||||
{
|
||||
rExempt++;
|
||||
ProbeReachCandidate(engine, oi, sp, cellId, in obj,
|
||||
"bsp-only-skip", sphereRadius, movement.Length(), currPos);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -3958,6 +4107,12 @@ public sealed class Transition
|
|||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[cyl-skip-bsp] obj=0x{obj.EntityId:X8} state=0x{obj.State:X8} — HAS_PHYSICS_BSP_PS dispatches BSP-only"));
|
||||
}
|
||||
if (reachProbe)
|
||||
{
|
||||
rExempt++;
|
||||
ProbeReachCandidate(engine, oi, sp, cellId, in obj,
|
||||
"bsp-only-skip", sphereRadius, movement.Length(), currPos);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -4121,6 +4276,24 @@ public sealed class Transition
|
|||
// per-object test body before the outer loop continues.
|
||||
sp.ObstructionEthereal = false;
|
||||
|
||||
// #334: this candidate actually reached a shape test. `result` is
|
||||
// post-Layer-2, i.e. what the query will really act on.
|
||||
if (reachProbe)
|
||||
{
|
||||
rTested++;
|
||||
if (result != TransitionState.OK) rBlocked++;
|
||||
ProbeReachCandidate(engine, oi, sp, cellId, in obj,
|
||||
result switch
|
||||
{
|
||||
TransitionState.OK => "tested-ok",
|
||||
TransitionState.Collided => "tested-collided",
|
||||
TransitionState.Adjusted => "tested-adjusted",
|
||||
TransitionState.Slid => "tested-slid",
|
||||
_ => "tested-invalid",
|
||||
},
|
||||
sphereRadius, movement.Length(), currPos);
|
||||
}
|
||||
|
||||
if (result != TransitionState.OK)
|
||||
{
|
||||
if (airborneDiag)
|
||||
|
|
@ -4133,13 +4306,108 @@ public sealed class Transition
|
|||
$"objR={obj.Radius:F3} cylH={obj.CylHeight:F3} " +
|
||||
$"state={result} pushDelta=({d.X:F3},{d.Y:F3},{d.Z:F3})");
|
||||
}
|
||||
// #334 (TEMPORARY): the early exit is a real query outcome, so
|
||||
// it must be summarised too — otherwise the summary would
|
||||
// under-report exactly the queries where something DID block,
|
||||
// and "blocked" is the control that proves the probe can see a
|
||||
// working collision as well as a missing one.
|
||||
if (reachProbe)
|
||||
PhysicsDiagnostics.LogReachQuery(
|
||||
oi.SelfEntityId, cellId, sp.StepDown,
|
||||
nearbyObjs.Entries.Length, rExempt, rReached,
|
||||
rRejected, rNoShape, rTested, rBlocked, currPos);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (reachProbe)
|
||||
PhysicsDiagnostics.LogReachQuery(
|
||||
oi.SelfEntityId, cellId, sp.StepDown,
|
||||
nearbyObjs.Entries.Length, rExempt, rReached,
|
||||
rRejected, rNoShape, rTested, rBlocked, currPos);
|
||||
|
||||
return TransitionState.OK;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #334 candidate-disposition probe helper (2026-08-06 — TEMPORARY, strip
|
||||
/// with the physics-probe family). Static, and takes everything by
|
||||
/// parameter, so it introduces no closure display class into
|
||||
/// <see cref="FindObjCollisionsInCell"/> — Slice I1's 0 B/resolve budget
|
||||
/// must hold with the probe compiled in and switched off.
|
||||
///
|
||||
/// <para>
|
||||
/// The target's physics-BSP ROOT sphere is resolved through the SAME
|
||||
/// production accessor registration used
|
||||
/// (<c>GetFlatGfxObj(id).PhysicsBsp</c>'s root node, per
|
||||
/// <c>LiveEntityCollisionBuilder</c> and <c>ShadowShapeBuilder</c>), so the
|
||||
/// probe cannot report geometry that differs from what the registry
|
||||
/// actually emitted. AP-156's lesson was exactly that: one resolver.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static void ProbeReachCandidate(
|
||||
PhysicsEngine engine,
|
||||
ObjectInfo oi,
|
||||
SpherePath sp,
|
||||
uint cellId,
|
||||
in ShadowEntry obj,
|
||||
string disposition,
|
||||
float sphereRadius,
|
||||
float movementLen,
|
||||
Vector3 currPos)
|
||||
{
|
||||
bool xyOnly = obj.CollisionType == ShadowCollisionType.Cylinder;
|
||||
|
||||
Vector3 dOrigin = currPos - obj.Position;
|
||||
float distOrigin = xyOnly
|
||||
? MathF.Sqrt(dOrigin.X * dOrigin.X + dOrigin.Y * dOrigin.Y)
|
||||
: dOrigin.Length();
|
||||
|
||||
// World-space offset from the part ORIGIN (what the DELETED filter
|
||||
// measured against) to the BSP root sphere CENTRE (what it should have
|
||||
// measured against). Both distances are still emitted after #333: the
|
||||
// pair is what proved the diagnosis, and it stays comparable across the
|
||||
// pre-fix and post-fix captures. Zero for non-BSP shapes, whose
|
||||
// Position already IS their centre.
|
||||
Vector3 bspCentreOffset = Vector3.Zero;
|
||||
if (obj.CollisionType == ShadowCollisionType.BSP)
|
||||
{
|
||||
var flatBsp = engine.DataCache?.GetFlatGfxObj(obj.GfxObjId)?.PhysicsBsp;
|
||||
if (flatBsp is { RootIndex: >= 0 })
|
||||
{
|
||||
bspCentreOffset = Vector3.Transform(
|
||||
flatBsp.Nodes[flatBsp.RootIndex].BoundingSphere.Origin * obj.Scale,
|
||||
obj.Rotation);
|
||||
}
|
||||
}
|
||||
|
||||
Vector3 dCentre = currPos - (obj.Position + bspCentreOffset);
|
||||
float distCentre = xyOnly
|
||||
? MathF.Sqrt(dCentre.X * dCentre.X + dCentre.Y * dCentre.Y)
|
||||
: dCentre.Length();
|
||||
|
||||
float budget = sphereRadius + obj.Radius + movementLen + 2f;
|
||||
|
||||
PhysicsDiagnostics.LogReachCandidate(
|
||||
moverId: oi.SelfEntityId,
|
||||
entityId: obj.EntityId,
|
||||
gfxObjId: obj.GfxObjId,
|
||||
cellId: cellId,
|
||||
shape: obj.CollisionType,
|
||||
disposition: disposition,
|
||||
stepDown: sp.StepDown,
|
||||
distOrigin: distOrigin,
|
||||
distCenter: distCentre,
|
||||
objRadius: obj.Radius,
|
||||
sphereRadius: sphereRadius,
|
||||
movementLen: movementLen,
|
||||
budget: budget,
|
||||
centerBudget: budget - 2f,
|
||||
objPos: obj.Position,
|
||||
bspCentreOffset: bspCentreOffset,
|
||||
currPos: currPos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BR-7 / A6.P4 (2026-06-11). The retail BUILDING collision channel —
|
||||
/// <c>CSortCell::find_collisions</c> (Ghidra 0x005340a0): an outdoor
|
||||
|
|
|
|||
|
|
@ -783,4 +783,52 @@ public static class RenderingDiagnostics
|
|||
/// </summary>
|
||||
public static string? FrameHistoryPath { get; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_FRAME_HISTORY");
|
||||
|
||||
// ── #337 collision-mesh wireframe (2026-08-06 — TEMPORARY) ──────────────
|
||||
//
|
||||
// The F2 collision overlay already existed, but for a BSP object it drew a
|
||||
// proxy cylinder sized from the REGISTERED BROADPHASE RADIUS. That shows
|
||||
// where the collision system thinks the object roughly is; it cannot show
|
||||
// where the collision SURFACES are, which is the only thing that answers
|
||||
// "is the collision geometry where the visual geometry is". The knobs
|
||||
// below turn F2 into the real answer: the actual physics-BSP polygon
|
||||
// edges, in world space, next to the same object's visual mesh box.
|
||||
//
|
||||
// Off by default, so F2 keeps its old cheap behaviour for anyone who wants
|
||||
// it and this costs nothing until asked for.
|
||||
|
||||
/// <summary>
|
||||
/// When true, the F2 collision overlay draws each nearby object's REAL
|
||||
/// physics-BSP polygon edges (cyan) and, beside them, the same object's
|
||||
/// visual mesh bounding box (magenta), plus the terrain triangle under the
|
||||
/// player (yellow). Any separation between the cyan surfaces and the
|
||||
/// object you can see is the "collision is not where the visual is"
|
||||
/// defect, read directly off the screen instead of inferred from a log.
|
||||
/// Initial state from <c>ACDREAM_WIRE_MESH=1</c>.
|
||||
/// TEMPORARY — strip with the #337 probe family.
|
||||
/// </summary>
|
||||
public static bool CollisionMeshWireframeEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_WIRE_MESH") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// Radius in metres around the player within which
|
||||
/// <see cref="CollisionMeshWireframeEnabled"/> resolves polygon geometry.
|
||||
/// A whole landblock of rock is far more geometry than a line list wants;
|
||||
/// 30 m covers everything you can wedge against. Override with
|
||||
/// <c>ACDREAM_WIRE_RADIUS=<metres></c>.
|
||||
/// </summary>
|
||||
public static float CollisionMeshWireframeRadius { get; set; } =
|
||||
ParsePositiveFloat(
|
||||
Environment.GetEnvironmentVariable("ACDREAM_WIRE_RADIUS"),
|
||||
fallback: 30f);
|
||||
|
||||
private static float ParsePositiveFloat(string? raw, float fallback)
|
||||
=> float.TryParse(
|
||||
raw,
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out float value)
|
||||
&& value > 0f
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -412,8 +412,10 @@ public sealed class LiveEntityCollisionBuilderTests
|
|||
/// 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.
|
||||
/// </summary>
|
||||
private static FlatCollisionSphere? Bsp(float radius, float centerZ = 1.25f)
|
||||
=> new FlatCollisionSphere(new Vector3(0f, 0f, centerZ), radius);
|
||||
private static ShadowPartGeometry? Bsp(float radius, float centerZ = 1.25f)
|
||||
=> ShadowPartGeometry.Create(
|
||||
new FlatCollisionSphere(new Vector3(0f, 0f, centerZ), radius),
|
||||
null);
|
||||
|
||||
private static LiveEntityDefaultPoseResolver PoseResolver() => new(
|
||||
_ => null,
|
||||
|
|
|
|||
|
|
@ -143,8 +143,10 @@ public sealed class PvpBitfieldSurvivesAppearanceRebuildTests
|
|||
setup.Parts.Add(0x0100AB01u);
|
||||
var builder = new LiveEntityCollisionBuilder(
|
||||
id => id == 0x0100AB01u
|
||||
? new FlatCollisionSphere(new Vector3(0f, 0f, 0.5f), 1f)
|
||||
: null,
|
||||
? ShadowPartGeometry.Create(
|
||||
new FlatCollisionSphere(new Vector3(0f, 0f, 0.5f), 1f),
|
||||
null)
|
||||
: (ShadowPartGeometry?)null,
|
||||
new LiveEntityDefaultPoseResolver(
|
||||
_ => null,
|
||||
new NullAnimationLoader(),
|
||||
|
|
|
|||
|
|
@ -339,7 +339,9 @@ public sealed class InstalledSetupBspPrimitiveDispatchTests
|
|||
setup,
|
||||
EntScale,
|
||||
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
|
||||
// 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.LocalRotation,
|
||||
s.Scale,
|
||||
new FlatCollisionSphere(Vector3.Zero, bspR / s.Scale)));
|
||||
ShadowPartGeometry.Create(
|
||||
new FlatCollisionSphere(Vector3.Zero, bspR / s.Scale),
|
||||
null)));
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -1113,7 +1115,7 @@ public class DoorBugTrajectoryReplayTests
|
|||
localPosition: Vector3.Zero,
|
||||
localRotation: Quaternion.Identity,
|
||||
scale: 1f,
|
||||
localBounds: new FlatCollisionSphere(Vector3.Zero, BspRadius));
|
||||
localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, BspRadius), null));
|
||||
|
||||
var cylShape = ShadowShape.Cylinder(
|
||||
gfxObjId: 0u,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,240 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// #333 / #337 regression — the query-site broadphase reach filter.
|
||||
///
|
||||
/// <para>
|
||||
/// <c>Transition.FindObjCollisionsInCell</c> used to discard a shadow candidate
|
||||
/// when <c>|currPos - obj.Position| > sphereRadius + obj.Radius +
|
||||
/// movement.Length() + 2f</c>. <c>obj.Position</c> is the part ORIGIN while
|
||||
/// <c>obj.Radius</c> is the physics-BSP ROOT BOUNDING SPHERE's radius, measured
|
||||
/// about a centre that is frequently metres away from that origin (AP-156:
|
||||
/// 376 of 973 installed physics-BSP parts sit further from their part origin
|
||||
/// than half their own radius, worst 20.762 m). Geometry deep inside the real
|
||||
/// bounding sphere was therefore rejected before <c>BSPQuery</c> ever ran —
|
||||
/// solid near the origin, permeable in a bounded shell beyond it. That is the
|
||||
/// mechanism of #337 (the Neftet plateau: wedged on top, jumps sink in,
|
||||
/// corpses fall through), measured in
|
||||
/// <c>docs/research/2026-08-06-337-neftet-wedge-mechanism.md</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail has no such filter. Disassembled from the PDB-paired v11.4186
|
||||
/// binary: <c>CObjCell::find_obj_collisions</c> @0x0052b750 calls
|
||||
/// <c>CPhysicsObj::FindObjCollisions</c> unconditionally,
|
||||
/// <c>CPhysicsObj::FindObjCollisions</c> @0x0050f050 has no float compare at
|
||||
/// all, <c>CPartArray::FindObjCollisions</c> @0x00518180 is a bare loop over
|
||||
/// parts, and <c>CPhysicsPart::find_obj_collisions</c> @0x0050d8d0 is two null
|
||||
/// checks plus <c>CGfxObj::find_obj_collisions</c> @0x00534700.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// This fixture is deliberately DAT-free so the gate runs everywhere, and it
|
||||
/// drives the PRODUCTION path end-to-end
|
||||
/// (<c>PhysicsEngine.ResolveWithTransition</c> →
|
||||
/// <c>Transition.FindObjCollisionsInCell</c> → <c>CollisionTraversal</c>)
|
||||
/// rather than re-computing a predicate in the test. Sabotage: restore the
|
||||
/// <c>maxReach</c> pre-check and <see cref="OffCentreBspFloorStopsAFallingMover"/>
|
||||
/// fails while <see cref="CentredBspFloorStopsAFallingMover"/> keeps passing —
|
||||
/// the pair discriminates "the filter is gone" from "the fixture cannot fall".
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class Issue333BroadphaseReachFilterTests
|
||||
{
|
||||
private const uint LandblockId = 0xA9B60000u;
|
||||
private const uint CellId = LandblockId | 0x0001u;
|
||||
private const uint EntityId = 0x333BEEF1u;
|
||||
private const uint GfxObjId = 0x333BEEF2u;
|
||||
private const ushort FloorPolyId = 1;
|
||||
|
||||
private const float SphereRadius = 0.48f;
|
||||
private const float SphereHeight = 1.835f; // human Setup 0x02000001
|
||||
private const float StepUpHeight = 0.60f;
|
||||
private const float StepDownHeight = 0.04f;
|
||||
|
||||
/// <summary>Part origin. The filter measured to THIS point.</summary>
|
||||
private static readonly Vector3 PartOrigin = new(12f, 12f, 0f);
|
||||
|
||||
/// <summary>
|
||||
/// Root bounding-sphere radius the registry publishes for this object,
|
||||
/// and the radius of the single BSP leaf node.
|
||||
/// </summary>
|
||||
private const float RootSphereRadius = 6f;
|
||||
|
||||
/// <summary>
|
||||
/// #333 REGRESSION. A floor slab whose geometry — and whose BSP root
|
||||
/// bounding sphere — sit 40 m above the part origin must stop a mover
|
||||
/// falling onto it.
|
||||
///
|
||||
/// <para>
|
||||
/// With the deleted filter the mover was 40.48 m from the part origin
|
||||
/// against a budget of 0.48 + 6 + ~0.3 + 2 = 8.78 m, so the candidate was
|
||||
/// discarded and the mover fell straight through. Measured to the root
|
||||
/// sphere's own centre it is 1.48 m against a 6 m radius — inside by
|
||||
/// 4.5 m of margin. Retail's BSP node test uses that centre; the filter
|
||||
/// used the origin.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OffCentreBspFloorStopsAFallingMover()
|
||||
{
|
||||
var engine = BuildEngineWithFloorSlab(slabLocalZ: 40f);
|
||||
|
||||
// Precondition, so a membership failure cannot masquerade as the
|
||||
// defect this test is about.
|
||||
Assert.NotEmpty(engine.ShadowObjects.GetObjectsInCell(CellId));
|
||||
|
||||
var (finalFeetZ, blocked) = DropOnto(engine, startFeetZ: 41.4f);
|
||||
|
||||
Assert.True(
|
||||
finalFeetZ > 39.5f,
|
||||
$"the mover fell through a floor slab 40 m above its owner's part "
|
||||
+ $"origin: feet reached z={finalFeetZ:F3}, and an unobstructed "
|
||||
+ $"fall would have reached 37.8. blockedAtLeastOnce={blocked}. "
|
||||
+ $"This is #333 — the query-site broadphase discarded the "
|
||||
+ $"candidate before BSPQuery ever ran.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CONTROL. The identical slab, this time AT the part origin, so the
|
||||
/// deleted filter admitted it. It blocked before the fix and must still
|
||||
/// block after. Without this row, a fixture that simply cannot fall would
|
||||
/// pass <see cref="OffCentreBspFloorStopsAFallingMover"/> for the wrong
|
||||
/// reason.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CentredBspFloorStopsAFallingMover()
|
||||
{
|
||||
var engine = BuildEngineWithFloorSlab(slabLocalZ: 0f);
|
||||
|
||||
Assert.NotEmpty(engine.ShadowObjects.GetObjectsInCell(CellId));
|
||||
|
||||
var (finalFeetZ, blocked) = DropOnto(engine, startFeetZ: 1.4f);
|
||||
|
||||
Assert.True(
|
||||
finalFeetZ > -0.5f,
|
||||
$"the control slab at the part origin must still stop the mover; "
|
||||
+ $"feet reached z={finalFeetZ:F3}, blockedAtLeastOnce={blocked}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop the mover straight down in 0.30 m steps for 12 ticks. Returns the
|
||||
/// final FEET height and whether any tick reported a collision normal.
|
||||
/// </summary>
|
||||
private static (float FinalFeetZ, bool Blocked) DropOnto(
|
||||
PhysicsEngine engine, float startFeetZ)
|
||||
{
|
||||
var pos = new Vector3(PartOrigin.X, PartOrigin.Y, startFeetZ);
|
||||
bool blocked = false;
|
||||
|
||||
for (int tick = 0; tick < 12; tick++)
|
||||
{
|
||||
Vector3 target = pos - new Vector3(0f, 0f, 0.30f);
|
||||
var result = engine.ResolveWithTransition(
|
||||
pos, target, CellId,
|
||||
SphereRadius, SphereHeight,
|
||||
StepUpHeight, StepDownHeight,
|
||||
isOnGround: false,
|
||||
body: null,
|
||||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||
movingEntityId: 0);
|
||||
|
||||
blocked |= result.CollisionNormalValid;
|
||||
pos = result.Position;
|
||||
}
|
||||
|
||||
return (pos.Z, blocked);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One static BSP owner at <see cref="PartOrigin"/> carrying a single
|
||||
/// horizontal floor polygon (8 × 8 m, normal +Z) at
|
||||
/// <paramref name="slabLocalZ"/> in the owner's local frame, with the BSP
|
||||
/// root node's bounding sphere centred on the slab — exactly the
|
||||
/// origin-vs-centre offset the DAT authors for large formations.
|
||||
/// </summary>
|
||||
private static PhysicsEngine BuildEngineWithFloorSlab(float slabLocalZ)
|
||||
{
|
||||
var cache = new PhysicsDataCache();
|
||||
var engine = new PhysicsEngine { DataCache = cache };
|
||||
|
||||
// Flat terrain far below: it must never be what stops the mover.
|
||||
var heights = new byte[81];
|
||||
var heightTable = new float[256];
|
||||
for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
|
||||
engine.AddLandblock(
|
||||
landblockId: LandblockId,
|
||||
terrain: new TerrainSurface(heights, heightTable),
|
||||
cells: System.Array.Empty<CellSurface>(),
|
||||
portals: System.Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
|
||||
var floorVerts = new[]
|
||||
{
|
||||
new Vector3(-4f, -4f, slabLocalZ),
|
||||
new Vector3( 4f, -4f, slabLocalZ),
|
||||
new Vector3( 4f, 4f, slabLocalZ),
|
||||
new Vector3(-4f, 4f, slabLocalZ),
|
||||
};
|
||||
var floorNormal = new Vector3(0f, 0f, 1f);
|
||||
var floorPoly = new ResolvedPolygon
|
||||
{
|
||||
Vertices = floorVerts,
|
||||
Plane = new Plane(floorNormal, -Vector3.Dot(floorNormal, floorVerts[0])),
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None,
|
||||
};
|
||||
|
||||
var leaf = new PhysicsBSPNode
|
||||
{
|
||||
Type = BSPNodeType.Leaf,
|
||||
BoundingSphere = new Sphere
|
||||
{
|
||||
Origin = new Vector3(0f, 0f, slabLocalZ),
|
||||
Radius = RootSphereRadius,
|
||||
},
|
||||
};
|
||||
leaf.Polygons.Add(FloorPolyId);
|
||||
|
||||
var physics = new GfxObjPhysics
|
||||
{
|
||||
BSP = new PhysicsBSPTree { Root = leaf },
|
||||
PhysicsPolygons = new Dictionary<ushort, Polygon>(),
|
||||
Vertices = new VertexArray(),
|
||||
Resolved = new Dictionary<ushort, ResolvedPolygon> { [FloorPolyId] = floorPoly },
|
||||
BoundingSphere = new Sphere
|
||||
{
|
||||
Origin = new Vector3(0f, 0f, slabLocalZ),
|
||||
Radius = RootSphereRadius,
|
||||
},
|
||||
};
|
||||
cache.RegisterGfxObjForTest(GfxObjId, physics);
|
||||
|
||||
// Registration publishes the ROOT SPHERE'S RADIUS about the PART
|
||||
// ORIGIN — the exact pairing the deleted filter then mis-measured.
|
||||
engine.ShadowObjects.Register(
|
||||
entityId: EntityId,
|
||||
gfxObjId: GfxObjId,
|
||||
worldPos: PartOrigin,
|
||||
rotation: Quaternion.Identity,
|
||||
radius: RootSphereRadius,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: LandblockId,
|
||||
collisionType: ShadowCollisionType.BSP,
|
||||
cylHeight: 0f,
|
||||
scale: 1.0f,
|
||||
state: 0x1u, // STATIC_PS
|
||||
flags: EntityCollisionFlags.None);
|
||||
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,946 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Tests.Conformance;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// #337 offline replay (TEMPORARY — strip with the physics-probe family).
|
||||
///
|
||||
/// <para>
|
||||
/// Reconstructs, from the installed DAT alone, the exact world placement of
|
||||
/// every landblock-static collision owner in Neftet landblock 0x8766, and
|
||||
/// asks the one question <c>[geom]</c> cannot answer: the <c>[geom]</c> probe
|
||||
/// compares the physics-BSP vertex cloud against the visual mesh box in the
|
||||
/// object's OWN LOCAL FRAME, so <c>verdict=coincident</c> proves only that the
|
||||
/// two agree on SHAPE. It says nothing about WHERE the object sits in the
|
||||
/// world. This test places the geometry in the world and measures.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Live measurement being replayed (337-support.log / 334-fix-gate.log):
|
||||
/// the player wedges at (134.314, 55.716, 50.011) in cell 0x8766002B, and
|
||||
/// owner 0xC8766002 / gfx 0x010046DE was tested 11,014 times there with
|
||||
/// 0 adjusted, 0 collided, 0 slid — while its two neighbours 0xC8766003 and
|
||||
/// 0xC8766009 adjusted 767 and 1,135 times against the same mover.
|
||||
/// </para>
|
||||
///
|
||||
/// Dat-data dependent; SKIPs cleanly without ACDREAM_DAT_DIR.
|
||||
/// </summary>
|
||||
public sealed class Issue337NeftetRockGeometryInspectionTests
|
||||
{
|
||||
private const uint Landblock = 0x8766_0000u;
|
||||
private const uint GfxObjMask = 0x01000000u;
|
||||
private const uint SetupMask = 0x02000000u;
|
||||
private const uint TypeMask = 0xFF000000u;
|
||||
|
||||
/// <summary>The wedge point, landblock-local, from the live capture.</summary>
|
||||
private static readonly Vector3 Wedge = new(134.313934f, 55.716248f, 50.010666f);
|
||||
|
||||
private readonly ITestOutputHelper _out;
|
||||
|
||||
public Issue337NeftetRockGeometryInspectionTests(ITestOutputHelper o) => _out = o;
|
||||
|
||||
private sealed record Placed(
|
||||
uint EntityId,
|
||||
uint DatId,
|
||||
uint GfxObjId,
|
||||
Vector3 Position,
|
||||
Quaternion Rotation,
|
||||
List<Vector3[]> WorldPolygons,
|
||||
List<Vector3[]> LocalPolygons,
|
||||
FlatPhysicsBsp Bsp,
|
||||
Vector3 WorldMin,
|
||||
Vector3 WorldMax,
|
||||
int BspNodes,
|
||||
int BspPolys);
|
||||
|
||||
[Fact]
|
||||
public void DumpNeftetLandblockStaticsAndProbeTheWedgePoint()
|
||||
{
|
||||
System.Globalization.CultureInfo.CurrentCulture =
|
||||
System.Globalization.CultureInfo.InvariantCulture;
|
||||
|
||||
string? datDir = ConformanceDats.ResolveDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
_out.WriteLine("SKIP: installed retail DAT directory is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var info = dats.Get<LandBlockInfo>((Landblock & 0xFFFF0000u) | 0xFFFEu);
|
||||
Assert.NotNull(info);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(Inv(
|
||||
$"landblock 0x{Landblock:X8}: Objects={info!.Objects.Count} Buildings={info.Buildings.Count}"));
|
||||
|
||||
uint lbX = (Landblock >> 24) & 0xFFu;
|
||||
uint lbY = (Landblock >> 16) & 0xFFu;
|
||||
uint counter = 0;
|
||||
|
||||
var cache = new PhysicsDataCache();
|
||||
var placed = new List<Placed>();
|
||||
|
||||
foreach (var stab in info.Objects)
|
||||
{
|
||||
if (!IsSupported(stab.Id)) continue;
|
||||
uint entityId = LandblockStaticEntityIdAllocatorAllocate(lbX, lbY, ref counter);
|
||||
placed.AddRange(Place(dats, cache, entityId, stab.Id,
|
||||
stab.Frame.Origin, stab.Frame.Orientation));
|
||||
}
|
||||
|
||||
foreach (var building in info.Buildings)
|
||||
{
|
||||
if (!IsSupported(building.ModelId)) continue;
|
||||
uint entityId = LandblockStaticEntityIdAllocatorAllocate(lbX, lbY, ref counter);
|
||||
placed.AddRange(Place(dats, cache, entityId, building.ModelId,
|
||||
building.Frame.Origin, building.Frame.Orientation));
|
||||
}
|
||||
|
||||
sb.AppendLine($"placed collision parts: {placed.Count}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("--- every part: world box, and the wedge point in ITS OWN local frame ---");
|
||||
foreach (var p in placed.OrderBy(p => p.EntityId).ThenBy(p => p.GfxObjId))
|
||||
{
|
||||
Vector3 local = Vector3.Transform(
|
||||
Wedge - p.Position, Quaternion.Inverse(p.Rotation));
|
||||
bool inWorldBox = Inside(p.WorldMin, p.WorldMax, Wedge);
|
||||
sb.AppendLine(Inv(
|
||||
$" ent=0x{p.EntityId:X8} dat=0x{p.DatId:X8} gfx=0x{p.GfxObjId:X8} " +
|
||||
$"pos=({p.Position.X:F3},{p.Position.Y:F3},{p.Position.Z:F3}) " +
|
||||
$"rot=({p.Rotation.W:F4},{p.Rotation.X:F4},{p.Rotation.Y:F4},{p.Rotation.Z:F4}) " +
|
||||
$"yawDeg={YawDegrees(p.Rotation):F2} " +
|
||||
$"nodes={p.BspNodes} polys={p.BspPolys} " +
|
||||
$"worldMin=({p.WorldMin.X:F2},{p.WorldMin.Y:F2},{p.WorldMin.Z:F2}) " +
|
||||
$"worldMax=({p.WorldMax.X:F2},{p.WorldMax.Y:F2},{p.WorldMax.Z:F2}) " +
|
||||
$"wedgeLocal=({local.X:F2},{local.Y:F2},{local.Z:F2}) " +
|
||||
$"wedgeInWorldBox={inWorldBox}"));
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(Inv(
|
||||
$"--- vertical column through the wedge XY ({Wedge.X:F3},{Wedge.Y:F3}) ---"));
|
||||
sb.AppendLine("(every physics polygon whose WORLD XY projection contains that point,");
|
||||
sb.AppendLine(" with the Z of the polygon's plane at that XY — this is the surface");
|
||||
sb.AppendLine(" a falling body would land on and the ceiling it would be under)");
|
||||
|
||||
var hits = new List<(uint Ent, uint Gfx, float Z, float Nz, int PolyIndex)>();
|
||||
foreach (var p in placed)
|
||||
{
|
||||
for (int i = 0; i < p.WorldPolygons.Count; i++)
|
||||
{
|
||||
Vector3[] poly = p.WorldPolygons[i];
|
||||
if (!XyContains(poly, Wedge.X, Wedge.Y)) continue;
|
||||
if (!TryPlaneZ(poly, Wedge.X, Wedge.Y, out float z, out float nz)) continue;
|
||||
hits.Add((p.EntityId, p.GfxObjId, z, nz, i));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var h in hits.OrderByDescending(h => h.Z))
|
||||
{
|
||||
sb.AppendLine(Inv(
|
||||
$" z={h.Z,9:F3} normalZ={h.Nz,7:F4} ent=0x{h.Ent:X8} gfx=0x{h.Gfx:X8} poly#{h.PolyIndex}"));
|
||||
}
|
||||
if (hits.Count == 0)
|
||||
sb.AppendLine(" (no physics polygon covers that XY at all)");
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(Inv(
|
||||
$"player sphere centre is at z={Wedge.Z:F3}"));
|
||||
var above = hits.Where(h => h.Z > Wedge.Z).OrderBy(h => h.Z).ToList();
|
||||
var below = hits.Where(h => h.Z <= Wedge.Z).OrderByDescending(h => h.Z).ToList();
|
||||
sb.AppendLine(Inv(
|
||||
$" nearest surface BELOW: {(below.Count == 0 ? "none" : $"z={below[0].Z:F3} ent=0x{below[0].Ent:X8} gfx=0x{below[0].Gfx:X8} (gap {Wedge.Z - below[0].Z:F3} m)")}"));
|
||||
sb.AppendLine(Inv(
|
||||
$" nearest surface ABOVE: {(above.Count == 0 ? "none" : $"z={above[0].Z:F3} ent=0x{above[0].Ent:X8} gfx=0x{above[0].Gfx:X8} (gap {above[0].Z - Wedge.Z:F3} m)")}"));
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("--- nearest physics polygon to the wedge point, per owner ---");
|
||||
foreach (var p in placed.OrderBy(p => p.EntityId))
|
||||
{
|
||||
float best = float.MaxValue;
|
||||
int bestIndex = -1;
|
||||
for (int i = 0; i < p.WorldPolygons.Count; i++)
|
||||
{
|
||||
float d = DistanceToPolygon(p.WorldPolygons[i], Wedge);
|
||||
if (d < best) { best = d; bestIndex = i; }
|
||||
}
|
||||
sb.AppendLine(Inv(
|
||||
$" ent=0x{p.EntityId:X8} gfx=0x{p.GfxObjId:X8} nearestPolyDist={best:F3} m (poly#{bestIndex})"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// REFEREE. Two independent answers to the SAME question, per owner:
|
||||
// (a) the production BSP walk, FlatBspQuery.SphereIntersectsPoly —
|
||||
// the Path-5 static-overlap primitive the resolver actually runs,
|
||||
// node bounding-sphere early-outs and all;
|
||||
// (b) brute force over every polygon the tree indexes.
|
||||
// They must agree. Where (b) says "a polygon is inside the sphere" and
|
||||
// (a) says "no", the geometry is present and the TRAVERSAL cannot reach
|
||||
// it — a bounded pocket, which is exactly the reported symptom. Where
|
||||
// both say no, the mesh genuinely is not there and the owner is
|
||||
// innocent.
|
||||
//
|
||||
// Sample points are taken ON the mesh (vertices + centroids, nudged
|
||||
// both ways along the polygon normal) so the referee covers the whole
|
||||
// surface rather than an arbitrary lattice, and any disagreement
|
||||
// localises to a named polygon.
|
||||
// ------------------------------------------------------------------
|
||||
const float SphereRadius = 0.48f; // the live mover's radius
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(Inv(
|
||||
$"--- referee: BSP walk vs brute force, sphere r={SphereRadius:F3} ---"));
|
||||
|
||||
foreach (var p in placed.OrderBy(p => p.EntityId))
|
||||
{
|
||||
int samples = 0, mismatch = 0, bothHit = 0;
|
||||
var mmMin = new Vector3(float.PositiveInfinity);
|
||||
var mmMax = new Vector3(float.NegativeInfinity);
|
||||
var firstMismatches = new List<string>();
|
||||
|
||||
foreach (Vector3 probe in SurfaceProbes(p.LocalPolygons, SphereRadius))
|
||||
{
|
||||
samples++;
|
||||
bool walk = FlatBspQuery.SphereIntersectsPoly(
|
||||
p.Bsp, probe, SphereRadius, out _, out _);
|
||||
bool brute = BruteHit(p.LocalPolygons, probe, SphereRadius);
|
||||
if (walk == brute) { if (walk) bothHit++; continue; }
|
||||
mismatch++;
|
||||
Vector3 world = p.Position + Vector3.Transform(probe, p.Rotation);
|
||||
mmMin = Vector3.Min(mmMin, world);
|
||||
mmMax = Vector3.Max(mmMax, world);
|
||||
if (firstMismatches.Count < 8)
|
||||
{
|
||||
firstMismatches.Add(Inv(
|
||||
$" local=({probe.X:F2},{probe.Y:F2},{probe.Z:F2}) " +
|
||||
$"world=({world.X:F2},{world.Y:F2},{world.Z:F2}) " +
|
||||
$"walk={walk} brute={brute}"));
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine(Inv(
|
||||
$" ent=0x{p.EntityId:X8} gfx=0x{p.GfxObjId:X8} polys={p.BspPolys} " +
|
||||
$"samples={samples} bothHit={bothHit} MISMATCH={mismatch}"));
|
||||
if (mismatch > 0)
|
||||
{
|
||||
sb.AppendLine(Inv(
|
||||
$" mismatch world box=({mmMin.X:F2},{mmMin.Y:F2},{mmMin.Z:F2})..({mmMax.X:F2},{mmMax.Y:F2},{mmMax.Z:F2})"));
|
||||
foreach (string line in firstMismatches) sb.AppendLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// The exact live query, replayed. The wedge point, plus every point on
|
||||
// a 1 m lattice through the plateau, both answers side by side.
|
||||
// ------------------------------------------------------------------
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("--- the live wedge query, replayed per owner ---");
|
||||
foreach (var p in placed.OrderBy(p => p.EntityId))
|
||||
{
|
||||
Vector3 local = Vector3.Transform(
|
||||
Wedge - p.Position, Quaternion.Inverse(p.Rotation));
|
||||
bool walk = FlatBspQuery.SphereIntersectsPoly(
|
||||
p.Bsp, local, SphereRadius, out ushort id, out _);
|
||||
bool brute = BruteHit(p.LocalPolygons, local, SphereRadius);
|
||||
float nearest = float.MaxValue;
|
||||
foreach (var poly in p.LocalPolygons)
|
||||
nearest = MathF.Min(nearest, DistanceToPolygon(poly, local));
|
||||
sb.AppendLine(Inv(
|
||||
$" ent=0x{p.EntityId:X8} gfx=0x{p.GfxObjId:X8} " +
|
||||
$"walkHit={walk} (poly {id}) bruteHit={brute} nearestPoly={nearest:F3} m"));
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("--- 1 m lattice over the plateau (x 120..150, y 40..70, z 44..56) ---");
|
||||
foreach (var p in placed.OrderBy(p => p.EntityId))
|
||||
{
|
||||
int lat = 0, latWalk = 0, latBrute = 0, latMismatch = 0;
|
||||
var missMin = new Vector3(float.PositiveInfinity);
|
||||
var missMax = new Vector3(float.NegativeInfinity);
|
||||
for (float x = 120f; x <= 150f; x += 1f)
|
||||
for (float y = 40f; y <= 70f; y += 1f)
|
||||
for (float z = 44f; z <= 56f; z += 1f)
|
||||
{
|
||||
var world = new Vector3(x, y, z);
|
||||
Vector3 local = Vector3.Transform(
|
||||
world - p.Position, Quaternion.Inverse(p.Rotation));
|
||||
lat++;
|
||||
bool walk = FlatBspQuery.SphereIntersectsPoly(
|
||||
p.Bsp, local, SphereRadius, out _, out _);
|
||||
bool brute = BruteHit(p.LocalPolygons, local, SphereRadius);
|
||||
if (walk) latWalk++;
|
||||
if (brute) latBrute++;
|
||||
if (walk == brute) continue;
|
||||
latMismatch++;
|
||||
missMin = Vector3.Min(missMin, world);
|
||||
missMax = Vector3.Max(missMax, world);
|
||||
}
|
||||
sb.AppendLine(Inv(
|
||||
$" ent=0x{p.EntityId:X8} gfx=0x{p.GfxObjId:X8} points={lat} " +
|
||||
$"walkHits={latWalk} bruteHits={latBrute} MISMATCH={latMismatch}" +
|
||||
(latMismatch > 0
|
||||
? $" box=({missMin.X:F1},{missMin.Y:F1},{missMin.Z:F1})..({missMax.X:F1},{missMax.Y:F1},{missMax.Z:F1})"
|
||||
: string.Empty)));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// The recorded live track, replayed. Each row is a position the client
|
||||
// actually held, taken from 337-support.log; the Y values on the climb
|
||||
// rows carry the +576 m live-centre offset that log was written in
|
||||
// (live centre 0x8763, three landblocks south of 0x8766), removed here.
|
||||
// For each, the walkable surface directly above/below it.
|
||||
// ------------------------------------------------------------------
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("--- recorded live positions vs the surface at their own XY ---");
|
||||
(string Tag, Vector3 P, float CpNz)[] track =
|
||||
[
|
||||
("climb cpNz=0.7401", new Vector3(185.53f, 617.52f - 576f, 3.839f), 0.7401f),
|
||||
("climb cpNz=0.7078", new Vector3(172.63f, 600.23f - 576f, 17.910f), 0.7078f),
|
||||
("climb cpNz=0.8640", new Vector3(154.04f, 596.93f - 576f, 32.543f), 0.8640f),
|
||||
("climb cpNz=0.8216", new Vector3(143.69f, 611.83f - 576f, 43.352f), 0.8216f),
|
||||
("climb cpNz=0.9532", new Vector3(138.38f, 621.53f - 576f, 48.567f), 0.9532f),
|
||||
("STALL cpNz=0.9805", new Vector3(130.81f, 625.88f - 576f, 51.097f), 0.9805f),
|
||||
("stand cpNz=0.9532", new Vector3(134.08f, 623.63f - 576f, 50.074f), 0.9532f),
|
||||
("stand cpNz=0.9532", new Vector3(135.77f, 625.86f - 576f, 49.994f), 0.9532f),
|
||||
("stand cpNz=1.0000", new Vector3(131.38f, 627.49f - 576f, 49.908f), 1.0000f),
|
||||
("STALL cpNz=0.9805", new Vector3(131.68f, 627.58f - 576f, 51.096f), 0.9805f),
|
||||
("WEDGE support=none", Wedge, float.NaN),
|
||||
];
|
||||
|
||||
foreach (var row in track)
|
||||
{
|
||||
var col = Column(placed, row.P.X, row.P.Y);
|
||||
var up = col.Where(c => c.Nz > 0f).OrderByDescending(c => c.Z).ToList();
|
||||
var upBelowHead = up.Where(c => c.Z <= row.P.Z + 0.05f).ToList();
|
||||
string surf = up.Count == 0
|
||||
? "NO UPWARD SURFACE IN COLUMN"
|
||||
: Inv($"topUp z={up[0].Z:F3} (ent=0x{up[0].Ent:X8}) delta={row.P.Z - up[0].Z:+0.000;-0.000}");
|
||||
sb.AppendLine(Inv(
|
||||
$" {row.Tag} p=({row.P.X:F2},{row.P.Y:F2},{row.P.Z:F3}) " +
|
||||
$"colPolys={col.Count} upFacing={up.Count} atOrBelowFeet={upBelowHead.Count} {surf}"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// HOLE MAP. Over the plateau, at 0.5 m XY resolution, does ANY
|
||||
// upward-facing physics polygon exist above z=35? A bounded region
|
||||
// with none is a hole in the rock's walking surface — a body that
|
||||
// reaches it stops being supported, sinks, and a corpse dropped on it
|
||||
// falls through, while a jump that clears it lands fine on the far
|
||||
// side. That is the reported symptom exactly.
|
||||
// ------------------------------------------------------------------
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("--- hole map: upward-facing physics coverage over the plateau ---");
|
||||
sb.AppendLine(" (0.5 m XY grid, x 118..152, y 38..72; '.'=covered above z=35, "
|
||||
+ "'#'=NO upward surface, '*'=the wedge XY)");
|
||||
int covered = 0, holes = 0;
|
||||
var holeMin = new Vector2(float.PositiveInfinity);
|
||||
var holeMax = new Vector2(float.NegativeInfinity);
|
||||
for (float y = 72f; y >= 38f; y -= 0.5f)
|
||||
{
|
||||
var line = new StringBuilder(Inv($" y={y,6:F1} "));
|
||||
for (float x = 118f; x <= 152f; x += 0.5f)
|
||||
{
|
||||
var col = Column(placed, x, y);
|
||||
bool hasUp = col.Any(c => c.Nz > 0f && c.Z > 35f);
|
||||
bool isWedge = MathF.Abs(x - Wedge.X) < 0.25f
|
||||
&& MathF.Abs(y - Wedge.Y) < 0.25f;
|
||||
if (hasUp) covered++;
|
||||
else
|
||||
{
|
||||
holes++;
|
||||
holeMin = Vector2.Min(holeMin, new Vector2(x, y));
|
||||
holeMax = Vector2.Max(holeMax, new Vector2(x, y));
|
||||
}
|
||||
line.Append(isWedge ? '*' : hasUp ? '.' : '#');
|
||||
}
|
||||
sb.AppendLine(line.ToString());
|
||||
}
|
||||
sb.AppendLine(Inv(
|
||||
$" covered={covered} holes={holes}" +
|
||||
(holes > 0
|
||||
? $" holeBox=({holeMin.X:F1},{holeMin.Y:F1})..({holeMax.X:F1},{holeMax.Y:F1})"
|
||||
: string.Empty)));
|
||||
|
||||
string outPath = Path.Combine(Path.GetTempPath(), "issue337-neftet-geometry.txt");
|
||||
File.WriteAllText(outPath, sb.ToString());
|
||||
_out.WriteLine(sb.ToString());
|
||||
_out.WriteLine($"(also written to {outPath})");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
/// <summary>
|
||||
/// #337 descent replay. The live capture shows the player standing on the
|
||||
/// plateau at feet z=51.096 (surface ~51.08), jumping to escape a
|
||||
/// horizontal stall, and then falling STRAIGHT THROUGH that same surface —
|
||||
/// four consecutive resolves accept a full-length downward step with no
|
||||
/// contact plane, ending 1.1 m inside solid rock. This replays those exact
|
||||
/// steps against the exact mesh, with a control step from the climb where
|
||||
/// the client DID land correctly on the same mesh.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ReplayTheDescentThatFellThroughTheSurface()
|
||||
{
|
||||
System.Globalization.CultureInfo.CurrentCulture =
|
||||
System.Globalization.CultureInfo.InvariantCulture;
|
||||
|
||||
string? datDir = ConformanceDats.ResolveDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
_out.WriteLine("SKIP: installed retail DAT directory is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var info = dats.Get<LandBlockInfo>((Landblock & 0xFFFF0000u) | 0xFFFEu);
|
||||
Assert.NotNull(info);
|
||||
|
||||
uint lbX = (Landblock >> 24) & 0xFFu;
|
||||
uint lbY = (Landblock >> 16) & 0xFFu;
|
||||
uint counter = 0;
|
||||
var cache = new PhysicsDataCache();
|
||||
var placed = new List<Placed>();
|
||||
foreach (var stab in info!.Objects)
|
||||
{
|
||||
if (!IsSupported(stab.Id)) continue;
|
||||
uint entityId = LandblockStaticEntityIdAllocatorAllocate(lbX, lbY, ref counter);
|
||||
placed.AddRange(Place(dats, cache, entityId, stab.Id,
|
||||
stab.Frame.Origin, stab.Frame.Orientation));
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// The player's verbatim dat sphere list — the same rows
|
||||
// PhysicsEngine hands to SpherePath.InitPath (TS-46).
|
||||
var playerSetup = dats.Get<Setup>(0x0200_0001u);
|
||||
Assert.NotNull(playerSetup);
|
||||
FlatSetupCollision playerFlat =
|
||||
FlatCollisionAssetBuilder.FlattenSetup(playerSetup!);
|
||||
sb.AppendLine("--- player setup 0x02000001 collision volume ---");
|
||||
sb.AppendLine(Inv(
|
||||
$" height={playerFlat.Height:F3} radius={playerFlat.Radius:F3} " +
|
||||
$"stepUp={playerFlat.StepUpHeight:F3} stepDown={playerFlat.StepDownHeight:F3} " +
|
||||
$"spheres={playerFlat.Spheres.Length} cylinders={playerFlat.Cylinders.Length}"));
|
||||
for (int i = 0; i < playerFlat.Spheres.Length; i++)
|
||||
{
|
||||
var s = playerFlat.Spheres[i];
|
||||
sb.AppendLine(Inv(
|
||||
$" sphere[{i}] origin=({s.Origin.X:F3},{s.Origin.Y:F3},{s.Origin.Z:F3}) r={s.Radius:F3}"));
|
||||
}
|
||||
for (int i = 0; i < playerFlat.Cylinders.Length; i++)
|
||||
{
|
||||
var c = playerFlat.Cylinders[i];
|
||||
sb.AppendLine(Inv(
|
||||
$" cylsphere[{i}] origin=({c.Origin.X:F3},{c.Origin.Y:F3},{c.Origin.Z:F3}) r={c.Radius:F3} h={c.Height:F3}"));
|
||||
}
|
||||
|
||||
// Sphere rows actually used. The live reach probe recorded
|
||||
// sphereR=0.480, so this asserts the replay is using the same volume
|
||||
// the client used rather than a lookalike.
|
||||
var rows = playerFlat.Spheres.Length > 0
|
||||
? playerFlat.Spheres.ToArray()
|
||||
: playerFlat.Cylinders
|
||||
.Select(c => new FlatCollisionSphere(c.Origin, c.Radius))
|
||||
.ToArray();
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("--- surface column at the fall XY ---");
|
||||
foreach ((string tag, float x, float y) in new[]
|
||||
{
|
||||
("stand-before-jump", 131.683f, 627.581f - 576f),
|
||||
("fall/landing ", 131.13f, 627.61f - 576f),
|
||||
("final wedge ", 131.38f, 627.49f - 576f),
|
||||
("login wedge ", Wedge.X, Wedge.Y),
|
||||
})
|
||||
{
|
||||
var col = Column(placed, x, y).OrderByDescending(c => c.Z).ToList();
|
||||
sb.AppendLine(Inv($" {tag} xy=({x:F3},{y:F3}) polys={col.Count}"));
|
||||
foreach (var c in col)
|
||||
{
|
||||
sb.AppendLine(Inv(
|
||||
$" z={c.Z,9:F3} nz={c.Nz,8:F4} ent=0x{c.Ent:X8} gfx=0x{c.Gfx:X8}"));
|
||||
}
|
||||
}
|
||||
|
||||
// The steps, feet-space, straight from 337-support.log (Y carries the
|
||||
// +576 m live-centre offset that log was written in).
|
||||
(string Tag, Vector3 From, Vector3 To, bool ClientHit)[] steps =
|
||||
[
|
||||
// Control: the climb. The client accepted these with a contact
|
||||
// plane from StepSphereDown, i.e. it DID land on this mesh.
|
||||
("CONTROL climb step", new Vector3(143.69f, 611.83f - 576f, 43.352f),
|
||||
new Vector3(143.69f, 611.83f - 576f, 42.852f), true),
|
||||
("CONTROL stand step", new Vector3(135.77f, 625.86f - 576f, 49.994f),
|
||||
new Vector3(135.77f, 625.86f - 576f, 49.494f), true),
|
||||
// The failing descent, verbatim, four consecutive resolves.
|
||||
("FALL 1 51.389->51.269", new Vector3(131.13f, 627.61f - 576f, 51.389f),
|
||||
new Vector3(131.13f, 627.61f - 576f, 51.269f), false),
|
||||
("FALL 2 51.269->50.961", new Vector3(131.13f, 627.61f - 576f, 51.269f),
|
||||
new Vector3(131.13f, 627.61f - 576f, 50.961f), false),
|
||||
("FALL 3 50.961->50.670", new Vector3(131.13f, 627.61f - 576f, 50.961f),
|
||||
new Vector3(131.13f, 627.61f - 576f, 50.670f), false),
|
||||
("FALL 4 50.670->50.335", new Vector3(131.13f, 627.61f - 576f, 50.670f),
|
||||
new Vector3(131.13f, 627.61f - 576f, 50.335f), false),
|
||||
("FALL 5 50.335->50.021", new Vector3(131.13f, 627.61f - 576f, 50.335f),
|
||||
new Vector3(131.13f, 627.61f - 576f, 50.021f), false),
|
||||
("FALL 6 50.021->49.656 (client BLOCKED here)",
|
||||
new Vector3(131.13f, 627.61f - 576f, 50.021f),
|
||||
new Vector3(131.13f, 627.61f - 576f, 49.656f), false),
|
||||
];
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("--- swept-sphere descent replay against every owner ---");
|
||||
foreach (var step in steps)
|
||||
{
|
||||
sb.AppendLine(Inv(
|
||||
$" {step.Tag} (client recorded a contact plane: {step.ClientHit})"));
|
||||
foreach (var p in placed.OrderBy(p => p.EntityId))
|
||||
{
|
||||
var invRot = Quaternion.Inverse(p.Rotation);
|
||||
for (int i = 0; i < rows.Length; i++)
|
||||
{
|
||||
Vector3 worldFrom = step.From + rows[i].Origin;
|
||||
Vector3 worldTo = step.To + rows[i].Origin;
|
||||
Vector3 localTo = Vector3.Transform(worldTo - p.Position, invRot);
|
||||
Vector3 localFrom = Vector3.Transform(worldFrom - p.Position, invRot);
|
||||
Vector3 localMove = localTo - localFrom;
|
||||
|
||||
bool swept = FlatBspQuery.SphereIntersectsPolyWithTime(
|
||||
p.Bsp, localTo, rows[i].Radius, localMove,
|
||||
out ushort sweptId, out _, out float sweptTime);
|
||||
bool stat = FlatBspQuery.SphereIntersectsPoly(
|
||||
p.Bsp, localTo, rows[i].Radius, out ushort statId, out _);
|
||||
float nearest = float.MaxValue;
|
||||
foreach (var poly in p.LocalPolygons)
|
||||
nearest = MathF.Min(nearest, DistanceToPolygon(poly, localTo));
|
||||
if (!swept && !stat && nearest > 3f) continue; // far away, silent
|
||||
sb.AppendLine(Inv(
|
||||
$" ent=0x{p.EntityId:X8} sphere[{i}] r={rows[i].Radius:F3} " +
|
||||
$"swept={swept}(poly {sweptId} t={sweptTime:F4}) " +
|
||||
$"static={stat}(poly {statId}) nearestPoly={nearest:F3} m"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
string outPath = Path.Combine(Path.GetTempPath(), "issue337-descent-replay.txt");
|
||||
File.WriteAllText(outPath, sb.ToString());
|
||||
_out.WriteLine(sb.ToString());
|
||||
_out.WriteLine($"(also written to {outPath})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #337 REPRODUCER. At the position the client fell through the plateau,
|
||||
/// the BSP query returns a hit — the geometry is present, correctly
|
||||
/// placed, and reachable by the production traversal. This asserts that
|
||||
/// load-bearing fact, which must hold both before and after any fix; if it
|
||||
/// ever stops holding, the diagnosis in
|
||||
/// docs/research/2026-08-06-337-neftet-wedge-mechanism.md is void.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TheBspQueryReturnsAHitAtThePositionTheClientFellThrough()
|
||||
{
|
||||
string? datDir = ConformanceDats.ResolveDatDir();
|
||||
if (datDir is null) return; // CI without dats — the sibling dumps skip too.
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var info = dats.Get<LandBlockInfo>((Landblock & 0xFFFF0000u) | 0xFFFEu);
|
||||
Assert.NotNull(info);
|
||||
|
||||
Placed rock = ResolveOwner(dats, info!, 0xC876_6009u);
|
||||
|
||||
// Feet position and per-sphere layout, verbatim from the live capture
|
||||
// (337-support.log t=303221015..303221125) and Setup 0x02000001.
|
||||
var feet = new Vector3(131.13f, 627.61f - 576f, 50.961f);
|
||||
var footSphereOffset = new Vector3(0f, 0f, 0.475f);
|
||||
const float SphereRadius = 0.48f;
|
||||
|
||||
Vector3 local = Vector3.Transform(
|
||||
feet + footSphereOffset - rock.Position,
|
||||
Quaternion.Inverse(rock.Rotation));
|
||||
|
||||
bool hit = FlatBspQuery.SphereIntersectsPoly(
|
||||
rock.Bsp, local, SphereRadius, out ushort polygonId, out _);
|
||||
|
||||
Assert.True(
|
||||
hit,
|
||||
"The rock's physics BSP must report the plateau surface under the "
|
||||
+ "mover at the position the live client fell through it. If this "
|
||||
+ "fails, the defect is in the geometry or the traversal after all.");
|
||||
Assert.NotEqual(0, polygonId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #337 EVIDENCE — the installed-DAT measurement that condemned the
|
||||
/// query-site broadphase, pinned so the diagnosis stays checkable.
|
||||
///
|
||||
/// <para>
|
||||
/// The deleted per-object filter in
|
||||
/// <c>Transition.FindObjCollisionsInCell</c> measured the mover's distance
|
||||
/// to the shadow entry's <c>Position</c> — the part ORIGIN — and compared
|
||||
/// it against <c>obj.Radius</c>, which is the physics-BSP ROOT BOUNDING
|
||||
/// SPHERE's radius. For this rock those two points are 23.6 m apart, so a
|
||||
/// mover standing on its plateau is inside the bounding sphere by ~20 m of
|
||||
/// margin and still failed the test. Same defect AP-156 fixed in the flood
|
||||
/// and #334 fixed in the registration extent walk, left in place at the
|
||||
/// query site — filed as #333.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail has no such filter. <c>CPartArray::FindObjCollisions</c>
|
||||
/// @0x00518180 is a bare loop over parts and
|
||||
/// <c>CPhysicsPart::find_obj_collisions</c> @0x0050d8d0 does two null
|
||||
/// checks and calls <c>CGfxObj::find_obj_collisions</c> @0x00534700 —
|
||||
/// verified instruction-by-instruction against the PDB-paired
|
||||
/// v11.4186 binary. Neither contains a compare or any float math. The
|
||||
/// only spatial rejection retail performs is the BSP node bounding-sphere
|
||||
/// test inside the walk, which is correctly centred. #333 therefore
|
||||
/// deleted the filter outright rather than re-centring it.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// This test asserts the DATA, not the production predicate — the
|
||||
/// production gate is
|
||||
/// <c>Issue333BroadphaseReachFilterTests.OffCentreBspFloorStopsAFallingMover</c>,
|
||||
/// which drives <c>ResolveWithTransition</c> end-to-end. Both halves must
|
||||
/// hold for the diagnosis to be the one recorded: the origin-measured
|
||||
/// distance OUTSIDE the old budget, the centre-measured distance
|
||||
/// comfortably INSIDE the same radius. If a future DAT or transform change
|
||||
/// makes either false, the recorded mechanism no longer describes this
|
||||
/// object and the research note needs revisiting.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TheOldBroadphaseMeasuredToTheOriginAndSoRejectedGeometryItStoodOn()
|
||||
{
|
||||
string? datDir = ConformanceDats.ResolveDatDir();
|
||||
if (datDir is null) return;
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var info = dats.Get<LandBlockInfo>((Landblock & 0xFFFF0000u) | 0xFFFEu);
|
||||
Assert.NotNull(info);
|
||||
Placed rock = ResolveOwner(dats, info!, 0xC876_6009u);
|
||||
|
||||
var feet = new Vector3(131.13f, 627.61f - 576f, 50.961f);
|
||||
Vector3 currPos = feet + new Vector3(0f, 0f, 0.475f);
|
||||
const float SphereRadius = 0.48f;
|
||||
const float Movement = 0.308f; // the live step length
|
||||
|
||||
var root = rock.Bsp.Nodes[rock.Bsp.RootIndex].BoundingSphere;
|
||||
float ownerRadius = root.Radius;
|
||||
|
||||
// Verbatim from the deleted predicate.
|
||||
float distToOrigin = (currPos - rock.Position).Length();
|
||||
float maxReach = SphereRadius + ownerRadius + Movement + 2f;
|
||||
|
||||
Assert.True(
|
||||
distToOrigin > maxReach,
|
||||
$"the old filter is supposed to have REJECTED this candidate: "
|
||||
+ $"distToOrigin={distToOrigin:F3} vs maxReach={maxReach:F3}.");
|
||||
|
||||
// What retail's BSP node test measures instead.
|
||||
Vector3 centre = rock.Position
|
||||
+ Vector3.Transform(root.Origin, rock.Rotation);
|
||||
float distToCentre = (currPos - centre).Length();
|
||||
|
||||
Assert.True(
|
||||
distToCentre <= ownerRadius,
|
||||
$"the mover must be inside the root bounding SPHERE it was standing "
|
||||
+ $"on: distToCentre={distToCentre:F3} > radius={ownerRadius:F3}.");
|
||||
}
|
||||
|
||||
private static Placed ResolveOwner(
|
||||
DatCollection dats, LandBlockInfo info, uint wantedEntityId)
|
||||
{
|
||||
uint lbX = (Landblock >> 24) & 0xFFu;
|
||||
uint lbY = (Landblock >> 16) & 0xFFu;
|
||||
uint counter = 0;
|
||||
var cache = new PhysicsDataCache();
|
||||
foreach (var stab in info.Objects)
|
||||
{
|
||||
if (!IsSupported(stab.Id)) continue;
|
||||
uint entityId = LandblockStaticEntityIdAllocatorAllocate(lbX, lbY, ref counter);
|
||||
if (entityId != wantedEntityId) continue;
|
||||
var parts = Place(dats, cache, entityId, stab.Id,
|
||||
stab.Frame.Origin, stab.Frame.Orientation).ToList();
|
||||
Assert.Single(parts);
|
||||
return parts[0];
|
||||
}
|
||||
throw new InvalidOperationException(
|
||||
$"landblock static 0x{wantedEntityId:X8} not found.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pass-through. Concatenated interpolated fragments cannot bind to
|
||||
/// <see cref="FormattableString"/>, so the test method pins
|
||||
/// <c>CurrentCulture</c> to invariant instead and this marks the call
|
||||
/// sites that depend on it.
|
||||
/// </summary>
|
||||
private static string Inv(string s) => s;
|
||||
|
||||
private static uint LandblockStaticEntityIdAllocatorAllocate(
|
||||
uint lbX, uint lbY, ref uint counter)
|
||||
=> LandblockStaticEntityIdAllocator.Allocate(lbX, lbY, ref counter);
|
||||
|
||||
private static bool IsSupported(uint id)
|
||||
{
|
||||
uint type = id & TypeMask;
|
||||
return type == GfxObjMask || type == SetupMask;
|
||||
}
|
||||
|
||||
private static IEnumerable<Placed> Place(
|
||||
DatCollection dats,
|
||||
PhysicsDataCache cache,
|
||||
uint entityId,
|
||||
uint datId,
|
||||
Vector3 origin,
|
||||
Quaternion orientation)
|
||||
{
|
||||
var parts = new List<(uint GfxObjId, Vector3 LocalPos, Quaternion LocalRot)>();
|
||||
|
||||
if ((datId & TypeMask) == GfxObjMask)
|
||||
{
|
||||
parts.Add((datId, Vector3.Zero, Quaternion.Identity));
|
||||
}
|
||||
else
|
||||
{
|
||||
var setup = dats.Get<Setup>(datId);
|
||||
if (setup is null) yield break;
|
||||
for (int i = 0; i < setup.Parts.Count; i++)
|
||||
{
|
||||
Vector3 lp = Vector3.Zero;
|
||||
Quaternion lr = Quaternion.Identity;
|
||||
if (!setup.PlacementFrames.TryGetValue(
|
||||
DatReaderWriter.Enums.Placement.Resting, out var pf)
|
||||
&& !setup.PlacementFrames.TryGetValue(
|
||||
DatReaderWriter.Enums.Placement.Default, out pf))
|
||||
{
|
||||
pf = setup.PlacementFrames.Values.FirstOrDefault();
|
||||
}
|
||||
if (pf?.Frames is not null && i < pf.Frames.Count)
|
||||
{
|
||||
lp = pf.Frames[i].Origin;
|
||||
lr = pf.Frames[i].Orientation;
|
||||
}
|
||||
parts.Add((setup.Parts[i], lp, lr));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var gfx = dats.Get<GfxObj>(part.GfxObjId);
|
||||
if (gfx is null) continue;
|
||||
cache.CacheGfxObj(part.GfxObjId, gfx);
|
||||
FlatGfxObjCollisionAsset flat =
|
||||
FlatCollisionAssetBuilder.FlattenGfxObj(gfx);
|
||||
FlatPhysicsBsp bsp = flat.PhysicsBsp;
|
||||
if (bsp.RootIndex < 0 || bsp.Nodes.Length == 0) continue;
|
||||
|
||||
Vector3 partWorldPos = origin + Vector3.Transform(part.LocalPos, orientation);
|
||||
Quaternion partWorldRot = orientation * part.LocalRot;
|
||||
|
||||
var seen = new HashSet<int>();
|
||||
var worldPolys = new List<Vector3[]>();
|
||||
var localPolys = new List<Vector3[]>();
|
||||
var min = new Vector3(float.PositiveInfinity);
|
||||
var max = new Vector3(float.NegativeInfinity);
|
||||
|
||||
foreach (var node in bsp.Nodes)
|
||||
{
|
||||
var range = node.PolygonIndexRange;
|
||||
for (int i = range.Start; i < range.EndExclusive; i++)
|
||||
{
|
||||
int pi = bsp.PolygonIndexStream[i];
|
||||
if ((uint)pi >= (uint)bsp.PolygonTable.Polygons.Length) continue;
|
||||
if (!seen.Add(pi)) continue;
|
||||
var poly = bsp.PolygonTable.Polygons[pi];
|
||||
var vr = poly.VertexRange;
|
||||
var verts = new Vector3[vr.Count];
|
||||
var lverts = new Vector3[vr.Count];
|
||||
for (int v = 0; v < vr.Count; v++)
|
||||
{
|
||||
Vector3 local = bsp.PolygonTable.Vertices[vr.Start + v];
|
||||
Vector3 world = partWorldPos
|
||||
+ Vector3.Transform(local, partWorldRot);
|
||||
lverts[v] = local;
|
||||
verts[v] = world;
|
||||
min = Vector3.Min(min, world);
|
||||
max = Vector3.Max(max, world);
|
||||
}
|
||||
worldPolys.Add(verts);
|
||||
localPolys.Add(lverts);
|
||||
}
|
||||
}
|
||||
|
||||
if (worldPolys.Count == 0) continue;
|
||||
|
||||
yield return new Placed(
|
||||
entityId, datId, part.GfxObjId, partWorldPos, partWorldRot,
|
||||
worldPolys, localPolys, bsp, min, max,
|
||||
bsp.Nodes.Length, worldPolys.Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Points just off the mesh surface, both sides, at every vertex and
|
||||
/// centroid. A sphere centred here demonstrably contains mesh, so the
|
||||
/// production walk must report a hit.
|
||||
/// </summary>
|
||||
private static IEnumerable<Vector3> SurfaceProbes(
|
||||
List<Vector3[]> polygons, float radius)
|
||||
{
|
||||
float nudge = radius * 0.5f;
|
||||
foreach (Vector3[] poly in polygons)
|
||||
{
|
||||
if (poly.Length < 3) continue;
|
||||
Vector3 n = Vector3.Cross(poly[1] - poly[0], poly[2] - poly[0]);
|
||||
float len = n.Length();
|
||||
if (len < 1e-9f) continue;
|
||||
n /= len;
|
||||
|
||||
Vector3 centroid = Vector3.Zero;
|
||||
foreach (Vector3 v in poly) centroid += v;
|
||||
centroid /= poly.Length;
|
||||
|
||||
yield return centroid + n * nudge;
|
||||
yield return centroid - n * nudge;
|
||||
foreach (Vector3 v in poly)
|
||||
{
|
||||
// Pull slightly toward the centroid so a vertex probe sits on
|
||||
// the polygon rather than exactly on its corner.
|
||||
Vector3 inset = Vector3.Lerp(v, centroid, 0.15f);
|
||||
yield return inset + n * nudge;
|
||||
yield return inset - n * nudge;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool BruteHit(List<Vector3[]> polygons, Vector3 centre, float radius)
|
||||
{
|
||||
foreach (Vector3[] poly in polygons)
|
||||
{
|
||||
if (DistanceToPolygon(poly, centre) < radius - 1e-4f)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every physics polygon whose world XY projection contains
|
||||
/// (<paramref name="x"/>, <paramref name="y"/>), with the Z of its plane
|
||||
/// at that XY and the Z component of its normal.
|
||||
/// </summary>
|
||||
private static List<(uint Ent, uint Gfx, float Z, float Nz)> Column(
|
||||
List<Placed> placed, float x, float y)
|
||||
{
|
||||
var result = new List<(uint, uint, float, float)>();
|
||||
foreach (var p in placed)
|
||||
{
|
||||
if (x < p.WorldMin.X || x > p.WorldMax.X
|
||||
|| y < p.WorldMin.Y || y > p.WorldMax.Y) continue;
|
||||
foreach (Vector3[] poly in p.WorldPolygons)
|
||||
{
|
||||
if (!XyContains(poly, x, y)) continue;
|
||||
if (!TryPlaneZ(poly, x, y, out float z, out float nz)) continue;
|
||||
result.Add((p.EntityId, p.GfxObjId, z, nz));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool Inside(Vector3 min, Vector3 max, Vector3 p)
|
||||
=> p.X >= min.X && p.X <= max.X
|
||||
&& p.Y >= min.Y && p.Y <= max.Y
|
||||
&& p.Z >= min.Z && p.Z <= max.Z;
|
||||
|
||||
private static float YawDegrees(Quaternion q)
|
||||
{
|
||||
// Rotation of +X about Z only; indicative for the mostly-Z-only
|
||||
// orientations landblock stabs carry.
|
||||
Vector3 x = Vector3.Transform(Vector3.UnitX, q);
|
||||
return MathF.Atan2(x.Y, x.X) * 180f / MathF.PI;
|
||||
}
|
||||
|
||||
private static bool XyContains(Vector3[] poly, float x, float y)
|
||||
{
|
||||
bool inside = false;
|
||||
for (int i = 0, j = poly.Length - 1; i < poly.Length; j = i++)
|
||||
{
|
||||
float yi = poly[i].Y, yj = poly[j].Y;
|
||||
if ((yi > y) == (yj > y)) continue;
|
||||
float t = (y - yi) / (yj - yi);
|
||||
float xAt = poly[i].X + t * (poly[j].X - poly[i].X);
|
||||
if (x < xAt) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
private static bool TryPlaneZ(Vector3[] poly, float x, float y, out float z, out float nz)
|
||||
{
|
||||
z = 0f; nz = 0f;
|
||||
if (poly.Length < 3) return false;
|
||||
Vector3 n = Vector3.Cross(poly[1] - poly[0], poly[2] - poly[0]);
|
||||
float len = n.Length();
|
||||
if (len < 1e-9f) return false;
|
||||
n /= len;
|
||||
if (MathF.Abs(n.Z) < 1e-6f) return false;
|
||||
nz = n.Z;
|
||||
// n · (P - poly0) = 0 -> z = poly0.z - (n.x*(x-p0.x) + n.y*(y-p0.y)) / n.z
|
||||
z = poly[0].Z - (n.X * (x - poly[0].X) + n.Y * (y - poly[0].Y)) / n.Z;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static float DistanceToPolygon(Vector3[] poly, Vector3 p)
|
||||
{
|
||||
float best = float.MaxValue;
|
||||
for (int i = 2; i < poly.Length; i++)
|
||||
best = MathF.Min(best, DistanceToTriangle(poly[0], poly[i - 1], poly[i], p));
|
||||
if (poly.Length == 2)
|
||||
best = MathF.Min(best, DistanceToSegment(poly[0], poly[1], p));
|
||||
return best;
|
||||
}
|
||||
|
||||
private static float DistanceToTriangle(Vector3 a, Vector3 b, Vector3 c, Vector3 p)
|
||||
{
|
||||
Vector3 ab = b - a, ac = c - a, ap = p - a;
|
||||
float d1 = Vector3.Dot(ab, ap), d2 = Vector3.Dot(ac, ap);
|
||||
if (d1 <= 0 && d2 <= 0) return Vector3.Distance(p, a);
|
||||
Vector3 bp = p - b;
|
||||
float d3 = Vector3.Dot(ab, bp), d4 = Vector3.Dot(ac, bp);
|
||||
if (d3 >= 0 && d4 <= d3) return Vector3.Distance(p, b);
|
||||
float vc = d1 * d4 - d3 * d2;
|
||||
if (vc <= 0 && d1 >= 0 && d3 <= 0)
|
||||
return Vector3.Distance(p, a + ab * (d1 / (d1 - d3)));
|
||||
Vector3 cp = p - c;
|
||||
float d5 = Vector3.Dot(ab, cp), d6 = Vector3.Dot(ac, cp);
|
||||
if (d6 >= 0 && d5 <= d6) return Vector3.Distance(p, c);
|
||||
float vb = d5 * d2 - d1 * d6;
|
||||
if (vb <= 0 && d2 >= 0 && d6 <= 0)
|
||||
return Vector3.Distance(p, a + ac * (d2 / (d2 - d6)));
|
||||
float va = d3 * d6 - d5 * d4;
|
||||
if (va <= 0 && (d4 - d3) >= 0 && (d5 - d6) >= 0)
|
||||
return Vector3.Distance(p, b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6))));
|
||||
float denom = 1f / (va + vb + vc);
|
||||
return Vector3.Distance(p, a + ab * (vb * denom) + ac * (vc * denom));
|
||||
}
|
||||
|
||||
private static float DistanceToSegment(Vector3 a, Vector3 b, Vector3 p)
|
||||
{
|
||||
Vector3 ab = b - a;
|
||||
float t = Vector3.Dot(p - a, ab) / MathF.Max(1e-9f, Vector3.Dot(ab, ab));
|
||||
t = Math.Clamp(t, 0f, 1f);
|
||||
return Vector3.Distance(p, a + ab * t);
|
||||
}
|
||||
}
|
||||
|
|
@ -26,19 +26,19 @@ public class ShadowObjectRegistryMultiPartTests
|
|||
localPosition: Vector3.Zero,
|
||||
localRotation: Quaternion.Identity,
|
||||
scale: 1.0f,
|
||||
localBounds: new FlatCollisionSphere(Vector3.Zero, 2.0f)),
|
||||
localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 2.0f), null)),
|
||||
ShadowShape.Bsp(
|
||||
gfxObjId: 0x010044B6u,
|
||||
localPosition: Vector3.Zero,
|
||||
localRotation: Quaternion.Identity,
|
||||
scale: 1.0f,
|
||||
localBounds: new FlatCollisionSphere(Vector3.Zero, 2.0f)),
|
||||
localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 2.0f), null)),
|
||||
ShadowShape.Bsp(
|
||||
gfxObjId: 0x010044B6u,
|
||||
localPosition: Vector3.Zero,
|
||||
localRotation: Quaternion.Identity,
|
||||
scale: 1.0f,
|
||||
localBounds: new FlatCollisionSphere(Vector3.Zero, 2.0f))
|
||||
localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 2.0f), null))
|
||||
};
|
||||
|
||||
[Fact]
|
||||
|
|
@ -260,9 +260,11 @@ public class ShadowObjectRegistryMultiPartTests
|
|||
localPosition: localPosition,
|
||||
localRotation: localRotation == default ? Quaternion.Identity : localRotation,
|
||||
scale: 1f,
|
||||
localBounds: new FlatCollisionSphere(
|
||||
boundsCenter == default ? new Vector3(0f, 6f, 0f) : boundsCenter,
|
||||
radius));
|
||||
localGeometry: ShadowPartGeometry.Create(
|
||||
new FlatCollisionSphere(
|
||||
boundsCenter == default ? new Vector3(0f, 6f, 0f) : boundsCenter,
|
||||
radius),
|
||||
null));
|
||||
|
||||
private static List<uint> FloodCellsFor(params ShadowShape[] shapes)
|
||||
{
|
||||
|
|
@ -444,7 +446,9 @@ public class ShadowObjectRegistryMultiPartTests
|
|||
setup,
|
||||
entScale: 1f,
|
||||
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);
|
||||
Assert.Equal(ShadowCollisionType.BSP, only.CollisionType);
|
||||
|
|
|
|||
|
|
@ -973,7 +973,7 @@ public class ShadowObjectRegistryTests
|
|||
Vector3.Zero,
|
||||
Quaternion.Identity,
|
||||
scale: 1f,
|
||||
localBounds: new FlatCollisionSphere(Vector3.Zero, radius));
|
||||
localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, radius), null));
|
||||
|
||||
private static CellPhysics BuildShadowCellSetTests_MakeLeafCell(Matrix4x4 worldTransform)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -467,5 +467,5 @@ public sealed class ShadowSetPositionCommitTests
|
|||
local,
|
||||
Quaternion.Identity,
|
||||
scale: 1f,
|
||||
localBounds: new FlatCollisionSphere(Vector3.Zero, 0.25f));
|
||||
localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 0.25f), null));
|
||||
}
|
||||
|
|
|
|||
120
tests/AcDream.Core.Tests/Physics/SupportProbeClassifierTests.cs
Normal file
120
tests/AcDream.Core.Tests/Physics/SupportProbeClassifierTests.cs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// #337 (2026-08-06 — TEMPORARY, delete with the <c>[support]</c> probe).
|
||||
///
|
||||
/// <para>
|
||||
/// The <c>[support]</c> line's whole value is its <c>support=</c> verdict:
|
||||
/// terrain, an object surface, or nothing. If that classifier is wrong, a
|
||||
/// capture does not merely fail to answer — it answers CONFIDENTLY WRONG, and
|
||||
/// this campaign has already spent two diagnoses on confident wrong answers.
|
||||
/// These cover the decision boundaries directly, so the live capture can be
|
||||
/// read at face value.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SupportProbeClassifierTests
|
||||
{
|
||||
private const float FlatNormalZ = 1f;
|
||||
|
||||
[Fact]
|
||||
public void NoContactPlane_IsUnsupported()
|
||||
{
|
||||
Assert.Equal(
|
||||
"none",
|
||||
PhysicsDiagnostics.ClassifySupport(
|
||||
contactPlaneValid: false,
|
||||
terrainSampled: true,
|
||||
contactPlaneZAtXY: 100f,
|
||||
contactPlaneNormalZ: FlatNormalZ,
|
||||
terrainZ: 100f,
|
||||
terrainNormalZ: FlatNormalZ));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlaneAtTerrainHeightAndTilt_IsTerrain()
|
||||
{
|
||||
Assert.Equal(
|
||||
"terrain",
|
||||
PhysicsDiagnostics.ClassifySupport(
|
||||
contactPlaneValid: true,
|
||||
terrainSampled: true,
|
||||
contactPlaneZAtXY: 41.25f,
|
||||
contactPlaneNormalZ: 0.94f,
|
||||
terrainZ: 41.26f,
|
||||
terrainNormalZ: 0.94f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlaneWellAboveTerrain_IsObject()
|
||||
{
|
||||
// The rock-plateau shape: the body rests six metres above the ground.
|
||||
Assert.Equal(
|
||||
"object",
|
||||
PhysicsDiagnostics.ClassifySupport(
|
||||
contactPlaneValid: true,
|
||||
terrainSampled: true,
|
||||
contactPlaneZAtXY: 47.5f,
|
||||
contactPlaneNormalZ: FlatNormalZ,
|
||||
terrainZ: 41.5f,
|
||||
terrainNormalZ: 0.9f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameHeightDifferentTilt_IsReportedSeparately()
|
||||
{
|
||||
// A collision surface lying flat against sloped ground. This must NOT
|
||||
// collapse into either answer: it is precisely the ambiguous case, and
|
||||
// guessing between them is what the probe exists to avoid.
|
||||
Assert.Equal(
|
||||
"coplanar-tilt-mismatch",
|
||||
PhysicsDiagnostics.ClassifySupport(
|
||||
contactPlaneValid: true,
|
||||
terrainSampled: true,
|
||||
contactPlaneZAtXY: 41.5f,
|
||||
contactPlaneNormalZ: 1.0f,
|
||||
terrainZ: 41.5f,
|
||||
terrainNormalZ: 0.72f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoTerrainUnderTheBody_SaysSoRatherThanGuessing()
|
||||
{
|
||||
Assert.Equal(
|
||||
"no-terrain",
|
||||
PhysicsDiagnostics.ClassifySupport(
|
||||
contactPlaneValid: true,
|
||||
terrainSampled: false,
|
||||
contactPlaneZAtXY: 12f,
|
||||
contactPlaneNormalZ: FlatNormalZ,
|
||||
terrainZ: float.NaN,
|
||||
terrainNormalZ: float.NaN));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlaneHeightIsEvaluatedAtTheBodysOwnXy()
|
||||
{
|
||||
// A 45-degree ramp through the origin: height must track X, or a body
|
||||
// standing on a slope would read as displaced from its own support.
|
||||
var slope = new Plane(Vector3.Normalize(new Vector3(-1f, 0f, 1f)), 0f);
|
||||
|
||||
Assert.True(PhysicsDiagnostics.TryPlaneZAt(slope, 0f, 0f, out float atOrigin));
|
||||
Assert.Equal(0f, atOrigin, 3);
|
||||
|
||||
Assert.True(PhysicsDiagnostics.TryPlaneZAt(slope, 10f, 0f, out float atTen));
|
||||
Assert.Equal(10f, atTen, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerticalPlaneHasNoHeight()
|
||||
{
|
||||
// A wall is never a floor. Reporting a height for one would read as a
|
||||
// wildly displaced surface and manufacture a false positive.
|
||||
var wall = new Plane(new Vector3(1f, 0f, 0f), -5f);
|
||||
|
||||
Assert.False(PhysicsDiagnostics.TryPlaneZAt(wall, 0f, 0f, out _));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue