acdream/docs/plans/2026-08-06-collision-fidelity-campaign.md
Erik c5443b3df9
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
test(physics): S6 — the camera provably reaches both PerfectClip TOI tails; contained, not dormant
AP-83/AP-91 claimed no current mover sets PerfectClip. The containment
proof found the opposite and the contract's honest-fallback fired: the
camera probe (the sole production setter) reaches BOTH ACE-derived
tails live — the viewer exemption is creature-only, the shadow-list
walk is unconditional, and static scenery with authored primitives is
a real non-creature population. Every reach is now recorded
(camera-live silently; any non-viewer mover loudly, one-shot), so a
future flag change cannot exercise unreviewed ACE-derived math
silently. Four tests drive the camera's exact call shape both ways;
the sabotage was intelligently adapted — there was no existing cut to
disable, so it flips the one axis the proof depends on (IsCreature)
and asserts reachability inverts. Both register rows rewritten
CONTAINED-not-dormant with severity narrowed to camera-feel (the probe
never commits a PhysicsBody).

Landing note: diagnostics-only diff (two guard calls + counters +
corrected stale comments), verified directly by the session lead
rather than a review cycle — the review budget went where behaviour
changed tonight.

Campaign S CLOSES with this landing: S1A/S1B/S2/S4/S5/S6 done, S3
cancelled, three user-passed gates, one honestly-open item — AD-66's
reland, twice self-refused by its own stability gate, blocked on the
#341 codegen-shape measurement instability whose ABA evidence and
first discriminating experiment are filed.

Clean-room suite: 11,257 passed / 6 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:21:40 +02:00

12 KiB
Raw Blame History

Campaign S — collision shape & response fidelity

Opened: 2026-08-06, immediately after #333/#337 closed (ea83b043). Status: IN FLIGHT — overnight session 2026-08-07 ledger: S1A (AP-157) closed by measurement, no code · S1B contract ready, not implemented · S2 contract ready, not implemented · S3 CANCELLED (planned on a misreading — see its section) · S1B LANDED (b3e43d22, #335 closed) and S2 LANDED (9671af02, AP-155 narrowed), Session-B dungeon gate USER-PASSED 2026-08-07 evening ("Feels good!") · S4 half-landed (AD-65 shipped and USER-PASSED at the 2026-08-07 morning gate; AD-66 withheld behind #341's measurement anomaly; AD-69 filed) · S5 closed (fix predated the campaign; zombie register row) · S6 LANDED (containment: the camera provably reaches BOTH ACE-derived TOI tails live — the rows' dormancy premise was false; guarded with counters + one-shot unverified-mover log, four tests, sabotage on the real exemption axis; AP-83/AP-91 rewritten CONTAINED-not-dormant, severity camera-feel only) · CAMPAIGN CLOSED 2026-08-07 night with ONE honestly-open item: AD-66's reland is blocked by the #341 codegen-shape measurement instability (twice self-refused by its own stability gate; the ABA evidence and the first discriminating experiment are in #341). The user's final slope look travels with that reland. Next: #344, #343, #341's boundary hunt, then vendors (M4) · #330 hoist landed, wiring withheld with a seven-point scope map · #32/#338 pre-work both closed. 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 ~2025 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 — CANCELLED 2026-08-07 (the slice was planned on a misreading)

Row: AP-84 — which stays exactly as it is.

This plan's original S3 text claimed "a door's collision stays where the shut door was." That scenario cannot occur, and AP-84's own row says why: an open door is ETHEREAL (#150) and bypasses collision entirely, so the only pose a door ever collides in IS the registered default pose. The row's risk column already carries the honest residual — "an entity whose server-driven motion state materially moves a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case)" — with the revisit trigger written. The register was right; this plan's summary of it was wrong, which is the same reading failure the campaign's own governing lesson warns about, committed by the campaign plan itself.

No fix, no gate, no door row in the morning sitting. AP-84 remains an active, deliberate approximation.


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.