Transition.FindObjCollisionsInCell discarded a shadow candidate when |currPos - obj.Position| > sphereRadius + obj.Radius + movement.Length() + 2f obj.Position is the part ORIGIN; obj.Radius is the physics-BSP ROOT BOUNDING SPHERE's radius, measured about a centre AP-156 established is frequently metres from that origin (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 thrown away before BSPQuery ever ran: solid near the origin, permeable in a bounded shell beyond it. For the Neftet rock 0xC8766009 / gfx=0x01004751 the two points are 23.556 m apart, which is #337 — wedged on the plateau, jumps sinking into the mesh, corpses falling through. A live capture recorded 7,225 rejections on that one owner, every single one with wouldAcceptAtCenter=True. Deleted rather than re-centred. Retail has no distance pre-filter, disassembled from the PDB-paired v11.4186 binary (CodeView GUID 9e847e2f-777c-4bd9-886c-22256bb87f32) rather than read from Binary Ninja: CObjCell::find_obj_collisions @0x0052b750 walks shadow_object_list and calls CPhysicsObj::FindObjCollisions (0x0052b78b) UNCONDITIONALLY; its only early-out is insert_type == INITIAL_PLACEMENT_INSERT (0x0052b759). CPhysicsObj::FindObjCollisions @0x0050f050 contains no float compare at all. CPartArray::FindObjCollisions @0x00518180 is a bare do/while over parts, and CPhysicsPart::find_obj_collisions @0x0050d8d0 is two null checks plus a call. Retail's only spatial rejection is the BSP node bounding-sphere test inside the walk — correctly centred, which is exactly what the deleted filter was not. Re-centring it (carry BoundsCenter on ShadowEntry) would have preserved an invention 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. Retail's own cross-cell slack constant is F_EPSILON = 0.0002 m, not 2 m. The method's comment claimed the filter was "the analog of the part sorting-sphere early-outs inside retail's CPhysicsObj::FindObjCollisions — response-neutral, pure perf". Both halves were false and cost #333 and #337; it is replaced by the disassembly above. Gate: Issue333BroadphaseReachFilterTests drives the production path end-to-end (ResolveWithTransition -> FindObjCollisionsInCell -> CollisionTraversal) on a DAT-free fixture so it runs everywhere, as a discriminating pair. Sabotage-verified: restore the pre-check and OffCentreBspFloorStopsAFallingMover reaches z=37.800 — exactly the unobstructed fall, blockedAtLeastOnce=False — while CentredBspFloorStopsAFallingMover keeps passing. Without the control a fixture unable to fall would pass the first test for the wrong reason. Issue337's skipped TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn asserted the now-deleted predicate and could never have gone green; it is rewritten as installed-DAT evidence pinning BOTH halves of the diagnosis and is no longer skipped. Perf measured, not assumed (Release, synthetic all-BSP cell, per ResolveWithTransition): at 38 candidates — the live maximum — 10.61 us -> 16.68 us (1.57x); at a deliberately unreachable 200, 17.34 -> 39.48 us (2.28x); ~0.16 us per additional candidate tested. Over 19,701 live [reach-q] samples the in-cell count is p50 = 9, p99 = 32, max 38. The ACDREAM_PROBE_REACH rejectedReach column is kept and is now structurally 0, so a post-fix capture stays comparable with the pre-fix one; dropping it would make the two incomparable. AP-158 retired (110 active AP rows). #333 and #337 closed pending the user's live acceptance at Neftet. Solution suite 11,231 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5a1eeace73
commit
ea83b043df
7 changed files with 478 additions and 85 deletions
|
|
@ -24,10 +24,16 @@ 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.
|
||||
|
||||
## #337 — Neftet rock plateaus: wedged at the top, jumps sink into the mesh, corpses fall through — DIAGNOSED, fix not landed
|
||||
## #337 — Neftet rock plateaus: wedged at the top, jumps sink into the mesh, corpses fall through — FIXED, awaiting live acceptance
|
||||
|
||||
**Status:** OPEN — **mechanism proven offline 2026-08-06**, fix proposed and
|
||||
awaiting approval. It is **#333**: the per-object broadphase in
|
||||
**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
|
||||
|
|
@ -39,9 +45,13 @@ instruction on the PDB-paired binary). `0xC8766002`, the owner with 11,014
|
|||
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`
|
||||
(the acceptance gate is the skipped
|
||||
`TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn`).
|
||||
`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
|
||||
|
|
@ -156,8 +166,41 @@ Two additions close that:
|
|||
|
||||
## #333 — The shadow broadphase reach filter measures from the PART ORIGIN, so an off-centre BSP part can be in the right cell and still never be tested
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** high for the tall-prop population. It is the gate immediately
|
||||
**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.
|
||||
|
|
@ -199,7 +242,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
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,9 @@
|
|||
# #337 — the Neftet plateau wedge: mechanism, measured
|
||||
|
||||
**Date:** 2026-08-06
|
||||
**Status:** mechanism proven offline; fix proposed, NOT landed.
|
||||
**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
|
||||
|
|
@ -200,26 +202,71 @@ response-neutral, pure perf" is **wrong on both counts.**
|
|||
|
||||
---
|
||||
|
||||
## Proposed fix
|
||||
## The fix, as landed
|
||||
|
||||
**Preferred — remove the per-object distance pre-check for BSP entries.**
|
||||
Retail has none, and the BSP walk's own root node bounding-sphere test is the
|
||||
correctly-centred early-out that makes it unnecessary. Size: delete ~10 lines
|
||||
in `Transition.FindObjCollisionsInCell` plus the probe's `rejected-reach`
|
||||
branch; correct the false retail-analog comment in the same commit. No
|
||||
divergence-register row is created; if #333's row exists it is deleted.
|
||||
**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.
|
||||
|
||||
**Fallback if a perf gate demands a filter** — measure to the bounding-sphere
|
||||
centre, the AP-156 correction applied at the query site:
|
||||
`obj.Position + Vector3.Transform(BoundsCenter * Scale, obj.Rotation)`.
|
||||
`ShadowShape.BoundsCenter` already carries this value; `ShadowEntry` does not,
|
||||
so this variant also touches `ShadowEntry` and both registration paths
|
||||
(`Register` and `RegisterMultiPart`). Larger, and it keeps a non-retail
|
||||
construct that then needs a register row.
|
||||
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.
|
||||
|
||||
Acceptance gate: un-skip
|
||||
`Issue337NeftetRockGeometryInspectionTests.TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn`.
|
||||
Verified to fail today with the numbers above.
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1158,7 +1158,13 @@ public static class PhysicsDiagnostics
|
|||
// 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);
|
||||
// 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);
|
||||
|
|
@ -1199,7 +1205,9 @@ public static class PhysicsDiagnostics
|
|||
/// <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>rejected-reach</c>, <c>exempt-rule</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
|
||||
|
|
@ -1250,8 +1258,10 @@ public static class PhysicsDiagnostics
|
|||
/// <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">The filter's admission threshold, 2 m slack
|
||||
/// included.</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(
|
||||
|
|
@ -1318,10 +1328,13 @@ public static class PhysicsDiagnostics
|
|||
/// </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 were
|
||||
/// measured by the reach filter.</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).</param>
|
||||
/// 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>
|
||||
|
|
|
|||
|
|
@ -3719,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)
|
||||
|
|
@ -3774,7 +3806,13 @@ public sealed class Transition
|
|||
using var nearbyObjs = ShadowEntrySnapshot.Capture(objsInCell);
|
||||
|
||||
// #334 probe tallies — see LogReachQuery for what each one decides.
|
||||
int rExempt = 0, rReached = 0, rRejected = 0, rNoShape = 0,
|
||||
// `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)
|
||||
|
|
@ -3836,25 +3874,12 @@ public sealed class Transition
|
|||
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;
|
||||
// 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++;
|
||||
if (distToCurr > maxReach)
|
||||
{
|
||||
if (reachProbe)
|
||||
{
|
||||
rRejected++;
|
||||
ProbeReachCandidate(engine, oi, sp, cellId, in obj,
|
||||
"rejected-reach", sphereRadius, movement.Length(), currPos);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Commit C 2026-04-29 — retail exemption block at the top of
|
||||
// CPhysicsObj::FindObjCollisions
|
||||
|
|
@ -4338,10 +4363,12 @@ public sealed class Transition
|
|||
? MathF.Sqrt(dOrigin.X * dOrigin.X + dOrigin.Y * dOrigin.Y)
|
||||
: dOrigin.Length();
|
||||
|
||||
// World-space offset from the part ORIGIN (what the filter measures
|
||||
// against) to the BSP root sphere CENTRE (what it should measure
|
||||
// against). Zero for non-BSP shapes, whose Position already IS their
|
||||
// centre.
|
||||
// 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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -583,19 +583,19 @@ public sealed class Issue337NeftetRockGeometryInspectionTests
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// #337 REPRODUCER — currently FAILING, hence skipped.
|
||||
/// #337 EVIDENCE — the installed-DAT measurement that condemned the
|
||||
/// query-site broadphase, pinned so the diagnosis stays checkable.
|
||||
///
|
||||
/// <para>
|
||||
/// The per-object broadphase in
|
||||
/// <c>Transition.FindObjCollisionsInCell</c> (TransitionTypes.cs, the
|
||||
/// <c>maxReach</c> test) measures the mover's distance to the shadow
|
||||
/// entry's <c>Position</c> — the part ORIGIN — and compares it against
|
||||
/// <c>obj.Radius</c>, which is the physics-BSP ROOT BOUNDING SPHERE's
|
||||
/// radius. For this rock those two are 23.6 m apart, so a mover standing
|
||||
/// on its plateau is inside the bounding sphere by ~20 m of margin and
|
||||
/// still fails the test. It is the same defect AP-156 fixed in the flood
|
||||
/// 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).
|
||||
/// query site — filed as #333.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -606,14 +606,24 @@ public sealed class Issue337NeftetRockGeometryInspectionTests
|
|||
/// 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.
|
||||
/// test inside the walk, which is correctly centred. #333 therefore
|
||||
/// deleted the filter outright rather than re-centring it.
|
||||
/// </para>
|
||||
///
|
||||
/// Un-skip this as the acceptance gate for the fix.
|
||||
/// <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(Skip = "#337: fails until the query-site broadphase measures to the "
|
||||
+ "BSP bounding-sphere centre (or is removed, as retail has none).")]
|
||||
public void TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn()
|
||||
[Fact]
|
||||
public void TheOldBroadphaseMeasuredToTheOriginAndSoRejectedGeometryItStoodOn()
|
||||
{
|
||||
string? datDir = ConformanceDats.ResolveDatDir();
|
||||
if (datDir is null) return;
|
||||
|
|
@ -628,19 +638,27 @@ public sealed class Issue337NeftetRockGeometryInspectionTests
|
|||
const float SphereRadius = 0.48f;
|
||||
const float Movement = 0.308f; // the live step length
|
||||
|
||||
float ownerRadius = rock.Bsp.Nodes[rock.Bsp.RootIndex].BoundingSphere.Radius;
|
||||
var root = rock.Bsp.Nodes[rock.Bsp.RootIndex].BoundingSphere;
|
||||
float ownerRadius = root.Radius;
|
||||
|
||||
// Verbatim from the production predicate.
|
||||
// Verbatim from the deleted predicate.
|
||||
float distToOrigin = (currPos - rock.Position).Length();
|
||||
float maxReach = SphereRadius + ownerRadius + Movement + 2f;
|
||||
|
||||
Assert.True(
|
||||
distToOrigin <= maxReach,
|
||||
$"broadphase rejected a candidate the mover is standing on: "
|
||||
+ $"distToOrigin={distToOrigin:F3} > maxReach={maxReach:F3}. "
|
||||
+ $"Measured to the BSP bounding-sphere CENTRE it is "
|
||||
+ $"{(currPos - (rock.Position + Vector3.Transform(rock.Bsp.Nodes[rock.Bsp.RootIndex].BoundingSphere.Origin, rock.Rotation))).Length():F3} m, "
|
||||
+ $"comfortably inside the same radius.");
|
||||
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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue