acdream/docs/plans/2026-08-06-collision-fidelity-campaign.md
Erik d73125d3b0
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
fix(physics): S4/AD-65 — the away-from-plane response snaps to the surface, as retail does
Campaign S slice S4, the half that landed. Retail's CTransition::
adjust_offset @0x0050a370 branches on dot(offset, contactPlane.N) at
0x0050a4fa: moving INTO the plane subtracts the normal component
(0x0050a529), moving AWAY calls Plane::snap_to_plane @0x00509c50 —
which preserves X and Y and re-solves ONLY Z so the offset lies in the
plane (the d terms cancel algebraically), no-op under the
0.000199999995f |N.z| epsilon. acdream ran the orthogonal projection in
BOTH directions, shrinking downhill XY travel by cos^2(theta): 25% at
30 degrees, 50% at 45 — AD-65's recorded shortfall, now retired.

The combined Opus review independently re-derived the algebra, the
branch polarity, the epsilon's bit-identity (17b75139), and the
sabotage magnitude (the re-instated projection yields X = 0.75 =
cos^2 30 exactly), and verified the delta is 4 non-comment lines with
the into-plane arm, the crease arm, and both no-plane arms untouched.
Its blast-radius sweep found the away arm exercised but NOT
discriminated by any pre-existing test — every one asserts lower
bounds the snap over-satisfies — so the two new exact-value tests are
the only discriminating coverage, recorded in the test's class doc,
and the felt 33-100% downhill speed-up is the morning gate's one row.

AD-66 (the push-out's bare radius) is WITHHELD: byte-confirmed twice,
implemented, then pulled after the same clean-room binaries measured
contradictory absorbed-tick outcomes flipping with nothing but test
assert shape — issue #341 carries the observation matrix and the
apparatus plan; its two exact-value tests are [Skip]-ed; the retained
substitution's rationale is restored at the site per review F1, with
the review's remaining findings (F2/F3/F4/F5/F6) applied and F8 filed
as #342. AD-69 filed: the same block omits retail's get_block_offset
seam-frame correction, deferred to the AD-66 relanding for
attributability. #340 filed: a fifth load-sensitive flake.

Review verdict: PASS. AD-65 is provably unable to reach the #341
anomaly's code path (the absorb scenario takes the crease arm).
Clean-room suite: 11,239 passed / 6 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 02:45:03 +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) · S4 half-landed (AD-65 shipped; AD-66 withheld behind #341's measurement anomaly; AD-69 filed) · S5 closed (fix predated the campaign; zombie register row) · S6 unstarted · #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.