diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 9c7882ce..d0ab445b 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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**; diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 3aa1bd43..a472a5c1 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -62,7 +62,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 51 active rows (AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 51 active rows (AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -159,10 +159,11 @@ readiness/requeue adaptation. See | AD-62 | **Filed 2026-08-03 (C4 route 2, round 2); rewritten round 3.** General rule: an accepted local-player ForcePosition that this route does not carry through to a committed canonical placement is never re-applied. That half matches retail — `SmartBox::BlipPlayer` attempts the placement exactly once and never retries. What diverges is that acdream has non-commit outcomes retail cannot reach at all, because retail's world is fully resident and its placement synchronous. Round 3 narrowed the loss to the re-apply alone wherever the packet's placement was actually BEGUN: the retail position event now fires at that packet's terminal outcome whether or not the placement committed (`SettlePending`'s `positionEventOwed` path), matching `BlipPlayer` discarding `SetPositionSimple`'s `enum SetPositionError` and `HandleReceivedPosition` acking unconditionally @0x00454091. Shapes losing ONLY the re-apply: (i) the destination landblock's collision generation is unpublished so the placement parks (`DeferredCell`) and is then retired by a non-position cause (collision-generation retirement, the lost-cell deadline, `ParkCollisionResidents`) with the accepted authority unmoved — the funnel's EQUAL branch; (ii) the same park superseded by a newer ordinary `Apply` Position which now owns the pose — the ADVANCED+ordinary branch; (iii) any OTHER `PositionAuthorityVersion` advance moving the record out from under the funnel's re-issue test — `TryApplyPickup` (`RuntimeEntityObjectLifetime.cs:1116`), `CommitPositionChannelUpdate` (`:2041`), `AdvanceCreateAuthority` (`:2466`) — effectively unreachable for a live local player, but they fail silently in the same direction and the funnel cannot tell them from (ii). Shapes still losing BOTH the re-apply and the ack because no placement was ever begun for that packet: (iv) a `Contention` whose blocking operation is EXTERNAL to this drive (a concurrent portal/teleport placement owns the entity) — nothing is recorded in `_pending`, so nothing pumps it and the packet is dropped outright; (vi) a re-issue retry marker whose re-issue never manages to begin before the funnel clears it. Losing BOTH for a DIFFERENT reason — the placement WAS begun, but the descriptor was displaced before reaching its own terminal settle: (v) a packet superseded by a newer force whose own placement begins cleanly — `SettlePending` opens by nulling `_pending` without reading it, so the older descriptor's owed ack is discarded. Replaying it would be worse than losing it (a stale-sequence report carrying the newer packet's committed pose), and the displacing packet always acks, so ACE always receives a report for the newest force. The `DeferredCell` park is NOT a precondition of this row: shapes (iv)-(vi) never park. In every shape the body stays where the last successful placement left it and the next accepted Position (ACE broadcasts at 5-10 Hz) carries the corrected pose forward. | `src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs` (`SettlePending` — the single terminal-outcome funnel: its `positionEventOwed` ack and its two non-reissuing branches; and `TryExecuteAcceptedLocalPosition`'s `Contention` return) | Retail has no park and no external placement authority: `SmartBox::BlipPlayer` runs synchronously against a fully resident world, so "arrived but not yet placeable" and "another placement owns this entity" are both unrepresentable there. Those are our async collision-publication and single-placement-authority adaptations. Re-issuing a retired force instead would be worse than not: shape (ii) would stamp the force route's `Teleport\|Slide` flags and an unconditional ack onto an ordinary echo's pose while skipping the `ConstrainTo` the ordinary branch runs (`RuntimeAuthoritativePositionRouteClassifier.cs:368-388`), and shape (i) can re-issue into the same persistent cancellation cause indefinitely. The drive still owns at most one in-flight placement and still re-issues whenever the newest accepted event IS a still-unserved ForcePosition. | A server correction whose destination collision is slow to publish, or which lands while another placement authority owns the entity, can be silently skipped: the player stays at the pre-correction pose for one broadcast interval (~100-200 ms). Sustained (a slow-publishing destination correcting repeatedly) this reads as rubber-banding that does not take. In shapes (iv)-(vi) ACE additionally receives one fewer `AutonomousPosition` than retail would have sent, so the server cannot tell its force was not applied. | `SmartBox::HandleReceivedPosition` @0x00453FD0 FORCE_POSITION branch (`SendPositionEvent` @0x00454091, early return @0x0045409D); `SmartBox::BlipPlayer` @0x00453940 (discards the error, returns void); `CPhysicsObj::SetPositionSimple` @0x005162B0 (returns `enum SetPositionError`; other callers test `== OK_SPE` @0x0055605D/@0x00556021); `CommandInterpreter::SendPositionEvent` @0x006B4770 | | AD-63 | **Filed 2026-08-04 (cancelled-park presentation rollback).** When a cancelled restorable park is rolled back, the entity's presentation is restored EXCEPT the player's selection. `ParkDeferred`'s Withdraw receipt makes the host sink clear the selection if the parked entity was the selected object (`_clearSelectionForUnavailableEntity`), and the `WithdrawalRestored` receipt that rolls that withdrawal back deliberately does not re-select it. Every other registration the withdrawal removed — the graphical bucket, projection visibility, plugin world state, the world-event replay set, the effect-pose registry, the local-player shadow, the presentation visibility sinks — IS restored exactly. | `src/AcDream.App/World/RuntimePlacementPresentationSink.cs` (`TryApplyWithdrawalRestoration` vs `TryPublishWithdrawal`'s `_clearSelectionForUnavailableEntity` call) | Selection is user intent, not a projection registration. Retail clears the selection when its target becomes unavailable (`SelectionChangeReason.SelectedObjectRemoved` is acdream's name for the same edge) and never re-selects on the object's behalf; re-selecting here would invent input the player did not give. Retail also cannot reach this state at all — it has no cancel for a lost-cell park (AP-136) — so there is no retail behaviour to match, only two acdream choices, and "do not act for the player" is the conservative one. | The player loses their target for the ~150 ms park window if the selected object happened to park, and must re-click it. No other state is affected: the object is visible, on the radar, collidable, and assessable again as soon as the restoration receipt drains. Retire together with AP-136 by making the park survive cancellation (issue #309), which removes the withdrawal — and therefore the selection clear — entirely. | AP-136 (the park rollback this rides on); no retail anchor — retail has no cancellable lost-cell park | | AD-64 | **Filed 2026-08-05 at the C5b architecture review's D1 fix.** The graphical and no-window hosts run parallel, non-shared inbound entity routes — `LiveEntitySessionController` → `LiveEntityNetworkUpdateController.OnPosition` versus `RuntimeLiveEntitySessionController.OnPositionUpdated` — and AD-60's W2 wire-cell commit is therefore expressed TWICE. The committed VALUE is shared exactly (one owner, `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, including the landblock-vs-cell preserve branch); what is duplicated is the REACHABILITY decision — which packets may reach it. The graphical host encodes that decision implicitly, as the set of early returns strewn through a 400-line `OnPosition` (authority gate on `Rejected`, the local force arm on every drive status except `NotApplicable`, the missile arm, the `ChildUnparentDisposition` Superseded/Pending arm, the initial-create residence gate inside `RebucketLiveEntity`). The no-window host encodes it explicitly, in one method, `TryCommitAcceptedWireCell`, whose gates were derived from those returns one by one. Two of the graphical gates have no no-window analogue and are deliberately absent rather than reproduced: the `ChildUnparentDisposition` arm is presentation recovery this host does not perform, and the residence gate's `MaterializationResidence is AwaitRuntimePlacement` half is App presentation bookkeeping whose no-window equivalent is unconditionally true for a residence-backed record. **CORRECTED 2026-08-05 at the C5b closeout (architecture finding L-B): "deliberately absent" was presented as the complete list of differences and it was not — there are three more, and the row's own "derived from those returns one by one" phrasing was the claim that made them invisible.** (a) **The residence gate is WEAKER than the merge's own.** Both hosts' wire-cell commits gate on `TryGetInitialCreateResidence` (= `RuntimeInitialCreateResidenceState.TryGetCurrent`), while `RuntimeEntityObjectLifetime.TryApplyPosition`'s FIFO enqueue branch gates on `TryGetPendingInitialResidence` (= `TryGetTransaction` = `TryGetCurrent` OR a completed-but-unretired lease, `RuntimeInitialCreateResidenceState.cs:729-748`). In that window the merge enqueues the packet as a continuation while the commit reads "no residence" and writes the wire cell AHEAD of the continuation that will replay it. Host-symmetric and pre-existing — the graphical `RebucketLiveEntity` has the identical pair — but this row previously claimed the `AwaitRuntimePlacement` half was the only deliberately-absent piece of the residence gate, which is false. (b) **The missile gates are two different expressions.** The graphical route PREFERS `earlyRemoteRoute.OperationKind is RuntimeSetPositionOperationKind.ProjectileAuthoritative` and falls back to the `Missile`-flag / bound-projectile conjunction only when the classification is null; the no-window route ALWAYS uses the conjunction, because it classifies nothing for a remote. They agree today (the conjunction is what the classifier's own projectile test is built from), but they are separately maintained and only the conjunction is reachable on one side — a change to the classifier's projectile predicate moves one host and not the other. (c) **The pre-merge PAYLOAD gate was absent entirely, and is now present.** The graphical route validates the wire payload before the merge (`LiveEntityNetworkUpdateController.OnPosition`'s `payloadIsValid` from `ProjectileController.CanAcceptPositionPayload` — despite the name, not projectile-scoped; it runs for every guid — consumed by `LiveEntityInboundAuthorityGate.TryAcceptPosition`'s `!payloadIsValid` return). The no-window route had no equivalent, so since D1 an unvalidated `update.Position.LandblockId` reached `CommitWireCellRebucket`, whose own doc calls `0` "the withdrawal shape" (cell 0 + landblock 0) — silently de-residencing the entity in the exact field `RuntimeEntityObjectViews.Snapshot` hands every bot as `CellId` and `RuntimeSetPositionState.IsAffectedCollisionResident` reads. Fixed at the closeout by applying the same predicate at the same point: `RuntimeAuthoritativePositionRouteClassifier.IsValidCreateWirePosition` plus the finite-velocity term, the pair `RuntimeEntityObjectLifetime.TryApplyPosition` already applies on its initial-residence branch. Rejecting BEFORE the merge (not merely before the commit) is what makes the hosts symmetric — neither lets an invalid payload advance the timestamp gate — and is pinned by `RuntimeLiveEntitySessionControllerTests.InvalidPositionPayload_IsRefusedBeforeTheMerge_InANoWindowHost`, sabotage-verified in both directions (gate removed -> red at the withdrawal-shape assertion; gate moved to guard only the commit -> red at the pose assertion). The no-window host also has no W3 (`TryAdoptWireCellAfterRouting`) analogue and needs none — it performs no remote contact routing at all. | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition`, the implicit gate set); `src/AcDream.App/World/LiveEntityRuntime.cs` (`RebucketLiveEntity`'s residence early return); `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` (`TryCommitAcceptedWireCell`, `IsMissilePacket`) | Retail has one client and therefore one route; there is no retail shape to match, only acdream's own two-host structure. The alternative — unifying the two session controllers so the decision exists once — is the genuinely correct fix and is filed as issue #324, but it is campaign-sized: it has to reconcile presentation recovery, hydration, the equipped-child renderer, and the remote routing arms that only one of the two hosts has. Duplicating a small, individually test-gated decision is the cheaper correct thing meanwhile; duplicating it SILENTLY, which is what the pre-D1 state amounted to (one host simply had none of it), is what this row exists to stop. | The two decisions can drift: a future change to one host's reachability rules will not be caught by the other host's tests. Concretely, if the graphical route later adds an early return, the no-window host keeps committing on that packet shape, and vice versa. Bounded by the eight-sabotage gate the D1 fix left behind, plus the closeout's ninth (the payload gate, red in both directions) — every arm of `TryCommitAcceptedWireCell` and both directions of the force rule are individually red-verified — so drift shows up as a test that must be deliberately changed, not as a silent divergence. **That bound does NOT cover the three differences added at the closeout**: the weaker residence predicate (a) and the missile-expression split (b) have no discriminating test on either side, because in both cases the two hosts currently AGREE and the divergence is structural rather than behavioural. They are recorded here precisely because nothing else will catch them. Retire with #324. | No retail anchor — acdream-only host-structure deviation. Adjacent rows: AD-60 (the W2/W3 channel list), AP-146/#320 (the local player's cell edges) | -| AD-65 | **Filed 2026-08-06 (found while retiring AD-10; NOT fixed here).** `Transition.AdjustOffset`'s `collisionAngle > 0` arm — the body moving AWAY from its contact plane — substitutes `result -= N * collisionAngle` for retail's `Plane::snap_to_plane` call, making the `if` and the `else` arms byte-identical. Retail's two arms are genuinely different: `snap_to_plane` (0x00509c50) writes ONLY `v.z = -(v.x*N.x + v.y*N.y) / N.z` and leaves X and Y untouched, while the into-plane arm subtracts the full normal component. So for a horizontal step of length d on a slope of angle theta, retail DESCENDS with XY preserved at d and Z dropping d*tan(theta) (speed along the plane d/cos theta), whereas acdream shrinks XY to d*cos^2(theta) (speed along the plane d*cos theta). acdream therefore descends slopes SLOWER than retail by cos^2(theta) in XY: **25% slow at 30 degrees, 50% at 45 degrees**. **MAGNITUDE CORRECTED 2026-08-06 at the AD-10 retail review (F1): this row first said 13%/29%, which is 1-cos(theta) -- the wrong formula for its own stated cos^2(theta) factor, and half the true value.** The correction is confirmed by measurement, not just algebra: #331's probe records 0.0735 m travelled for a 0.1 m request at 30.96 degrees, i.e. 26.5% short, which is exactly cos^2(30.96). This matters because the row is a LEAD for #269's slope-slide residual -- at the understated magnitude the lead reads as marginal and could be dismissed. Uphill (`collisionAngle <= 0`) is correct and identical to retail. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`AdjustOffset`, the `else` arm commented "Moving away from contact plane: snap to plane surface" — the comment names snap_to_plane but the code does not call it) | Not justified — this is an unexamined substitution, not a decision. It is filed rather than fixed because it changes LOCAL-PLAYER movement feel and so needs its own visual gate; folding it into a remote-movement change would put a local-player regression behind the wrong acceptance test. | Downhill locomotion is 25-50% slow in XY across the walkable slope range (corrected 2026-08-06; was understated as 13-29%), for every mover that runs the sweep (local player, remotes, projectiles). **Recorded as a LEAD, not a diagnosis, for the open #269 slope-slide feel residual** (Campaign P): the direction is right (downhill-only, XY-shortening) but nothing here establishes causation, and #269 still needs its live cdb A/B. Note #269's friction and jump chains are byte-exonerated and must not be re-audited; `adjust_offset` is a different function and is not covered by that do-not-retry. | `CTransition::adjust_offset` 0x0050a370, pc:272271-272393; the branch at `0050a4fa fcomp [0x795344]` / `0050a502 test ah,0x41` / `0050a505 jne 0x50a515` — disassembled from the PDB-paired v11.4186 binary (GUID 9e847e2f-777c-4bd9-886c-22256bb87f32), 0x795344 = 0.0f (bytes 00000000). FPU C0 is "less" and C3 is "equal", so `jne` on `ah & 0x41` takes the SUBTRACT branch at 0x50a515 when `cAngle <= 0` and falls through to `call 0x509c50` (`Plane::snap_to_plane`, pc:271852) when `cAngle > 0`. Binary Ninja renders all four comparisons in this function as the `fnstsw`/`test ah` mush and cannot be read for direction. | -| AD-66 | **Filed 2026-08-06 (found while retiring AD-10; NOT fixed here).** `Transition.AdjustOffset`'s safety push-out substitutes `naturalRestingDist = radius * ContactPlane.Normal.Z` for retail's bare `radius` in BOTH the trigger comparison and the `zDist` numerator. The substitution is deliberate and carries a written rationale in the code (the LocalSphere origin sits at (0, 0, radius) along WORLD Z, so a sphere resting on a tilted plane is `radius * N.z` from it, and the bare threshold would fire spuriously on every slope and lift the feet by r*(sec theta - 1) — 7 cm at 30 degrees, 48 cm at 60). The rationale may well be correct. What is missing is the register row: an intentional deviation from a byte-confirmed retail constant with no row is precisely what this register exists to catch, and the code comment's claim that "ACE and the published pseudocode have the original threshold" understates it — the retail BINARY has it. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`AdjustOffset`, the `ci.ContactPlaneCellId != 0 && !ci.ContactPlaneIsWater` block) | Argued at length in the code comment and empirically motivated (the uncorrected threshold reportedly broke ValidateWalkable's contact check on steep slopes and flickered the Falling animation while running uphill). Filed to make the deviation auditable, not to assert it is wrong. | If the sphere-origin premise is mistaken, the push-out under-fires on slopes and a genuinely penetrating sphere is left below its contact plane. Conversely, if the premise is right, retail itself has the spurious lift and acdream is deliberately smoother than retail on slopes — a feel divergence in the same family as, and possibly interacting with, AD-65 and #269. | `CTransition::adjust_offset` 0x0050a370; disassembled from the PDB-paired v11.4186 binary: `0050a5c4 fld [ecx+0xc]` loads the bare `global_sphere->radius` and `0050a5c7 fsub [0x7c6878]` subtracts 0.00019999999494757503f (bytes 17b75139) for the trigger; `0050a5dc fsubr [ecx+0xc]` reloads the bare radius for the numerator before `0050a5df fdiv [esi+8]` divides by `contact_plane.N.z`. Neither site multiplies by N.z. | +| ~~AD-65~~ | **RETIRED 2026-08-07 (Campaign S S4).** `Transition.AdjustOffset`'s away-from-plane arm now performs retail's `Plane::snap_to_plane` @0x00509c50 semantics verbatim: XY preserved, Z re-solved as `-(x*Nx + y*Ny)/Nz`, no-op under the 0.000199999995f |N.z| epsilon — replacing the orthogonal projection whose cos²θ downhill XY shortfall this row recorded (25% at 30°, 50% at 45°). Branch polarity ported from the `test ah,0x41` idiom at 0x0050a4fa: into-plane subtracts, away-from-plane snaps. Conformance: `S4AdjustOffsetConformanceTests` exact-value rows, sabotage-verified (the re-instated projection reproduces exactly the recorded cos²30° = 0.75 shrinkage). NOTE: the SIBLING row AD-66 was byte-re-confirmed but its landing was WITHHELD the same night — see issue #341. Original text: **Filed 2026-08-06 (found while retiring AD-10; NOT fixed here).** `Transition.AdjustOffset`'s `collisionAngle > 0` arm — the body moving AWAY from its contact plane — substitutes `result -= N * collisionAngle` for retail's `Plane::snap_to_plane` call, making the `if` and the `else` arms byte-identical. Retail's two arms are genuinely different: `snap_to_plane` (0x00509c50) writes ONLY `v.z = -(v.x*N.x + v.y*N.y) / N.z` and leaves X and Y untouched, while the into-plane arm subtracts the full normal component. So for a horizontal step of length d on a slope of angle theta, retail DESCENDS with XY preserved at d and Z dropping d*tan(theta) (speed along the plane d/cos theta), whereas acdream shrinks XY to d*cos^2(theta) (speed along the plane d*cos theta). acdream therefore descends slopes SLOWER than retail by cos^2(theta) in XY: **25% slow at 30 degrees, 50% at 45 degrees**. **MAGNITUDE CORRECTED 2026-08-06 at the AD-10 retail review (F1): this row first said 13%/29%, which is 1-cos(theta) -- the wrong formula for its own stated cos^2(theta) factor, and half the true value.** The correction is confirmed by measurement, not just algebra: #331's probe records 0.0735 m travelled for a 0.1 m request at 30.96 degrees, i.e. 26.5% short, which is exactly cos^2(30.96). This matters because the row is a LEAD for #269's slope-slide residual -- at the understated magnitude the lead reads as marginal and could be dismissed. Uphill (`collisionAngle <= 0`) is correct and identical to retail. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`AdjustOffset`, the `else` arm commented "Moving away from contact plane: snap to plane surface" — the comment names snap_to_plane but the code does not call it) | Not justified — this is an unexamined substitution, not a decision. It is filed rather than fixed because it changes LOCAL-PLAYER movement feel and so needs its own visual gate; folding it into a remote-movement change would put a local-player regression behind the wrong acceptance test. | Downhill locomotion is 25-50% slow in XY across the walkable slope range (corrected 2026-08-06; was understated as 13-29%), for every mover that runs the sweep (local player, remotes, projectiles). **Recorded as a LEAD, not a diagnosis, for the open #269 slope-slide feel residual** (Campaign P): the direction is right (downhill-only, XY-shortening) but nothing here establishes causation, and #269 still needs its live cdb A/B. Note #269's friction and jump chains are byte-exonerated and must not be re-audited; `adjust_offset` is a different function and is not covered by that do-not-retry. | `CTransition::adjust_offset` 0x0050a370, pc:272271-272393; the branch at `0050a4fa fcomp [0x795344]` / `0050a502 test ah,0x41` / `0050a505 jne 0x50a515` — disassembled from the PDB-paired v11.4186 binary (GUID 9e847e2f-777c-4bd9-886c-22256bb87f32), 0x795344 = 0.0f (bytes 00000000). FPU C0 is "less" and C3 is "equal", so `jne` on `ah & 0x41` takes the SUBTRACT branch at 0x50a515 when `cAngle <= 0` and falls through to `call 0x509c50` (`Plane::snap_to_plane`, pc:271852) when `cAngle > 0`. Binary Ninja renders all four comparisons in this function as the `fnstsw`/`test ah` mush and cannot be read for direction. | +| AD-66 | **LANDING WITHHELD 2026-08-07 (Campaign S S4) — the row stays ACTIVE and its byte evidence is now DOUBLE-confirmed.** The bare-radius port was implemented, conformance-tested, and then PULLED: it collides with the #331 absorb characterization pin through a measurement that contradicted itself (the same clean-room binaries measured both a one-time resting lift and an exact latch, flipping with nothing but the test's post-tick assert shape). Issue #341 carries the observation matrix and the apparatus plan; the two exact-value conformance tests are [Skip]-ed in the tree awaiting the relanding. Do not re-derive the bytes — they were never the question. **Filed 2026-08-06 (found while retiring AD-10; NOT fixed here).** `Transition.AdjustOffset`'s safety push-out substitutes `naturalRestingDist = radius * ContactPlane.Normal.Z` for retail's bare `radius` in BOTH the trigger comparison and the `zDist` numerator. The substitution is deliberate and carries a written rationale in the code (the LocalSphere origin sits at (0, 0, radius) along WORLD Z, so a sphere resting on a tilted plane is `radius * N.z` from it, and the bare threshold would fire spuriously on every slope and lift the feet by r*(sec theta - 1) — 7 cm at 30 degrees, 48 cm at 60). The rationale may well be correct. What is missing is the register row: an intentional deviation from a byte-confirmed retail constant with no row is precisely what this register exists to catch, and the code comment's claim that "ACE and the published pseudocode have the original threshold" understates it — the retail BINARY has it. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`AdjustOffset`, the `ci.ContactPlaneCellId != 0 && !ci.ContactPlaneIsWater` block) | Argued at length in the code comment and empirically motivated (the uncorrected threshold reportedly broke ValidateWalkable's contact check on steep slopes and flickered the Falling animation while running uphill). Filed to make the deviation auditable, not to assert it is wrong. | If the sphere-origin premise is mistaken, the push-out under-fires on slopes and a genuinely penetrating sphere is left below its contact plane. Conversely, if the premise is right, retail itself has the spurious lift and acdream is deliberately smoother than retail on slopes — a feel divergence in the same family as, and possibly interacting with, AD-65 and #269. | `CTransition::adjust_offset` 0x0050a370; disassembled from the PDB-paired v11.4186 binary: `0050a5c4 fld [ecx+0xc]` loads the bare `global_sphere->radius` and `0050a5c7 fsub [0x7c6878]` subtracts 0.00019999999494757503f (bytes 17b75139) for the trigger; `0050a5dc fsubr [ecx+0xc]` reloads the bare radius for the numerator before `0050a5df fdiv [esi+8]` divides by `contact_plane.N.z`. Neither site multiplies by N.z. | | AD-67 | **Filed 2026-08-07 at the #32 closeout.** The narrowed `CollisionInfo.SetContactPlane` still writes `ContactPlaneCellId`, which retail's `COLLISIONINFO::set_contact_plane` @0x00509d80 does not — retail writes the cell id only in `CTransition::init_contact_plane` (@0x0050e8ca). Kept deliberately at the #32 fix on the research doc's own advice: acdream's consumers (the `[support]` probe's provenance, water-plane bookkeeping, `AdjustOffset`'s `ContactPlaneCellId != 0` gate) rely on the cell id being current per contact write, and retail's equivalent state travels a different route the port has not needed. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SetContactPlane`, the `ContactPlaneCellId = cellId` line) | The #32 fix removed the four LAST-KNOWN writes — the defect — and deliberately did not also change this contact-group field in the same commit; two behaviour changes in one fix would have made the user's cliff gate ambiguous. | A consumer that assumes the cell id changes ONLY at transition seed time (retail's timing) would observe it changing per contact write instead. No such consumer is known; `AdjustOffset`'s gate wants the current value. | `COLLISIONINFO::set_contact_plane` 0x00509d80 (22 bytes, no cell-id write); `CTransition::init_contact_plane` 0x0050e850 (cell id at 0x0050e8ca) | | AD-68 | **Filed 2026-08-07 at the #338 closure.** During an entity's ASYNC-RESIDENCY window — its flat Setup collision not yet resident — `LiveEntityMotionRuntimeController.GetSetupMoverShape` returns a placeholder mover shape: empty sphere list (falling back to the legacy 0.48/1.835 capsule reconstruction) and step heights **0.4/0.4**, values that appear nowhere in retail (authored human values are 0.600/1.500; retail's not-on-walkable fallback is 0.04). The local player has the same window between controller construction (0.4f defaults) and the publication candidate's adoption. Retail loads Setups synchronously and has no such window at all. Measured scale: 358 placeholder resolves vs 111,248 authored-pair resolves across one long session — seconds per entity, once. | `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs` (`GetSetupMoverShape`, the `setup is null` and `<= 0f` arms); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (0.4f field defaults, adopted-over at publication) | An adaptation to async residency, not a wiring defect — #338's live probe proved prepare/publish/resolve all carry the authored values in steady state. Left as-is deliberately: shrinking the window is streaming work, not physics work. | A remote moving DURING its residency window steps 0.4 instead of its authored heights, and collides as a capsule instead of its sphere list — briefly, once per entity. If a future report says "an NPC stumbled on a stair right as it appeared", this row is the first suspect. | `CTransition::step_up` 0x0050b610 (0.04 fallback at 0x0050b655); `CPartArray::GetStepUpHeight` 0x005180d0; issue #338 | +| AD-69 | **Filed 2026-08-07 at the S4 pseudocode pass (implementer finding, verified against the decomp).** `Transition.AdjustOffset`'s safety push-out computes `dist` WITHOUT the cell-relative correction retail applies: retail's `adjust_offset` (and ACE's port, independently) run the sphere centre through `LandDefs::get_block_offset` against the contact plane's own cell before the plane-distance dot, so a contact plane owned by a DIFFERENT landblock than the mover's current cell measures in the plane's frame. acdream dots the raw world-space centre against the stored plane. Same-landblock contact (the overwhelming case) is identical; a landblock-SEAM contact measures dist offset by the block delta, mis-firing or mis-suppressing the push-out at seams. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`AdjustOffset`, the dist computation ahead of the push-out block) | Discovered during S4 but deliberately not folded in: S4's own AD-66 half was withheld the same night (#341), and a third change in the same block would have made the anomaly investigation unattributable. Fix alongside the AD-66 relanding. | A mover resting on a contact plane owned by the neighbouring landblock (seam walking) gets a push-out computed against a dist that is wrong by the block offset — either a spurious lift or a missed penetration correction, exactly at landblock seams, the #176/#177 symptom neighbourhood. | `CTransition::adjust_offset` 0x0050a370 (pc:272271-272393); `LandDefs::get_block_offset`; ACE `Transition.AdjustOffset` (cross-check); issue #341 (sequencing) | --- diff --git a/docs/plans/2026-08-06-collision-fidelity-campaign.md b/docs/plans/2026-08-06-collision-fidelity-campaign.md index 1e2ebad0..ea8e9abc 100644 --- a/docs/plans/2026-08-06-collision-fidelity-campaign.md +++ b/docs/plans/2026-08-06-collision-fidelity-campaign.md @@ -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. diff --git a/docs/plans/2026-08-07-morning-gate-checklist.md b/docs/plans/2026-08-07-morning-gate-checklist.md index cbe6c32b..888a2387 100644 --- a/docs/plans/2026-08-07-morning-gate-checklist.md +++ b/docs/plans/2026-08-07-morning-gate-checklist.md @@ -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 diff --git a/docs/research/2026-08-07-s4-adjustoffset-contract.md b/docs/research/2026-08-07-s4-adjustoffset-contract.md index 8b9c8783..b4d81c8d 100644 --- a/docs/research/2026-08-07-s4-adjustoffset-contract.md +++ b/docs/research/2026-08-07-s4-adjustoffset-contract.md @@ -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. diff --git a/docs/research/2026-08-07-s4-pseudocode.md b/docs/research/2026-08-07-s4-pseudocode.md new file mode 100644 index 00000000..6b256b30 --- /dev/null +++ b/docs/research/2026-08-07-s4-pseudocode.md @@ -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 */ # 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** | diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 97865f43..d36485a6 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -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 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. /// - 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)) { diff --git a/tests/AcDream.Core.Tests/Physics/S4AdjustOffsetConformanceTests.cs b/tests/AcDream.Core.Tests/Physics/S4AdjustOffsetConformanceTests.cs new file mode 100644 index 00000000..e4ba4ec1 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/S4AdjustOffsetConformanceTests.cs @@ -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; + +/// +/// Campaign S slice S4 (2026-08-07) conformance suite for +/// Transition.AdjustOffset's two register-row substitutions: +/// +/// +/// AD-65 — the away-from-plane arm (collisionAngle > 0) must +/// SNAP to the contact plane (retail's Plane::snap_to_plane +/// @0x00509c50: X/Y preserved, Z re-solved, epsilon no-op), not run the +/// orthogonal-projection subtraction used by the into-plane arm. +/// AD-66 — the safety push-out's trigger comparison and zDist +/// numerator must use the BARE global_sphere->radius, not +/// radius * ContactPlane.Normal.Z. +/// +/// +/// See docs/research/2026-08-07-s4-adjustoffset-contract.md (the +/// pinned contract) and docs/research/2026-08-07-s4-pseudocode.md +/// (the full branch-tree derivation, including the resolved Binary Ninja +/// flag-idiom ambiguity for the two epsilon-relative comparisons this suite +/// exercises). +/// +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. + // ========================================================================= + + /// + /// Constructs a sphere position whose signed plane distance sits strictly + /// BETWEEN the old (buggy) radius*N.z threshold and the new + /// (retail-faithful) bare-radius threshold. This single fixture + /// discriminates BOTH AD-66 sub-fixes at once: + /// + /// the OLD trigger (dist < radius*N.z - EPSILON) would NOT + /// have fired here at all (0.47 is not less than 0.4328); + /// the NEW trigger (dist < radius - EPSILON) DOES fire + /// (0.47 < 0.4998), and the pushed amount must equal the bare-radius + /// zDist formula, not the radius*N.z one. + /// + /// + [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. + // ========================================================================= + + /// + /// 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. + /// Sabotage record (SAB-S4-1, 2026-08-07, verified twice — +/// implementer and reviewer independently): re-instating +/// result -= N * collisionAngle in the away arm reds +/// AdjustOffset_AwayFromPlane_SnapsPreservingXYAndResolvingZ 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). +/// +/// What the rest of the suite does NOT discriminate: 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. +/// +/// + [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 + { + [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(), Array.Empty(), + worldOffsetX: 0f, worldOffsetY: 0f); + + engine.DataCache.RegisterCellStructForTest(cellId, cell); + return (engine, root); + } +}