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

@ -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

View file

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

View file

@ -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

View file

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

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** |

View file

@ -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))
{

View file

@ -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 &gt; 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-&gt;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 &lt; 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 &lt; radius - EPSILON</c>) DOES fire
/// (0.47 &lt; 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 &gt;
/// 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 -&gt;
/// AdjustOffset -&gt; 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 33100% 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);
}
}