fix(physics): S4/AD-65 — the away-from-plane response snaps to the surface, as retail does
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

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>
This commit is contained in:
Erik 2026-08-07 02:45:03 +02:00
parent 4721838916
commit d73125d3b0
8 changed files with 859 additions and 40 deletions

View file

@ -0,0 +1,287 @@
# `CTransition::adjust_offset` — full branch-tree pseudocode (Campaign S, S4)
> **OUTCOME NOTE (2026-08-07, appended by the session lead):** AD-66 was
> WITHHELD after this doc was written — the production code retains the
> `radius * N.z` substitution and register row AD-66 stays ACTIVE; see issue
> #341 for the measurement anomaly that blocked the landing. Statements below
> describing the bare-radius port as applied describe the IMPLEMENTED-THEN-
> PULLED state, not HEAD. The disassembly itself is unaffected and remains
> the oracle for the relanding.
Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt`, function at
`0x0050a370`, pseudo-C lines 272271-272393. Companion callee
`Plane::snap_to_plane` at `0x00509c50`, lines 271852-271869. Cross-referenced
against `references/ACE/Source/ACE.Server/Physics/Animation/Transition.cs:34-87`
(`Transition.AdjustOffset`) and
`references/ACE/Source/ACE.Server/Physics/Extensions/PlaneExtensions.cs:31-37`
(`SnapToPlane`), and against `references/ACE/Source/ACE.Server/Physics/Common/Vector.cs:8-16`
(`NormalizeCheckSmall`). Written under the S4 contract
`docs/research/2026-08-07-s4-adjustoffset-contract.md` for AD-65 + AD-66.
acdream port: `src/AcDream.Core/Physics/TransitionTypes.cs`, `Transition.AdjustOffset`
(private → `internal` as of this slice, to allow direct exact-value testing —
matches the existing `SlideSphereInternal` precedent in the same file).
## Signature
```
Vector3 adjust_offset(CTransition* this, Vector3 offset)
```
Called once per sub-step from `find_transitional_position` BEFORE the offset
is applied to `check_pos` (acdream: `TransitionalInsert` reads
`CollisionInfo` state left by the PREVIOUS step, then calls `AdjustOffset`
before mutating `CheckPos`).
## Full branch tree
```
adjust_offset(offset) -> Vector3:
result = offset
checkSlide = false
# ---- sliding-normal gate (0x0050a398) ----
slidingAngle = dot(result, collision_info.sliding_normal)
if collision_info.sliding_normal_valid:
if slidingAngle < 0:
checkSlide = true # ecx_1 = 1
else:
collision_info.sliding_normal_valid = false
# ---- branch on contact plane (0x0050a3de) ----
if collision_info.contact_plane_valid:
collisionAngle = dot(result, contact_plane.N) # arg3 @0x0050a408
slideOffset = cross(contact_plane.N, sliding_normal) # @0x0050a42a onward
if checkSlide: # ecx_1 != 0 (0x0050a42a)
# ---- crease-slide arm: verified identical to acdream, NOT changed ----
if normalize_check_small(slideOffset): # degenerate (len <= EPSILON)
result = Zero
else:
result = dot(slideOffset, result) * slideOffset
elif collisionAngle <= 0: # 0x0050a505, "ah & 0x41" != 0
# ---- INTO-plane arm (0x0050a529) — unchanged, already correct ----
result -= contact_plane.N * collisionAngle
else: # collisionAngle > 0
# ---- AWAY-from-plane arm (0x0050a50e) — AD-65 FIX ----
snap_to_plane(contact_plane, &result) # NOT the subtraction!
# ---- safety push-out (0x0050a571) — AD-66 FIX applies inside ----
if not contact_plane_is_water:
if contact_plane_cell_id != 0:
blockOffset = get_block_offset(sphere_path.check_pos.objcell_id,
contact_plane_cell_id)
globSphere = sphere_path.global_sphere[0]
dist = dot(globSphere.center - blockOffset, contact_plane.N)
+ contact_plane.d
# AD-66: retail compares/divides the BARE radius, not
# radius*N.z, at BOTH sites below.
if dist < globSphere.radius - F_EPSILON: # 0x0050a5cf
zDist = (globSphere.radius - dist) / contact_plane.N.z # 0x0050a5df
if globSphere.radius > |zDist|: # 0x0050a5e9
sphere_path.add_offset_to_check_pos((0, 0, zDist))
# ---- no contact plane (0x0050a61e) ----
elif checkSlide: # ecx_1 != 0
slidingAngle2 = dot(result, sliding_normal)
result -= sliding_normal * slidingAngle2
# else: result unchanged (no contact plane, no slide)
return result
```
## `Plane::snap_to_plane` (0x00509c50) — the AD-65 target
```
snap_to_plane(plane, offset* /* in-out */):
if |plane.N.z| <= F_EPSILON (0.000199999995f):
return # no-op — X, Y, Z ALL unchanged
# offset.z temporarily zeroed, then re-solved so dot(N, offset) + d == 0:
offset.z = -(offset.x * N.x + offset.y * N.y) / N.z
# X and Y are NEVER written — only Z changes.
```
### Deriving the formula (the `d` terms cancel)
Retail's literal decompiled expression (pc:271864-271867) is:
```
offset.z = 0 # temporary
A = offset.x*N.x + offset.y*N.y # (z already 0, so this
# is the full dot(N,offset))
offset.z = ( -(A + d) * (1/N.z) ) - ( (1/N.z) * -d )
```
Expand:
```
offset.z = -(A+d)/N.z + d/N.z
= [ -(A+d) + d ] / N.z
= [ -A - d + d ] / N.z
= -A / N.z
= -(offset.x*N.x + offset.y*N.y) / N.z
```
The `d` terms cancel exactly, leaving the plain XY-dot-over-N.z formula
above. This matches ACE's `PlaneExtensions.SnapToPlane` byte-for-byte
(confirmed by reading `references/ACE/.../PlaneExtensions.cs:31-37`, which
carries the unsimplified `-(...+d)*(1/N.z) - (1/N.z)*-p.D` form — ACE did
not even bother to algebraically simplify it, which is good corroborating
evidence this is really what retail computes rather than an ACE
reinterpretation).
## The Binary Ninja flag-idiom ambiguity (resolve, don't guess)
Four x87 float comparisons in this function's neighborhood get turned into
the same packed-flags shape by Binary Ninja:
```
eax = (ST0<src)<<8 | (unordered)<<10 | (ST0==src)<<14 | ...
bool p = /* test ah, <mask> */ # sometimes resolved, sometimes not
```
**One of the four (the outer collisionAngle<=0 vs >0 branch) is resolved
cleanly** — Binary Ninja rendered it directly as
`if ((eax_4_ah & 0x41) != 0)` with no `/* unimplemented */` placeholder, and
mask `0x41` (bits C0|C3) is the standard x87 "ST0 <= src" idiom. The
divergence register's AD-65 row independently disassembled the raw bytes at
this exact site (`0050a4fa fcomp [0x795344]` / `0050a502 test ah,0x41` /
`0050a505 jne 0x50a515`, 0x795344 = the float constant 0.0f) and confirms:
`jne` on `ah & 0x41` takes the SUBTRACT branch when `collisionAngle <= 0` and
falls through to `call 0x509c50` (snap_to_plane) when `collisionAngle > 0`.
**No ambiguity here** — this is the branch AD-65's headline fix depends on,
and it is independently confirmed by both the contract and this doc's own
reading of the pseudo-C.
**The other three all use mask `0x5` (bits C0|C2) and ALL THREE are left as
`/* bool p = unimplemented {test ah, 0x5} */`** by Binary Ninja — it could
not resolve them into readable expressions:
1. `snap_to_plane`'s own `|N.z| <= F_EPSILON` guard (pc:271859-271862).
2. AD-66's safety-push trigger comparison, `dist` vs `radius - F_EPSILON`
(pc:272358-272361).
3. `normalize_check_small`'s degenerate-length check (pc:91421-91424,
unrelated to AD-65/AD-66 but in the same neighborhood and same idiom —
documented here since the sliding-normal arm cites it).
Attempting to read the polarity directly off the packed-flag pseudocode's
`(x87_rA < x87_rB)` sub-expression is **unsound** for these three: the
subtraction operand order recorded by the decompiler
(`(x87_r6 - x87_r7)` in snap_to_plane vs `(x87_r5 - temp0)` in
normalize_check_small) is not by itself sufficient to recover which operand
was `ST(0)` in the original `fcomp`, and a naive literal reading of the two
sites against each other produces **contradictory** polarities (the two
reads cannot both be "ST0 < src means true" and remain self-consistent with
their own surrounding code's evident purpose). **This doc does not attempt
to re-derive them from the disassembly-free pseudo-C.** Instead, each is
resolved by triangulating independent evidence:
| Site | Resolved polarity used | Evidence |
|---|---|---|
| `snap_to_plane` epsilon guard | `\|N.z\| <= F_EPSILON` → no-op; else → resolve Z | (a) **the S4 contract pins this explicitly** ("if `\|N.z\| <= 0.000199999995f` do NOTHING; else..."); (b) domain reasoning — the resolve divides by `N.z`, so the guard must protect against near-zero `N.z` (a near-vertical wall), not near-full `N.z` (a floor); (c) ACE's `PlaneExtensions.SnapToPlane` (`if (Math.Abs(p.Normal.Z) <= PhysicsGlobals.EPSILON) return;`) — independently ported, agrees exactly. |
| AD-66 trigger (`dist` vs `radius - F_EPSILON`) | compute/push branch fires when `dist < radius - F_EPSILON` | (a) preserves the EXISTING acdream control-flow direction (push fires when penetrating) — the contract asks only to substitute the RADIUS term, not invert the comparison; (b) domain reasoning — a push-up-when-penetrating safety net must fire on LOW `dist`; (c) ACE's `Transition.cs:77` (`if (dist >= globSphere.Radius - PhysicsGlobals.EPSILON) return offset;`) — the negation of exactly this condition, independently ported, agrees. |
| `normalize_check_small` degenerate check | `length <= F_EPSILON` → return 1 (small); else → normalize, return 0 | ACE's `Vector.NormalizeCheckSmall` (`var dist = v.Length(); if (dist < PhysicsGlobals.EPSILON) return true; v *= 1/dist; return false;`) — independently ported, agrees, and also confirms the length is the FULL vector length, not (as the raw decompiled `this->x` alone might suggest — see next section) just the X component. |
All three triangulations AGREE with each other's implied "the guarded
branch is the geometrically meaningful one" reading and agree with the two
independently-sourced ACE ports. None of this changes what ships: (1) and
(2) are exactly AD-65's and AD-66's fixes; (3) confirms NO change is needed
to the sliding-normal arm.
## The sliding-normal arm (0x0050a42a) — verified against acdream, NOT changed
Per the contract, this arm must be verified but is out of scope to modify
unless it diverges. It does not.
**Cross product.** Retail computes (pc:272326-272328):
```
crossVec.x = sliding_normal.z * N.y - sliding_normal.y * N.z
crossVec.y = sliding_normal.x * N.z - sliding_normal.z * N.x
crossVec.z = sliding_normal.y * N.x - sliding_normal.x * N.y
```
This is algebraically `cross(N, sliding_normal)` (standard
`cross(a,b) = (a.y*b.z - a.z*b.y, a.z*b.x - a.x*b.z, a.x*b.y - a.y*b.x)` with
`a=N, b=sliding_normal`). acdream's `Vector3.Cross(ci.ContactPlane.Normal,
ci.SlidingNormal)` computes the same thing. **Match.**
**Projection.** Retail (pc:272332-272339): `dot = dot(crossVec, result)`,
then `result = crossVec * dot`. acdream: `result = Vector3.Dot(slideOffset,
result) * slideOffset`. Scalar-times-vector is commutative here — same
value. **Match.**
**Degenerate case (`normalize_check_small` returns nonzero).** Retail
(pc:272341-272345) is genuinely ambiguous in the raw pseudo-C: it shows
`x = __return_1` (the un-normalized cross-product X component, NOT zero)
followed by `memset(&s, 0, 0x14)` which zeroes `s`, `z`, and 16 more
trailing bytes of stack — it does NOT show `x` (the X component) being
zeroed by the memset span shown. Read completely literally, this would mean
X keeps a tiny nonzero leftover value while Y and Z become exactly zero,
which does not match "degenerate → whole vector is zero".
This is judged to be a **Binary Ninja decompilation artifact**, not real
retail behavior, for three independent reasons: (1) the decompiled
`normalize_check_small` itself only reads `this->x` (pc:91417) as the thing
compared against `F_EPSILON` — never `this->y` or `this->z` in a
sum-of-squares — even though lines 91415-91416 (`this->z;` / `this->y;`
bare, unassigned reads) show the decompiler DID emit memory-read
instructions for y and z that it then failed to fold into the length
expression; (2) ACE's independently-ported `NormalizeCheckSmall`
unambiguously computes the full `v.Length()`; (3) acdream's own existing
port (`slideOffset.Length() < PhysicsGlobals.EPSILON → result = Vector3.Zero`)
already implements the sensible full-zero, full-length reading and there is
no report of it producing wrong behavior. This matches the project's
documented BN-artifact class (`feedback_bn_decomp_field_names.md`): a lost
FPU sum-of-squares reduced to one leftover operand load. **No change made.**
acdream's existing `slideLen < EPSILON → result = Vector3.Zero` stands.
## The no-contact-plane branches — verified, NOT changed
- No contact plane, no slide (0x0050a3de implicit else): `result` is
returned unmodified. acdream: `branch = "no-cp"`, no mutation. **Match.**
- No contact plane, sliding active (0x0050a61e): `result -= sliding_normal *
dot(result, sliding_normal)`. acdream: `branch = "no-cp-slide"`,
`result -= ci.SlidingNormal * slidingAngle`. **Match.**
## Observed but OUT OF SCOPE: the missing block-offset correction
Retail's safety push-out (pc:272354, `LandDefs::get_block_offset`) and ACE's
port (`Transition.cs:75`, `LandDefs.GetBlockOffset(SpherePath.CheckPos.ObjCellID,
CollisionInfo.ContactPlaneCellID)`) both re-express the sphere center into
the CONTACT PLANE's cell-relative frame before computing `dist`, to handle
the case where the contact plane was recorded in a different (landblock-
adjacent) cell than `check_pos`'s current cell. acdream's port
(`TransitionTypes.cs:5602-5607`, both before and after this slice's fix)
uses `sp.GlobalSphere[0].Origin` directly with no block-offset correction.
This is a THIRD potential divergence in the same safety block, but it is
**not AD-65 or AD-66** and is not one of the "two fixes" the S4 contract
scopes — flagged here per the contract's "Anything outside AdjustOffset and
its tests" OUT-of-scope clause read narrowly (in scope location, out of
scope fix). Left unchanged; worth a future register row if the session lead
wants it filed.
## Constants
- `F_EPSILON` = `0.000199999995f` (retail's exact float32 bit pattern for
"0.0002"). acdream's `PhysicsGlobals.EPSILON = 0.0002f` compiles to the
identical bit pattern (both are "nearest float32 to decimal 0.0002") —
no new constant needed, reused as-is.
## Deliverable summary (what changes, what doesn't)
| Arm | Retail | acdream before S4 | acdream after S4 |
|---|---|---|---|
| `collisionAngle <= 0` (into plane) | subtract full N component | same | **unchanged** |
| `collisionAngle > 0` (away from plane) | `snap_to_plane`: XY preserved, Z re-solved, epsilon no-op | subtract full N component (AD-65 bug) | **fixed: snap semantics** |
| Safety-push trigger | bare `radius - F_EPSILON` | `radius*N.z - F_EPSILON` (AD-66 bug) | **fixed: bare radius** |
| Safety-push zDist numerator | `(radius - dist) / N.z` | `(radius*N.z - dist) / N.z` (AD-66 bug) | **fixed: bare radius** |
| Safety-push sanity bound (`radius > \|zDist\|`) | bare `radius` | bare `radius` (already correct) | unchanged |
| Sliding-normal crease arm | cross + normalize + project | same | unchanged (verified) |
| No-contact-plane arms | subtract or no-op | same | unchanged (verified) |
| Block-offset correction in safety push | present (`get_block_offset`) | absent | **unchanged — out of S4 scope, flagged above** |