acdream/docs/research/2026-08-06-ap156-review-closure.md
Erik e6457cc849 fix(physics): close the AP-156 fix review — real containment oracle, type-level invariant, AP-158
Both review lenses PASSED; this is the cleanup, not a rescue. Evidence:
docs/research/2026-08-06-ap156-review-closure.md (the review itself is
committed alongside it as the received artifact).

R1 — the load-bearing containment test could not fail. Its truth and flood
values were two hand-copies of the same expression over the same part set,
so the shortfall was algebraically identically zero for any DAT input. The
oracle is now PHYSICS-POLYGON VERTICES — a different DAT field from the
bounding sphere the builder emits, so the two sides can genuinely disagree.
Sabotage-verified three ways after full cleans: dropping the bounds centre
in production reddens it (428 Setups, worst 35.869 m on 0x0200129A, matching
an independent out-of-repo sweep exactly); dropping only the scale on the
centre reddens it (326); and corrupting the TEST's own bounds oracle reddens
it (467) where under the shipped oracle that same corruption was invisible
by algebra. Renamed accordingly. A6's stale "cap control" comment corrected:
that loop is the test's own uncapped re-implementation and cannot observe a
cap regression — the cap is covered in Core.

R2 — the population was understated. 172 is AP-152's DISPATCH population;
AP-156's is 530 BSP-bearing Setups, of which 525 have a flood sphere move
and 428 fail vertex containment before the fix (412 at a 1 cm tolerance —
the review's figure; the gap is 16 Setups between 1.4 mm and 10 mm, real
geometry). 0 fail after, at any tolerance down to zero. Corrected in the
AP-156 row, the section-3 header, the C5c handoff and two test docstrings.
Dated review artifacts are left as written — "170 of 172" was correct for
what they measured, and rewriting evidence to match a later measurement
loses provenance.

A1 — BoundsCenter = default reopened at the type what the commit closed at
the seam. Dropping the default alone would NOT have closed the review's own
scenario (a copied Cylinder call site would write Vector3.Zero explicitly
and stay green), so ShadowShape's constructor is now private and BSP shapes
are built only through ShadowShape.Bsp(..., FlatCollisionSphere localBounds),
which takes radius and centre as ONE value and scales them together. There
is no expression a caller can write that carries one and drops the other.
22 construction sites converted; the same sabotage now reddens 5 Core tests
where the review's sabotage A reached 4, because both BSP producers share
one scaling path.

A2 — #333 is real and bigger than filed, and its retail question is
answered. I disassembled CObjCell::find_obj_collisions @0x0052b750 from the
PDB-paired binary myself (check_exe_pdb.py MATCH) rather than inheriting the
claim: its only early-out is sphere_path.insert_type == INITIAL_PLACEMENT_
INSERT, then it calls FindObjCollisions on every unparented non-self shadow
object UNCONDITIONALLY. Retail has NO distance pre-filter, so acdream's
"+ movement + 2f" reach filter is an invention with no register row — filed
as AP-158, carrying the disassembly, the F_EPSILON = 0.0002 m contrast, and
the measured blast radius (118 of 477 unique installed physics-BSP GfxObjs
exceed its ~2.5 m budget, 46 exceed 5 m). Active AP rows 109 -> 110.

Recorded prominently in three places a reader will hit: TALL PROPS MAY SHOW
NO VISIBLE CHANGE UNTIL #333 LANDS, and a null result at the connected gate
is EXPECTED, not evidence against AP-156.

LOW items. R3: the comment claiming the cited evidence justified the whole
cap line is corrected, but int.MaxValue on the sorting-sphere branch stays —
capping at 1 would take Spheres[0], and retail's one sphere is
CSetup::sorting_sphere, a different DAT field; capping keeps the wrong field
AND flips the substitution under-inclusive (#98/#168 direction). AP-157
already owns it. R4: acdream scales the flood sphere where retail's
find_transit_cells never reads gfxobj_scale — added as a second residual on
AP-156. R5: retail's slack constant carried into AP-158 and #333. A3: the
per-call delegate allocation is back to a cached field, still derived from
the single bounds resolver. A5: noted; b52967de's message cannot be amended.

Gates: all 44 bin/obj deleted before every verdict-deciding build, each test
run gated on a verified "Build succeeded" in the same invocation. Release
build 0 errors / 21 pre-existing warnings. Complete suite 11,208 passed /
4 skipped / 0 failed — reconciles exactly with the e2b2d04c baseline; one
test renamed, none added, removed or skipped. Nothing conflated with the
known load-sensitive flakes #302 / #308 / #321.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 16:44:48 +02:00

12 KiB

AP-156 fix review — closure record

Closes the six findings in 2026-08-06-ap156-fix-review.md (both lenses PASS; this is cleanup before merge, not a rescue). Worktree .claude/worktrees/resume-session-e0bd03e1-d5bf45, base e2b2d04c.

All 44 bin/obj deleted before every verdict-deciding build, and every test run gated on a grep for Build succeeded in the same invocation — the trap the review documented (a sabotage that trips CS0219 under TreatWarningsAsErrors fails the build and lets the previous sabotage's DLL report the wrong failure) was not hit.


R1 — the containment oracle now discriminates

Done. InstalledSetupBspPrimitiveDispatchTests no longer compares the emitted flood sphere against a hand-rebuilt copy of the same sphere. It transforms every physics-polygon vertex (GfxObj.PhysicsPolygonsGfxObj.VertexArray.Vertices) of every physics-BSP part into the entity frame and asserts some emitted flood sphere covers it. Vertices are a different DAT field from the bounding sphere, so the two sides of the comparison are no longer the same expression. Renamed to InstalledSetups_BspFloodSpheres_ContainTheirOwnPhysicsPolygons.

New population + defect controls: 973 physics-BSP parts, 530 BSP-bearing Setups, 376 off-centre parts, 91,689 physics vertices, 428 pre-fix failures, deepest part array 49. Every one measured first by a scratch DatReaderWriter console program outside the repo that references no acdream assembly, then reproduced exactly through the production builder.

Sabotage-verified, three ways, each after a full clean and a verified Build succeeded:

# Sabotage Result
S1 ShadowShape.Bsp drops the bounds centre (the AP-156 defect itself) RED — 428 Setups, worst 35.869 m on 0x0200129A (matches the independent sweep exactly)
S2 ShadowShape.Bsp applies the scale to the radius but not the centre RED — 326 Setups, worst 15.288 m
S3 the TEST's own Bounds() oracle corrupted (bs.Origin negated) RED — 467 Setups, worst 72.134 m
S1b same sabotage, run against Core rather than Content RED — 5 tests: the 4 the review's sabotage A found, plus ShadowRegistrationOverflowTests.FromLandblockBspParts_CarriesTheScaledRootSphereCentre, which sabotage A could not reach because it only touched BuildFloodSpheres. The factory refactor makes both BSP producers share one scaling path, so one sabotage now covers both.
restored GREEN — 127/127 Content, 4,268/1 skip Core

S3 is the one that matters for R1. Under the shipped oracle that same corruption was invisible by algebra: truth was (partOrigin + rotate(O, R))·s and flood was LocalPosition + rotate(BoundsCenter, R) = partOrigin·s + rotate(O·s, R), so negating O moved both sides identically and the shortfall stayed ≡ 0. The vertex oracle catches it. That is the tenth green-test-covering-nothing this campaign, and this is the direction of sabotage that proves it is closed — breaking the production code was never the hard half.

R2 — population corrected, and refined

Done, with a refinement the review will want. The review says 412, not "170 of 172". Both figures are right about different things, and neither is the number to quote to the user:

Question Answer
Setups carrying both a primitive and a physics-BSP part (AP-152's DISPATCH population) 172 of 5,935
BSP-bearing Setups (AP-156's population) 530
…whose flood sphere actually MOVES because of the fix 525
…failing vertex-level containment before the fix, 1 mm tolerance 428
…failing at a 1 cm tolerance 412 ← the review's figure
…failing after the fix, at any tolerance down to zero 0

The 412/428 gap is 16 Setups whose pre-fix shortfall lands between 1.4 mm and 10 mm — real geometry, three orders of magnitude above float noise at these radii, not a measurement artifact. The test keeps its existing 1 mm tolerance and therefore pins 428; the register records both and names the tolerance, because a bare "412" is unreproducible without it.

The number to tell the user is 525 — Setups whose behaviour changes — not 172, and not 412. Corrected in the AP-156 row, the section-3 header, the C5c handoff, and the two stale test docstrings. The dated review documents (2026-08-06-ap152-review-retail.md, and the fix review itself) are left as written: "170 of 172" was correct for what those reviews measured (root-sphere containment over the 172), and rewriting a dated evidence artifact to match a later measurement is how provenance gets lost.

The commit's own record cannot be corrected in place — b52967de is HEAD~1 and amending it would rewrite published history. This document plus the register row is that correction.

A1 — the hole is closed at the type

Done, and deliberately stronger than the review's recommendation. The review suggested dropping the = default and passing Vector3.Zero explicitly at six call sites. That does not close the review's own failure scenario: someone adding a BSP branch to LandblockPhysicsContentBuilder by copying the adjacent Cylinder call would then write Vector3.Zero explicitly and get the same 20 m mis-placement, still green.

Instead ShadowShape's constructor is now private, and the three factories are the only way to build one:

ShadowShape.Bsp(gfxObjId, localPosition, localRotation, scale, FlatCollisionSphere localBounds)
ShadowShape.Cylinder(gfxObjId, localPosition, localRotation, scale, radius, cylHeight)
ShadowShape.Sphere(gfxObjId, localPosition, localRotation, scale, radius)

Bsp takes the radius and the centre as one FlatCollisionSphere value and scales them together inside the factory. There is no longer an expression a caller can write that carries one and drops the other — which is the commit body's own claim ("the radius cannot be taken while the origin is dropped"), now true of the type and not only of the FromSetup seam. 22 construction sites converted; S1 and S2 above are exactly the two ways the factory could still be got wrong, and both are red.

Residual, recorded rather than fixed: default(ShadowShape) remains constructible because C# structs always have a parameterless constructor. It yields CollisionType = BSP, Radius = 0 — inert, and no production path produces one.

A2 — #333 updated, AP-158 filed, and the retail claim independently verified

Done. I disassembled CObjCell::find_obj_collisions @0x0052b750 myself from the PDB-paired binary (check_exe_pdb.pyMATCH, CodeView GUID 9e847e2f-777c-4bd9-886c-22256bb87f32, linker 2013-09-06T00:17:56Z) rather than inheriting the claim, because Binary Ninja drops flag tests and that has bitten this campaign repeatedly:

0x0052b759  cmp dword [ebx+0x174], 2   ; sphere_path.insert_type
0x0052b765  je  0x52b7a0               ; INITIAL_PLACEMENT_INSERT -> return OK_TS
0x0052b773  mov ecx,[edi+0xc8]         ; shadow_object_list.data
0x0052b77f  mov edx,[ecx+0x40]         ; physobj->parent -> skip if set
0x0052b786  cmp ecx,[ebx]              ; self -> skip
0x0052b78b  call 0x50f050              ; FindObjCollisions — UNCONDITIONAL
0x0052b79e  jb  0x52b773               ; loop, bound [edi+0xc4]

The single early-out is a transition-state test, not a distance test — the whole function is 0x54 bytes and there is no float comparison in it. Confirms the review: retail has no distance pre-filter at all, so acdream's sphereRadius + obj.Radius + movement.Length() + 2f is an invention. [ebx+0x174] resolves to sphere_path.insert_type against acclient.h's CTransition layout and the pseudo-C at 308916-308940 agrees.

Blast radius reproduced independently: 118 of the 477 unique installed physics-BSP GfxObjs exceed the ~2.5 m budget, 46 exceed 5 m.

Filed as AP-158 — the filter had no register row at all — carrying the disassembly, the F_EPSILON = 1.9999999e-4 m contrast (R5), the measured blast radius, and the "this can mask AP-156's entire visible benefit" consequence. #333 updated: its research question is struck through and answered, and what remains is a judgement call (keep the filter with a correct measurement point, or delete it and walk the list as retail does — the retail-faithful option, which should be costed first).

The consequence that had to be recorded prominently

Tall props may show no visible change at all until #333 lands, and a null result at the connected gate is EXPECTED, not evidence against AP-156. Recorded in three places a reader will actually hit: the AP-156 register row's WHAT REMAINS OPEN, the AP-158 row's risk column, and item 7 of the C5c handoff's §3.1 gate list — alongside the other half of the same warning, that "a prop stopped blocking" is only a bug if the object's own BSP geometry still reaches the cell it stopped blocking from.

LOW items

R3 — int.MaxValue on the sorting-sphere branch. Comment corrected; code deliberately unchanged, and I think the review's implied fix is wrong. The review is right that the cited evidence (0x0052b9d6 push 1) argues for a cap of 1, not int.MaxValue, and the comment block claimed to justify the whole line when it justified only the BSP half — that is fixed, and the comment now says plainly which half is a port and which is not. But capping at 1 would take Spheres[0], and retail's one sphere is CSetup::sorting_sphere, a different DAT field. Capping would not move toward retail; it would keep the wrong field and additionally make the substitution under-inclusive, which is the #98/#168 direction. int.MaxValue keeps it over-inclusive until AP-157 ports the real field, which is the honest state. Inert over installed data either way — max 5 Spheres on any Setup (0x020016F7).

R4 — acdream scales the flood sphere, retail does not. Recorded as a second residual on the AP-156 row. find_transit_cells @0x0052cae0 reads only CPhysicsPart::pos (+0x30) and never gfxobj_scale (+0x24), while CPhysicsPart::find_obj_collisions @0x0050d8d0 does thread gfxobj_scale.z into SPHEREPATH::cache_localspace_sphere. Direction: over-inclusive for scale > 1, under-inclusive for scale < 1. Radius scaling predates the commit; centre scaling did not, which is why it needed the row.

R5 — retail's slack is 0.0002 m, not 2 m. Carried into AP-158's anchor column and into #333, where whoever fixes the filter will hit it.

A3 — per-call delegate allocation. Fixed: LiveEntityCollisionBuilder now holds private readonly Func<uint,bool> _hasPhysicsBsp, initialised once in the single real constructor and still derived from _physicsBspBounds, so the single-resolver invariant is intact and Build allocates no closure.

A6 — the Content test's "cap control" does not test the cap. Comment corrected: the loop is the test's own uncapped re-implementation and cannot observe a cap regression; the cap is covered by BuildFloodSpheres_CapsCylSpheresAtTenButNeverTheBspParts, which reddens under both cap sabotages. What mostBspShapesOnOneSetup == 49 genuinely proves — that the containment claim reaches Setups past the retired clamp rather than stopping short of them — is now what the comment claims.

A5 — sabotage A reddens 4 Core tests, not 3. Noted; b52967de's message cannot be amended. Coverage is one better than advertised, which is the benign direction.


Gates

Gate Baseline e2b2d04c Observed
Release build, all 44 bin/obj deleted 0 errors, 21 warnings 0 errors, 21 warnings
Complete suite, -m:1, ACDREAM_PAK_PATH set 11,208 / 4 skipped / 0 failed 11,208 / 4 / 0 — reconciles exactly; one test RENAMED, none added or removed
Content 127 / 0 skips 127 / 0 skips
Active AP register rows 109 110 (AP-158 filed)

No new skips. Nothing was conflated with the known load-sensitive flakes #302 / #308 / #321.