docs: open Campaign S — collision shape & response fidelity
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 <noreply@anthropic.com>
This commit is contained in:
parent
3171f43002
commit
a069094770
1 changed files with 247 additions and 0 deletions
247
docs/plans/2026-08-06-collision-fidelity-campaign.md
Normal file
247
docs/plans/2026-08-06-collision-fidelity-campaign.md
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue