From a069094770cc99a023c220a9dadc8d7412cc5a60 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 6 Aug 2026 22:33:42 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20open=20Campaign=20S=20=E2=80=94=20colli?= =?UTF-8?q?sion=20shape=20&=20response=20fidelity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plans the twelve remaining collision-domain items. Slice boundaries come from file coupling, not from how the symptoms group: AP-157, the AP-156 residual and AP-159/#335 all edit ShadowObjectRegistry (two of them the same function), so they are ONE slice against a pinned contract per feedback_dont_parallelize_coupled_plan_slices. Ordering is membership-before-query, because we just watched a membership fix (AP-156) be made invisible by a query-site defect (AP-158) directly downstream of it. Every slice opens with a measurement that can cancel it. In this domain over the last two weeks: AP-155's recorded direction was inverted, AP-156's risk column was wrong (and that is why #334 hid inside it), AP-22 described an unreachable branch, and #331's headline claim was refuted. One row in four was materially wrong about its own population, direction, or existence. Two items are deliberately reclassified. AP-83/AP-91 are NOT fixes — the x87 PerfectClip tails do not decompile, so there is no retail text to port; the honest deliverable is proving the branch unreachable. AD-65's "live lead for #269" framing is retracted: #269 closed 2026-07-31 and AD-65's sign is opposite to that symptom. #32 and #338 are pre-work, not slices — both are blocked on a measurement far cheaper than the fix, and #32 is the outstanding half of the user's original two-bug report, so it does not sit behind six slices. Live gates batched into four sessions; #330 needs none and is the parallel track for whenever a gate is blocking. Co-Authored-By: Claude Opus 5 --- .../2026-08-06-collision-fidelity-campaign.md | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 docs/plans/2026-08-06-collision-fidelity-campaign.md diff --git a/docs/plans/2026-08-06-collision-fidelity-campaign.md b/docs/plans/2026-08-06-collision-fidelity-campaign.md new file mode 100644 index 00000000..0e15d580 --- /dev/null +++ b/docs/plans/2026-08-06-collision-fidelity-campaign.md @@ -0,0 +1,247 @@ +# Campaign S — collision shape & response fidelity + +**Opened:** 2026-08-06, immediately after #333/#337 closed (`ea83b043`). +**Status:** PLANNED, not started. +**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 ~20–25 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 + +**Row:** AP-84. **Files:** `LiveEntityDefaultPoseResolver.cs`, +`LiveEntityCollisionBuilder.cs`, `ShadowShapeBuilder.cs` (`partPoseOverride`). + +Server entities with a MotionTableId register their BSP part shapes at the +default style's first-cycle LowFrame pose and never update them; retail uses +the live `CPhysicsPart` pose. A door's collision therefore stays where the shut +door was. + +**Why not folded into S1:** different files, different subsystem (animation, not +membership), and it needs a *visual* gate that S1 does not — you have to watch +a door open and then walk through the doorway. + +**Gate:** live. Open a door, walk through, close it, walk into it. + +--- + +### 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.