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

266 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.