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
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:
parent
4721838916
commit
d73125d3b0
8 changed files with 859 additions and 40 deletions
|
|
@ -158,6 +158,68 @@ resolution.
|
|||
|
||||
---
|
||||
|
||||
## #341 — AD-66's landing is blocked by an unexplained measurement flip on the #331 absorb scenario
|
||||
|
||||
**Status:** OPEN — HIGH priority for the next physics session; the fix itself
|
||||
is byte-proven, the BLOCKER is that the measurement chain contradicted itself.
|
||||
**Filed:** 2026-08-07 (overnight), at the S4 landing split.
|
||||
|
||||
Retail's `adjust_offset` safety push-out uses the BARE sphere radius in both
|
||||
its trigger and its `zDist` numerator (byte-anchored twice in AD-66's register
|
||||
row). Landing that in `Transition.AdjustOffset` made exactly one suite test
|
||||
fail — `RuntimeRemoteUphillProgressTests.AnExactlyUpSlopeOffsetIsAbsorbedByThePersistedSlidingNormal`,
|
||||
the #331 absorb characterization pin — and the attempt to recalibrate it
|
||||
produced OBSERVATIONS THAT FLIP WITH THE SHAPE OF THE TEST'S POST-TICK
|
||||
ASSERTS, which is not physically possible for honestly-measured stored state:
|
||||
|
||||
| test-code variant (identical code before/during the 5 absorbed ticks) | observed Z after ticks | runs |
|
||||
|---|---|---|
|
||||
| original exact-latch `Assert.Equal(latched, body.Position)` | latched + 0.0798 (LIFT) | implementer's suite run + 6 consecutive runs + 1 more after restore |
|
||||
| recalibrated: compute `restingLift` from `body.ContactPlane` post-tick, then component asserts | latched exactly (NO lift) | 1 run (version A) + 4+ runs (version C) incl. a full bin/obj clean-room |
|
||||
|
||||
Both shapes were run against binaries proven to contain the AD-66 fix (the S4
|
||||
conformance exact-value tests passed in the same clean-room). 0.0798 m =
|
||||
`0.48 * (1/cos31° − 1)`, the delta between the two resting heights, so BOTH
|
||||
outcomes are physically coherent stories — the problem is that the same
|
||||
binaries told both.
|
||||
|
||||
**Hypotheses deliberately NOT chased at 04:00:** a property-read side effect
|
||||
(reading `body.ContactPlane` between tick and assert — should be impossible);
|
||||
xUnit execution-order/parallelism interacting with harness or engine state;
|
||||
JIT/tiering differences by method shape; yet another artifact-staleness vector
|
||||
not covered by bin/obj deletion. **Next session: instrument the scenario
|
||||
itself** (per-tick position prints inside the test, a matrix of assert-shape ×
|
||||
clean-room state), per `feedback_apparatus_for_physics_bugs` — three
|
||||
contradictory reads means apparatus, not a fourth guess.
|
||||
|
||||
Until resolved: the AD-66 production code is REVERTED to the radius*N.z
|
||||
substitution (comment block at the site names this issue), its two exact-value
|
||||
conformance tests are `[Skip]`-ed with pointers here, and the register row
|
||||
stays ACTIVE. **The byte evidence was never the open question — do not
|
||||
"resolve" this by re-deriving it a third time.**
|
||||
|
||||
---
|
||||
|
||||
## #342 — `Issue265SteepSlopeCaptureBisectTests.cs:920` is a tautology: `Assert.Equal(x.Z > 0.01f, x.Z > 0.01f)`
|
||||
|
||||
**Status:** OPEN. LOW — a dead assertion that can never fail, in the
|
||||
steep-slope family Campaign S leans on. Found by the S4 review (F8),
|
||||
out of that slice's scope. Fix = recover the intended comparison from the
|
||||
test's context, not just delete.
|
||||
**Filed:** 2026-08-07.
|
||||
|
||||
---
|
||||
|
||||
## #340 — `StreamingWorkBudgetTests.DestinationAndEmptyUnloadPriorityNeverBypassPublicationBudget` is a FIFTH load-sensitive flake
|
||||
|
||||
**Status:** OPEN. LOW.
|
||||
**Filed:** 2026-08-07 (overnight), first observed in a clean-room full-suite
|
||||
run; passes standalone immediately after. Distinct from #302, #308, #321 and
|
||||
#336 per the never-conflate rule. Same class: load-sensitive, deterministic in
|
||||
isolation.
|
||||
|
||||
---
|
||||
|
||||
## #339 — Stuck in portal space: the destination reveal generation never becomes ready
|
||||
|
||||
**Status:** OPEN — observed live 2026-08-07, evidence captured. **Not chased**;
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,13 @@
|
|||
# Campaign S — collision shape & response fidelity
|
||||
|
||||
**Opened:** 2026-08-06, immediately after #333/#337 closed (`ea83b043`).
|
||||
**Status:** PLANNED, not started.
|
||||
**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.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
# Morning gate — 2026-08-07 (one sitting)
|
||||
|
||||
**Status: DRAFT — the overnight session updates this as work lands. Do not
|
||||
run it until this banner says READY.**
|
||||
**Status: READY — pending only the AD-65 review verdict and final commit,
|
||||
which the session lead completes before launch. Launch happens when you say
|
||||
go, not before.**
|
||||
|
||||
Everything below folds the deferred visual gates into one sitting, ordered so
|
||||
travel between sites doubles as #339 reproduction attempts. Launch is
|
||||
|
|
@ -37,13 +38,38 @@ now part of every capture.
|
|||
then-active AD-56 row. Both corruptions fixed; the class is now a memory
|
||||
rule. No feel gate owed for S5.
|
||||
|
||||
## 2. The sitting
|
||||
## 2. The sitting — ONE row
|
||||
|
||||
| # | What | Where / how | Pass looks like |
|
||||
|---|---|---|---|
|
||||
| G1 | *(placeholder — filled in by the overnight landings: #330 is
|
||||
automated-gate-only and needs no eyes; S1/S2/S3/S4/S5 rows appear here
|
||||
exactly as far as the night got)* | | |
|
||||
| G1 | **S4 / AD-65 — downhill slope feel.** The away-from-plane response now
|
||||
snaps to the surface (XY preserved) instead of projecting (XY shrunk by
|
||||
cos²θ — 25% at 30°, 50% at 45°). | Run DOWN a long slope (the Rithwic
|
||||
descent works), then across it diagonally; jump down-slope and land
|
||||
running. | Downhill ground speed feels like retail — no "wading" slowdown
|
||||
on descents; no new stutter or floatiness; landings keep momentum
|
||||
downhill. |
|
||||
|
||||
That is the whole sitting: **one slope run, ~3 minutes.** Everything else
|
||||
either closed with no gate owed or was deliberately withheld (below).
|
||||
|
||||
## 2.5 Withheld / deferred overnight — nothing to test, decisions recorded
|
||||
|
||||
- **AD-66 (the push-out's bare radius) — WITHHELD, issue #341.** The port is
|
||||
byte-proven twice over, but the landing produced a measurement that
|
||||
contradicted itself (same binaries, opposite outcomes flipping with test
|
||||
assert shape). Parked behind an instrumentation plan rather than guessed
|
||||
at. AD-69 filed alongside: the same block misses retail's seam-frame
|
||||
correction.
|
||||
- **#330 headless collision — wiring withheld, hoist landed.** Dual review
|
||||
converged on a real dependency the contract missed: headless has no
|
||||
remote-motion tick, so spawn-registered shadows would freeze into phantom
|
||||
obstacles. The issue now carries the full seven-point scope map.
|
||||
- **S1B (indoor box-admit) and S2 (static sphere emission)** — contracts
|
||||
written and committed, not implemented; next session picks them up
|
||||
directly.
|
||||
- **S3 — cancelled**: planned on a misreading; open doors are ethereal, so
|
||||
AP-84's approximation is behaviourally equivalent. The register was right.
|
||||
|
||||
## 3. Free riders during travel
|
||||
|
||||
|
|
|
|||
|
|
@ -81,3 +81,36 @@ outside `AdjustOffset` and its tests.
|
|||
|
||||
Only `C:\Users\erikn\source\repos\acdream`, absolute paths, no subagents.
|
||||
Contradictions between this contract and the source: STOP and report.
|
||||
|
||||
---
|
||||
|
||||
# OUTCOME (appended 2026-08-07, end of the overnight session)
|
||||
|
||||
**AD-65: LANDED.** The away-from-plane arm performs retail's snap_to_plane
|
||||
verbatim; conformance exact-value tests sabotage-verified (the re-instated
|
||||
projection reproduces the recorded cos²30° = 0.75 shrinkage exactly); the
|
||||
named uphill no-flap STOP scenario passed; register row retired.
|
||||
|
||||
**AD-66: WITHHELD — issue #341.** The bare-radius port was implemented and
|
||||
then pulled, not because the bytes were doubted (they are now DOUBLE
|
||||
byte-confirmed) but because its interaction with the #331 absorb
|
||||
characterization pin produced a measurement that contradicted itself: the
|
||||
same clean-room binaries measured both a one-time resting lift and an exact
|
||||
latch on the absorbed-tick scenario, flipping with nothing but the shape of
|
||||
the test's post-tick asserts. Three contradictory reads is the
|
||||
apparatus-not-a-fourth-guess threshold; #341 carries the observation matrix
|
||||
and the instrumentation plan. The production site carries a comment block;
|
||||
the two exact-value tests are [Skip]-ed pointing at #341.
|
||||
|
||||
**Third finding from the pseudocode pass: AD-69 filed** — the push-out's
|
||||
`dist` omits retail's `get_block_offset` cell-relative correction, wrong
|
||||
exactly at landblock seams. Deliberately not folded into tonight's landing;
|
||||
fix alongside the AD-66 relanding so the anomaly investigation stays
|
||||
attributable.
|
||||
|
||||
**Process note for the record:** tonight's session hit the stale-artifact
|
||||
plague again mid-recalibration and burned roughly an hour on contradictory
|
||||
evidence before applying its own clean-room rule; and the recalibration was
|
||||
attempted twice on empirics before the withhold decision. The
|
||||
morning-after reading of #341 should start from the observation matrix, not
|
||||
from this contract.
|
||||
|
|
|
|||
287
docs/research/2026-08-07-s4-pseudocode.md
Normal file
287
docs/research/2026-08-07-s4-pseudocode.md
Normal 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** |
|
||||
|
|
@ -5491,8 +5491,14 @@ public sealed class Transition
|
|||
///
|
||||
/// Ported from pseudocode section 6 (AdjustOffset).
|
||||
/// ACE: Transition.AdjustOffset(Vector3 offset).
|
||||
///
|
||||
/// internal (not private) as of Campaign S slice S4 (2026-08-07), matching
|
||||
/// the existing <see cref="SlideSphereInternal"/> precedent, so conformance
|
||||
/// tests can call it with exact-value CollisionInfo/SpherePath fixtures
|
||||
/// instead of driving it indirectly through the whole step-subdivision
|
||||
/// loop. See docs/research/2026-08-07-s4-pseudocode.md.
|
||||
/// </summary>
|
||||
private Vector3 AdjustOffset(Vector3 offset)
|
||||
internal Vector3 AdjustOffset(Vector3 offset)
|
||||
{
|
||||
var sp = SpherePath;
|
||||
var ci = CollisionInfo;
|
||||
|
|
@ -5561,42 +5567,54 @@ public sealed class Transition
|
|||
else if (collisionAngle <= 0f)
|
||||
{
|
||||
// Moving into the contact plane: remove component into the plane.
|
||||
// retail CTransition::adjust_offset 0x0050a505/0x0050a529
|
||||
// (collisionAngle <= 0 -> subtract full normal component).
|
||||
result -= ci.ContactPlane.Normal * collisionAngle;
|
||||
branch = "into-plane";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Moving away from contact plane: snap to plane surface.
|
||||
// SnapToPlane: remove any component that would violate the plane.
|
||||
result -= ci.ContactPlane.Normal * collisionAngle;
|
||||
// AD-65 fix (2026-08-07): moving away from the contact plane —
|
||||
// retail calls Plane::snap_to_plane @0x00509c50 here, which is NOT
|
||||
// the orthogonal-projection subtraction used in the into-plane arm
|
||||
// above. snap_to_plane preserves X and Y and re-solves ONLY Z so
|
||||
// the offset lies exactly in the plane:
|
||||
// |N.z| <= EPSILON -> no-op (near-vertical plane; can't divide)
|
||||
// else -> result.z = -(result.x*N.x + result.y*N.y) / N.z
|
||||
// (retail's literal formula carries +d/N.z -d/N.z terms that
|
||||
// cancel algebraically; derivation in
|
||||
// docs/research/2026-08-07-s4-pseudocode.md.) The pre-fix
|
||||
// subtraction shrank XY by cos^2(theta) on slopes — register
|
||||
// row AD-65 in docs/architecture/retail-divergence-register.md.
|
||||
Vector3 n = ci.ContactPlane.Normal;
|
||||
if (MathF.Abs(n.Z) > PhysicsGlobals.EPSILON)
|
||||
result.Z = -(result.X * n.X + result.Y * n.Y) / n.Z;
|
||||
branch = "away-plane";
|
||||
}
|
||||
|
||||
// Safety check: ensure the sphere stays above the contact plane.
|
||||
// Ported from pseudocode section 6 (AdjustOffset safety block), with
|
||||
// a correction for the Z-axis sphere-origin convention.
|
||||
// AD-66 — deviation RETAINED, landing WITHHELD (2026-08-07, S4;
|
||||
// issue #341). Retail's CTransition::adjust_offset 0x0050a370 uses
|
||||
// the BARE global_sphere->radius for both the trigger comparison
|
||||
// (0050a5c4 fld [ecx+0xc], then subtract F_EPSILON) and the zDist
|
||||
// numerator (0050a5dc fsubr [ecx+0xc]) — byte-confirmed twice. The
|
||||
// code below deliberately does NOT port that yet: the S4 landing was
|
||||
// pulled after the same clean-room binaries measured contradictory
|
||||
// outcomes on the #331 absorb scenario (see #341). The register row
|
||||
// AD-66 stays ACTIVE.
|
||||
//
|
||||
// The LocalSphere origin is at (0, 0, radius): the sphere center
|
||||
// sits `radius` above the character root along WORLD Z, NOT along
|
||||
// the plane normal. When a character stands with feet on a tilted
|
||||
// plane, the sphere center's perpendicular distance to that plane
|
||||
// is `radius * Normal.Z`, not `radius`. The naïve `dist < radius`
|
||||
// threshold therefore fires spuriously on every slope — the sphere
|
||||
// is geometrically offset, not actually penetrating — and the
|
||||
// subsequent push-up lifts the feet by `r * (sec θ - 1)`: 7 cm at
|
||||
// 30°, 20 cm at 45°, 48 cm at 60°. The steep-slope lift is large
|
||||
// enough to break the "feet on plane → set contact plane" check in
|
||||
// ValidateWalkable; ValidateTransition then clears OnWalkable,
|
||||
// gravity applies next frame, and the character visibly flickers
|
||||
// into the Falling animation while running up hills. Observed
|
||||
// empirically on steep slopes.
|
||||
//
|
||||
// Correct threshold: `radius * Normal.Z` (the natural resting
|
||||
// distance of a Z-aligned sphere on the given plane). The push
|
||||
// fires only when the sphere is ACTUALLY penetrating below natural
|
||||
// resting. ACE and the published pseudocode have the original
|
||||
// threshold, but the bug would also affect ACE's simulation — it's
|
||||
// just invisible server-side where no one renders characters.
|
||||
// The RATIONALE for the retained substitution, preserved because the
|
||||
// deviation is live: the LocalSphere origin is at (0, 0, radius) —
|
||||
// the sphere centre sits `radius` above the root along WORLD Z, not
|
||||
// along the plane normal, so a sphere resting on a tilted plane is
|
||||
// `radius * Normal.Z` from it. The bare `dist < radius` threshold
|
||||
// fires spuriously on every slope and the push-up lifts the feet by
|
||||
// r * (sec θ - 1): 7 cm at 30°, 20 cm at 45°, 48 cm at 60° — enough
|
||||
// to break ValidateWalkable's feet-on-plane check, clear OnWalkable,
|
||||
// and flicker the Falling animation while running uphill (observed
|
||||
// empirically). If the premise is right, RETAIL ITSELF has that
|
||||
// lift; resolving which story is true is exactly what #341's
|
||||
// apparatus session is for.
|
||||
if (ci.ContactPlaneCellId != 0 && !ci.ContactPlaneIsWater)
|
||||
{
|
||||
Vector3 globCenter = sp.GlobalSphere[0].Origin;
|
||||
|
|
@ -5606,13 +5624,25 @@ public sealed class Transition
|
|||
float dist = Vector3.Dot(globCenter, ci.ContactPlane.Normal)
|
||||
+ ci.ContactPlane.D;
|
||||
|
||||
// Natural resting distance of the Z-aligned sphere on this plane.
|
||||
// AD-66 WITHHELD 2026-08-07 (S4): retail's BARE radius here is
|
||||
// byte-confirmed (0050a5c4 / 0050a5dc, see the register row), but
|
||||
// landing it collided with the #331 absorb characterization pin
|
||||
// through an interaction the overnight session could not
|
||||
// stabilize: the same clean-room binaries measured BOTH a
|
||||
// one-time resting lift and an exact latch on the absorbed-tick
|
||||
// scenario, flipping with nothing but the shape of the test's
|
||||
// post-tick asserts. Until that measurement anomaly is explained
|
||||
// with real apparatus, the pre-S4 substitution stays, and the
|
||||
// register row stays ACTIVE. Do not land the bare radius on the
|
||||
// strength of the byte evidence alone - the bytes were never the
|
||||
// open question.
|
||||
float naturalRestingDist = radius * ci.ContactPlane.Normal.Z;
|
||||
|
||||
if (dist < naturalRestingDist - PhysicsGlobals.EPSILON)
|
||||
{
|
||||
// Sphere is actually penetrating the plane (feet below it)
|
||||
// — push up along +Z to restore natural resting distance.
|
||||
// Sphere is penetrating below the natural-resting threshold
|
||||
// (radius * N.z — the retained AD-66 substitution, see the
|
||||
// block comment above) — push up along +Z to restore it.
|
||||
float zDist = (naturalRestingDist - dist) / ci.ContactPlane.Normal.Z;
|
||||
if (radius > MathF.Abs(zDist))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,374 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using Plane = System.Numerics.Plane;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign S slice S4 (2026-08-07) conformance suite for
|
||||
/// <c>Transition.AdjustOffset</c>'s two register-row substitutions:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item>AD-65 — the away-from-plane arm (<c>collisionAngle > 0</c>) must
|
||||
/// SNAP to the contact plane (retail's <c>Plane::snap_to_plane</c>
|
||||
/// @0x00509c50: X/Y preserved, Z re-solved, epsilon no-op), not run the
|
||||
/// orthogonal-projection subtraction used by the into-plane arm.</item>
|
||||
/// <item>AD-66 — the safety push-out's trigger comparison and <c>zDist</c>
|
||||
/// numerator must use the BARE <c>global_sphere->radius</c>, not
|
||||
/// <c>radius * ContactPlane.Normal.Z</c>.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// See <c>docs/research/2026-08-07-s4-adjustoffset-contract.md</c> (the
|
||||
/// pinned contract) and <c>docs/research/2026-08-07-s4-pseudocode.md</c>
|
||||
/// (the full branch-tree derivation, including the resolved Binary Ninja
|
||||
/// flag-idiom ambiguity for the two epsilon-relative comparisons this suite
|
||||
/// exercises).
|
||||
/// </summary>
|
||||
public class S4AdjustOffsetConformanceTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public S4AdjustOffsetConformanceTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private const float Tolerance = 1e-5f;
|
||||
|
||||
// =========================================================================
|
||||
// AD-65 — away-from-plane arm must SNAP (XY preserved, Z re-solved).
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void AdjustOffset_AwayFromPlane_SnapsPreservingXYAndResolvingZ()
|
||||
{
|
||||
// 30 degree contact plane: N = (sin30, 0, cos30) = (0.5, 0, 0.8660254).
|
||||
// Exact unit vector (30-60-90 triangle: 0.5^2 + 0.8660254^2 == 1).
|
||||
var normal = new Vector3(0.5f, 0f, 0.8660254f);
|
||||
var t = new Transition();
|
||||
t.CollisionInfo.SetContactPlane(new Plane(normal, 0f), cellId: 0, isWater: false);
|
||||
|
||||
// Moving +X only: dot(offset, N) = 0.5 > 0 -> AWAY from the plane -> snap.
|
||||
var offset = new Vector3(1f, 0f, 0f);
|
||||
Vector3 result = t.AdjustOffset(offset);
|
||||
|
||||
// snap_to_plane preserves X and Y exactly and re-solves Z:
|
||||
// z = -(x*N.x + y*N.y) / N.z = -(1*0.5 + 0*0) / 0.8660254 = -0.5773502691896258
|
||||
Assert.Equal(1f, result.X, Tolerance);
|
||||
Assert.Equal(0f, result.Y, Tolerance);
|
||||
Assert.Equal(-0.5773502691896258f, result.Z, Tolerance);
|
||||
|
||||
_out.WriteLine($"snap result = ({result.X:F7}, {result.Y:F7}, {result.Z:F7})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjustOffset_AwayFromPlane_NearVerticalPlane_IsNoOp()
|
||||
{
|
||||
// |N.z| = 0.0001 <= PhysicsGlobals.EPSILON (0.0002) -> snap_to_plane's
|
||||
// divide-guard trips -> the ENTIRE offset (X, Y, and Z) is left
|
||||
// unchanged, not just Z.
|
||||
var normal = new Vector3(1f, 0f, 0.0001f);
|
||||
var t = new Transition();
|
||||
t.CollisionInfo.SetContactPlane(new Plane(normal, 0f), cellId: 0, isWater: false);
|
||||
|
||||
// dot(offset, N) = 1*1 + 0 + 0*0.0001 = 1 > 0 -> away-from-plane arm
|
||||
// entered, but the epsilon guard inside must no-op.
|
||||
var offset = new Vector3(1f, 0f, 0f);
|
||||
Vector3 result = t.AdjustOffset(offset);
|
||||
|
||||
Assert.Equal(offset, result);
|
||||
_out.WriteLine($"epsilon no-op result = ({result.X:F7}, {result.Y:F7}, {result.Z:F7})");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Into-plane arm — verify unchanged (not part of AD-65's fix, but the
|
||||
// contract requires this exact-value case be covered too).
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void AdjustOffset_IntoPlane_SubtractsFullNormalComponent()
|
||||
{
|
||||
var normal = new Vector3(0.5f, 0f, 0.8660254f);
|
||||
var t = new Transition();
|
||||
t.CollisionInfo.SetContactPlane(new Plane(normal, 0f), cellId: 0, isWater: false);
|
||||
|
||||
// Moving -X: dot(offset, N) = -0.5 <= 0 -> INTO the plane -> subtract.
|
||||
var offset = new Vector3(-1f, 0f, 0f);
|
||||
Vector3 result = t.AdjustOffset(offset);
|
||||
|
||||
// result = offset - N * collisionAngle = (-1,0,0) - (0.5,0,0.8660254)*(-0.5)
|
||||
// = (-0.75, 0, 0.4330127)
|
||||
Assert.Equal(-0.75f, result.X, Tolerance);
|
||||
Assert.Equal(0f, result.Y, Tolerance);
|
||||
Assert.Equal(0.4330127f, result.Z, Tolerance);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// AD-66 — safety push-out must use the BARE radius (not radius*N.z) for
|
||||
// both the trigger comparison and the zDist numerator.
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a sphere position whose signed plane distance sits strictly
|
||||
/// BETWEEN the old (buggy) <c>radius*N.z</c> threshold and the new
|
||||
/// (retail-faithful) bare-<c>radius</c> threshold. This single fixture
|
||||
/// discriminates BOTH AD-66 sub-fixes at once:
|
||||
/// <list type="bullet">
|
||||
/// <item>the OLD trigger (<c>dist < radius*N.z - EPSILON</c>) would NOT
|
||||
/// have fired here at all (0.47 is not less than 0.4328);</item>
|
||||
/// <item>the NEW trigger (<c>dist < radius - EPSILON</c>) DOES fire
|
||||
/// (0.47 < 0.4998), and the pushed amount must equal the bare-radius
|
||||
/// zDist formula, not the radius*N.z one.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[Fact(Skip = "AD-66 WITHHELD 2026-08-07: retail's bare radius is byte-confirmed but the landing was pulled after the same clean-room binaries measured contradictory absorbed-tick behaviour flipping with test-assert shape alone. Unskip with the AD-66 relanding. See the S4 outcome note in the contract doc.")]
|
||||
public void AdjustOffset_SafetyPush_UsesBareRadiusForTriggerAndNumerator()
|
||||
{
|
||||
const float radius = 0.5f;
|
||||
var normal = new Vector3(0.5f, 0f, 0.8660254f); // 30 degrees, unit.
|
||||
const float dist = 0.47f; // strictly between radius*N.z (0.4330127) and radius (0.5)
|
||||
|
||||
// Sanity: confirm the fixture actually sits in the discriminating gap
|
||||
// before trusting the test's own claim about it.
|
||||
float naturalRestingDistOld = radius * normal.Z;
|
||||
Assert.True(dist > naturalRestingDistOld - PhysicsGlobals.EPSILON,
|
||||
"fixture must NOT trip the old radius*N.z trigger");
|
||||
Assert.True(dist < radius - PhysicsGlobals.EPSILON,
|
||||
"fixture MUST trip the new bare-radius trigger");
|
||||
|
||||
var t = new Transition();
|
||||
t.CollisionInfo.SetContactPlane(new Plane(normal, 0f), cellId: 0xA9B40001u, isWater: false);
|
||||
|
||||
// globCenter chosen purely along N (Y=0) so dot(globCenter,N)+D == dist
|
||||
// exactly: globCenter.z = dist / N.z (D = 0).
|
||||
float centerZ = dist / normal.Z;
|
||||
t.SpherePath.GlobalSphere[0].Origin = new Vector3(0f, 0f, centerZ);
|
||||
t.SpherePath.GlobalSphere[0].Radius = radius;
|
||||
|
||||
float checkPosZBefore = t.SpherePath.CheckPos.Z;
|
||||
float globSphereZBefore = t.SpherePath.GlobalSphere[0].Origin.Z;
|
||||
|
||||
// Zero movement request: collisionAngle == 0 <= 0 takes the (no-op at
|
||||
// zero) into-plane arm, isolating the safety-push block under test.
|
||||
t.AdjustOffset(Vector3.Zero);
|
||||
|
||||
float expectedZDist = (radius - dist) / normal.Z; // bare-radius numerator
|
||||
float actualPush = t.SpherePath.CheckPos.Z - checkPosZBefore;
|
||||
|
||||
Assert.True(actualPush > 0f,
|
||||
"the bare-radius trigger must fire and push the sphere up; " +
|
||||
"the old radius*N.z trigger would NOT have fired for this fixture " +
|
||||
$"(dist={dist}, old threshold={naturalRestingDistOld - PhysicsGlobals.EPSILON:F7}).");
|
||||
Assert.Equal(expectedZDist, actualPush, Tolerance);
|
||||
// AddOffsetToCheckPos mirrors the same push onto every active
|
||||
// GlobalSphere entry (NumSphere == 1 here), from ITS OWN baseline —
|
||||
// not CheckPos's baseline, which started at a different Z.
|
||||
Assert.Equal(globSphereZBefore + expectedZDist, t.SpherePath.GlobalSphere[0].Origin.Z, Tolerance);
|
||||
|
||||
_out.WriteLine($"push = {actualPush:F7} (expected bare-radius zDist = {expectedZDist:F7}); " +
|
||||
$"old naturalRestingDist formula would have given " +
|
||||
$"{(naturalRestingDistOld - dist) / normal.Z:F7} AND would not have fired at all.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "AD-66 WITHHELD 2026-08-07: retail's bare radius is byte-confirmed but the landing was pulled after the same clean-room binaries measured contradictory absorbed-tick behaviour flipping with test-assert shape alone. Unskip with the AD-66 relanding. See the S4 outcome note in the contract doc.")]
|
||||
public void AdjustOffset_SafetyPush_DoesNotFire_WhenAboveBareRadiusThreshold()
|
||||
{
|
||||
const float radius = 0.5f;
|
||||
var normal = new Vector3(0.5f, 0f, 0.8660254f);
|
||||
const float dist = 0.6f; // > radius (0.5) -> comfortably above threshold either way.
|
||||
|
||||
var t = new Transition();
|
||||
t.CollisionInfo.SetContactPlane(new Plane(normal, 0f), cellId: 0xA9B40001u, isWater: false);
|
||||
|
||||
float centerZ = dist / normal.Z;
|
||||
t.SpherePath.GlobalSphere[0].Origin = new Vector3(0f, 0f, centerZ);
|
||||
t.SpherePath.GlobalSphere[0].Radius = radius;
|
||||
|
||||
float checkPosZBefore = t.SpherePath.CheckPos.Z;
|
||||
t.AdjustOffset(Vector3.Zero);
|
||||
|
||||
Assert.Equal(checkPosZBefore, t.SpherePath.CheckPos.Z, Tolerance);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Uphill no-flap guard. Written as the S4 contract's AD-66 STOP-condition
|
||||
// scenario; AD-66 was then WITHHELD (issue #341), so this currently runs
|
||||
// against the RETAINED radius*N.z substitution and its green is evidence
|
||||
// about THAT code. It must stay green across the AD-66 relanding too —
|
||||
// it is the scenario the substitution was originally written to protect.
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// 42 degree contact plane (N.z = cos(42deg) = 0.74314 >
|
||||
/// PhysicsGlobals.FloorZ = 0.6642, so it IS walkable, with a margin of
|
||||
/// about 0.079 — "steep but walkable", matching the register row's own
|
||||
/// framing). A single large sloped BSP polygon rising toward +X stands in
|
||||
/// for outdoor terrain (same mechanism: FindEnvCollisions ->
|
||||
/// AdjustOffset -> ValidateWalkable per tick); the mover requests a
|
||||
/// PURELY HORIZONTAL forward step every tick (a purely
|
||||
/// horizontal request against a seeded contact plane — the ordinary
|
||||
/// grounded-movement shape; note PhysicsBody.cs:350-355 documents
|
||||
/// horizontal PROCEEDING as the failure symptom when the contact-plane
|
||||
/// seed is missing, which is precisely what the per-tick assertions
|
||||
/// below rule out) and relies on collision detection against the
|
||||
/// rising polygon plus AdjustOffset's projection/safety-push to keep the
|
||||
/// sphere glued to the surface, exactly the retail per-tick mechanism.
|
||||
/// <para><b>Sabotage record (SAB-S4-1, 2026-08-07, verified twice —
|
||||
/// implementer and reviewer independently):</b> re-instating
|
||||
/// <c>result -= N * collisionAngle</c> in the away arm reds
|
||||
/// <c>AdjustOffset_AwayFromPlane_SnapsPreservingXYAndResolvingZ</c> with
|
||||
/// X = 0.75 — exactly the cos²30° shrinkage AD-65's register row recorded —
|
||||
/// and reds the near-vertical no-op test with (0, 0, -1e-4).</para>
|
||||
///
|
||||
/// <para><b>What the rest of the suite does NOT discriminate:</b> every
|
||||
/// pre-existing test that reaches the away arm
|
||||
/// (RuntimeRemoteSlopeProjectionTests, RuntimeRemoteSteepContactSlideTests)
|
||||
/// asserts lower bounds or XY-invariant offsets that the snap over-satisfies
|
||||
/// — RuntimeRemoteSlopeProjectionTests records its own AdjustOffset
|
||||
/// short-circuit sabotage staying GREEN. The two exact-value tests here are
|
||||
/// the ONLY discriminating coverage for AD-65; the felt 33–100% downhill
|
||||
/// speed-up is the morning gate's G1 row.</para>
|
||||
///
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Uphill_NoContactFlapAcrossTicks()
|
||||
{
|
||||
const float radius = 0.5f;
|
||||
const float angleDegrees = 42f;
|
||||
const uint cellId = 0xA9B40157u;
|
||||
|
||||
float theta = angleDegrees * MathF.PI / 180f;
|
||||
float sinT = MathF.Sin(theta);
|
||||
float cosT = MathF.Cos(theta);
|
||||
Assert.True(cosT > PhysicsGlobals.FloorZ,
|
||||
"fixture sanity: the slope must be walkable by retail's own FloorZ test");
|
||||
|
||||
var (engine, root) = BuildSlopeEngine(sinT, cosT, cellId);
|
||||
|
||||
// Resting root Z at horizontal x0, derived from: sphere center =
|
||||
// root + (0,0,radius); dot(center, N) + D == radius (D == 0);
|
||||
// N = (-sinT, 0, cosT).
|
||||
float x0 = 1.0f;
|
||||
float RestingRootZ(float x) => (radius * (1f - cosT) + sinT * x) / cosT;
|
||||
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
ContactPlaneValid = true,
|
||||
ContactPlane = new Plane(new Vector3(-sinT, 0f, cosT), 0f),
|
||||
ContactPlaneCellId = cellId,
|
||||
ContactPlaneIsWater = false,
|
||||
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
|
||||
};
|
||||
|
||||
Vector3 position = new(x0, 0f, RestingRootZ(x0));
|
||||
const float dxPerTick = 0.12f;
|
||||
const int ticks = 15;
|
||||
|
||||
for (int tick = 0; tick < ticks; tick++)
|
||||
{
|
||||
Vector3 target = position + new Vector3(dxPerTick, 0f, 0f);
|
||||
|
||||
ResolveResult result = engine.ResolveWithTransition(
|
||||
currentPos: position,
|
||||
targetPos: target,
|
||||
cellId: cellId,
|
||||
sphereRadius: radius,
|
||||
sphereHeight: 0f,
|
||||
stepUpHeight: 0.4f,
|
||||
stepDownHeight: 0.4f,
|
||||
isOnGround: true,
|
||||
body: body);
|
||||
|
||||
_out.WriteLine(
|
||||
$"tick {tick}: ok={result.Ok} pos=({result.Position.X:F4},{result.Position.Y:F4}," +
|
||||
$"{result.Position.Z:F4}) inContact={result.InContact} onWalkable={result.OnWalkable} " +
|
||||
$"planeN=({result.ContactPlane.Normal.X:F4},{result.ContactPlane.Normal.Y:F4}," +
|
||||
$"{result.ContactPlane.Normal.Z:F4})");
|
||||
|
||||
Assert.True(result.Ok, $"tick {tick}: transition must not get stuck running uphill");
|
||||
Assert.True(result.InContact,
|
||||
$"tick {tick}: contact must not be lost running uphill (the AD-66 flap symptom)");
|
||||
Assert.True(result.OnWalkable,
|
||||
$"tick {tick}: OnWalkable must not flap to false running uphill on a walkable " +
|
||||
"slope. Written as the S4/AD-66 STOP condition; with AD-66 withheld (#341) it " +
|
||||
"guards the RETAINED substitution and must survive the relanding.");
|
||||
|
||||
position = result.Position;
|
||||
}
|
||||
|
||||
// retail's unchanged, correct into-plane arm (AD-65's register row:
|
||||
// "Uphill (collisionAngle <= 0) is correct and identical to retail")
|
||||
// projects a purely-horizontal request of dx against a theta-degree
|
||||
// incline down to dx*cos^2(theta) of effective horizontal advance —
|
||||
// removing the into-plane component always costs a cos^2(theta)
|
||||
// factor. That is expected slope-climbing physics, not a stall, so
|
||||
// the progress floor below is calibrated to it rather than a naive
|
||||
// dx-per-tick expectation (which the earlier, wrong version of this
|
||||
// test asserted and which redded even though nothing was stuck).
|
||||
float expectedMinimumAdvance = dxPerTick * cosT * cosT * (ticks - 3);
|
||||
Assert.True(position.X - x0 > expectedMinimumAdvance,
|
||||
$"expected at least {expectedMinimumAdvance:F4} m of horizontal advance " +
|
||||
"(dx*cos^2(theta) per tick, retail's unchanged into-plane projection); " +
|
||||
$"got {position.X - x0:F4} m -- a shortfall here would mean the mover " +
|
||||
"stalled, not merely slowed by the expected slope projection.");
|
||||
}
|
||||
|
||||
private static (PhysicsEngine Engine, PhysicsBSPNode Root) BuildSlopeEngine(
|
||||
float sinT, float cosT, uint cellId)
|
||||
{
|
||||
// Large sloped quad, plane through the origin: N = (-sinT, 0, cosT),
|
||||
// D = 0 -> z(x) = x * tan(theta). Spans far enough in X/Y to hold the
|
||||
// whole multi-tick uphill run away from any polygon edge.
|
||||
float ZAt(float x) => x * sinT / cosT;
|
||||
Vector3[] vertices =
|
||||
[
|
||||
new(-10f, -30f, ZAt(-10f)),
|
||||
new(60f, -30f, ZAt(60f)),
|
||||
new(60f, 30f, ZAt(60f)),
|
||||
new(-10f, 30f, ZAt(-10f)),
|
||||
];
|
||||
var plane = new Plane(new Vector3(-sinT, 0f, cosT), 0f);
|
||||
|
||||
var root = new PhysicsBSPNode
|
||||
{
|
||||
Type = BSPNodeType.Leaf,
|
||||
BoundingSphere = new Sphere { Origin = new Vector3(25f, 0f, ZAt(25f)), Radius = 100f },
|
||||
};
|
||||
root.Polygons.Add(1);
|
||||
|
||||
var resolved = new Dictionary<ushort, ResolvedPolygon>
|
||||
{
|
||||
[1] = new ResolvedPolygon
|
||||
{
|
||||
Id = 1,
|
||||
Vertices = vertices,
|
||||
Plane = plane,
|
||||
NumPoints = vertices.Length,
|
||||
SidesType = CullMode.None,
|
||||
},
|
||||
};
|
||||
|
||||
var cell = new CellPhysics
|
||||
{
|
||||
BSP = new PhysicsBSPTree { Root = root },
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Resolved = resolved,
|
||||
CellBSP = new CellBSPTree { Root = new CellBSPNode { Type = BSPNodeType.Leaf } },
|
||||
};
|
||||
|
||||
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
|
||||
|
||||
var heights = new byte[81];
|
||||
var heightTable = new float[256];
|
||||
for (int i = 0; i < 256; i++) heightTable[i] = i * 1f;
|
||||
engine.AddLandblock(0xA9B4FFFFu, new TerrainSurface(heights, heightTable),
|
||||
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f, worldOffsetY: 0f);
|
||||
|
||||
engine.DataCache.RegisterCellStructForTest(cellId, cell);
|
||||
return (engine, root);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue