acdream/docs/research/2026-08-06-337-neftet-wedge-mechanism.md
Erik 3171f43002 docs: file #338 — player steps at 0.400 where Setup 0x02000001 authors 0.600/1.500
Spotted in the #337 [support] capture and deliberately left out of that
fix so the fix stayed falsifiable. Filed with what is NOT established
attached: whether retail reads the authored Setup field at all is the
first question, and the entry says to grep named-retail before touching
anything. The #337 lineage already burned two diagnoses reasoned from
source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:17:28 +02:00

14 KiB
Raw Blame History

#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
…218906219296 51.096 51.081 standing correctly (delta +0.015). Every horizontal move of 0.250.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):

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.pyMATCH, 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 (ResolveWithTransitionFindObjCollisionsInCellCollisionTraversal) 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.