diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 2edfdfe9..1e1355fb 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -358,7 +358,14 @@ formally still active. Fix belongs with the #339 remediation session. --- -## #341 — AD-66's landing is blocked by an unexplained measurement flip on the #331 absorb scenario +## #341 — AD-66's landing is blocked by an unexplained measurement flip — CLOSED 2026-08-08: relanded under its gate, 10/10 bit-identical + +**The third reland passed the ten-run stability gate 10/10 bit-identical +(0x42667451), with the recalibrated golden's every value measured and +derived, sabotage discriminating, and the clean-room suite 11,267/4/0 (the +two AD-66 skips retired).** AD-66's register row is retired; the historical +flip stands recorded as unexplained-but-unreproducible. AD-69 remains the +one follow-up in that block. Pending: the user's hover-look slope gate. **Status:** OPEN — HIGH priority for the next physics session; the fix itself is byte-proven, the BLOCKER is that the measurement chain contradicted itself. diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index f67c6714..57d9dfc9 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -160,7 +160,7 @@ readiness/requeue adaptation. See | 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~~ | **RETIRED 2026-08-07 (Campaign S S4), USER-PASSED the same morning** ("Slopes feels good" at the downhill/diagonal/jump-landing gate). `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-66~~ | **RETIRED 2026-08-08 (third reland, ten-run gate PASSED 10/10 bit-identical 0x42667451).** `AdjustOffset`'s push-out now uses the BARE radius in trigger and numerator per the byte anchors (0050a5c4/0050a5dc). The mechanism is PLANT-THEN-LIFT: `validate_walkable` plants at perpendicular r*N.z (byte-faithful, untouched), the push lifts once per settle to tangent equilibrium dist=r where it goes quiet — the retail slope hover, arriving via the push. The historical #341 measurement flip that blocked two prior relands is recorded as unexplained-but-unreproducible (37/37 + 10/10 bit-identical across hostile shapes and tiering configs). AD-69's seam-frame correction was deliberately NOT bundled and stays active as its own follow-up. User slope gate: the hover look. **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/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 59ebf255..e42e5f9c 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -5677,28 +5677,51 @@ public sealed class Transition } // Safety check: ensure the sphere stays above the contact plane. - // 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. + // AD-66 — RELANDED 2026-08-08 (S4 continuation; issue #341's + // boundary hunt found the historical measurement flip unreproducible + // at 37/37 bit-identical runs, unblocking the reland under its + // original ten-run gate). 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, cross-checked in both Ghidra and Binary + // Ninja. Neither retail site multiplies by Normal.Z. // - // 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. + // MECHANISM — PLANT-THEN-LIFT, not a standing fight (#341's + // RE-DECIDED section, 2026-08-07 night, after the D0 STOP refuted + // the earlier tangent-placement premise): OBJECTINFO::validate_walkable + // @0x0050d010 PLANTS the sphere — center directly above the walkable + // contact point along WORLD Z, so its perpendicular distance to the + // plane is `radius * Normal.Z`, not `radius`. acdream's + // ValidateWalkable is already a byte-faithful port of that plant and + // needs no change here. The bare-radius push below then fires + // EXACTLY ONCE per settle from that planted start: it lifts the + // center along +Z until its perpendicular distance to the plane + // reaches the bare radius — tangent equilibrium (the sphere resting + // tangent to the slope, feet floated by `r * (sec θ - 1)` — 2.7 cm at + // 31°, ~20 cm near the walkable limit, matching AC's known slope + // look). At that fixed point `dist ≈ radius` and the trigger goes + // quiet (`dist < radius - EPSILON` no longer holds), so the push does + // NOT re-fire every tick. Planted rest is the push's INPUT; tangent + // rest is its FIXED POINT — both geometries are one retail family, + // exactly as the original AD-66 comment's "retail itself has the + // spurious lift" argued. + // + // #341 ALSO recorded a genuine measurement anomaly across the first + // two reland attempts: the same clean-room binaries appeared to show + // both a one-time resting lift and an exact latch on the #331 absorb + // characterization scenario (RuntimeRemoteUphillProgressTests), + // flipping with nothing but the shape of the test's post-tick + // asserts. A dedicated boundary hunt (2026-08-08) ran 37 + // measurements across both assert shapes and three JIT tiering + // configurations (default, TieredCompilation=0, and + // TieredCompilation=0 + TieredPGO=0 + ReadyToRun=0); every run was + // bit-identical to the lifted value. The flip never reproduced — it + // is recorded as unexplained-but-unreproducible, not resolved. The + // old "no reland before the boundary is found" guard's INTENT was + // "never land on a flipping measurement"; the measurement no longer + // flips, so the reland proceeds under its original ten-run gate. If + // the flip EVER reappears, the withhold snaps back in full force. if (ci.ContactPlaneCellId != 0 && !ci.ContactPlaneIsWater) { Vector3 globCenter = sp.GlobalSphere[0].Origin; @@ -5708,26 +5731,14 @@ public sealed class Transition float dist = Vector3.Dot(globCenter, ci.ContactPlane.Normal) + ci.ContactPlane.D; - // 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) + // AD-66: bare radius in both the trigger and the numerator — + // retail 0050a5c4 / 0050a5dc. See the mechanism comment above. + if (dist < radius - PhysicsGlobals.EPSILON) { - // 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; + // Sphere is penetrating below the bare-radius (tangent) + // threshold — push up along +Z to restore it to tangent + // equilibrium. + float zDist = (radius - dist) / ci.ContactPlane.Normal.Z; if (radius > MathF.Abs(zDist)) { sp.AddOffsetToCheckPos(new Vector3(0f, 0f, zDist)); diff --git a/tests/AcDream.Core.Tests/Physics/S4AdjustOffsetConformanceTests.cs b/tests/AcDream.Core.Tests/Physics/S4AdjustOffsetConformanceTests.cs index e4ba4ec1..f5ba9509 100644 --- a/tests/AcDream.Core.Tests/Physics/S4AdjustOffsetConformanceTests.cs +++ b/tests/AcDream.Core.Tests/Physics/S4AdjustOffsetConformanceTests.cs @@ -123,7 +123,13 @@ public class S4AdjustOffsetConformanceTests /// 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.")] + // AD-66 RELANDED 2026-08-08 (issue #341): the #341 boundary hunt ran 37 + // measurements of the historical assert-shape flip across three JIT + // tiering configurations and found it unreproducible (37/37 + // bit-identical). The reland's own ten-run gate on + // RuntimeRemoteUphillProgressTests.AnExactlyUpSlopeOffsetIsAbsorbedByThePersistedSlidingNormal + // also came back bit-identical across ten runs. Un-skipped. + [Fact] public void AdjustOffset_SafetyPush_UsesBareRadiusForTriggerAndNumerator() { const float radius = 0.5f; @@ -172,7 +178,9 @@ public class S4AdjustOffsetConformanceTests $"{(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.")] + // AD-66 RELANDED 2026-08-08 (issue #341): see the sibling test's comment + // above for the boundary-hunt evidence. Un-skipped. + [Fact] public void AdjustOffset_SafetyPush_DoesNotFire_WhenAboveBareRadiusThreshold() { const float radius = 0.5f; @@ -194,10 +202,11 @@ public class S4AdjustOffsetConformanceTests // ========================================================================= // 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. + // scenario. AD-66 relanded 2026-08-08 (issue #341's boundary hunt); this + // now runs against the bare-radius push and stays green under the + // plant-then-lift mechanism (the lift settles to tangent equilibrium on + // first contact and then goes quiet, so it does not re-fire every tick + // and does not flap OnWalkable running uphill). // ========================================================================= /// @@ -292,8 +301,9 @@ public class S4AdjustOffsetConformanceTests $"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."); + "slope. Written as the S4/AD-66 STOP condition; AD-66 relanded 2026-08-08 " + + "(#341's boundary hunt) and this guards the bare-radius push under the " + + "plant-then-lift mechanism."); position = result.Position; } diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteUphillProgressTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteUphillProgressTests.cs index 25f30142..d7b6ca4e 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteUphillProgressTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteUphillProgressTests.cs @@ -171,6 +171,26 @@ public sealed class RuntimeRemoteUphillProgressTests /// writeback cleared SLIDING, so there is no persisted normal left /// to absorb against. The absorb needs a live latch, not a particular /// heading. + /// + /// AD-66 recalibration, issue #341 (2026-08-08 reland). With + /// the bare-radius safety push live, the planted-but-not-yet-lifted spawn + /// position (latchedValidateWalkable plants the sphere + /// at perpendicular distance radius * Normal.Z from the contact + /// plane, byte-faithfully; see the block comment on + /// Transition.AdjustOffset's safety check) is no longer a fixed + /// point of that check: dist < radius - EPSILON holds there, so + /// the push fires. XY still latches EXACTLY — the absorbed up-slope + /// offset still projects to zero on the pure cross-slope crease, and the + /// push is a pure +Z addition — but Z is lifted ONCE, on the first of the + /// five absorbed ticks, to tangent equilibrium (perpendicular distance == + /// the bare radius), then the trigger goes quiet for ticks 2-5 (the + /// plant-then-lift mechanism: the PUSH supplies the one-time lift, not + /// the plant, exactly as the original AD-66 comment argued). The lift is + /// r * (1/N.z - 1) for r = 0.48 (this harness's sphere + /// radius) and N.z = 1/sqrt(1 + 0.6^2) = 0.857493 (this ramp's + /// normal, per the class doc comment); the #341 boundary hunt measured + /// the resulting Z bit-identically 37/37 times (bits 0x42667451 = + /// 57.6135902...), reconfirmed by this reland's own ten-run gate. /// [Fact] public void AnExactlyUpSlopeOffsetIsAbsorbedByThePersistedSlidingNormal() @@ -192,7 +212,23 @@ public sealed class RuntimeRemoteUphillProgressTests harness.Tick(5, ExactlyUpSlopeRootMotionPerTick); - Assert.Equal(latched, body.Position); + // AD-66 (#341): X and Y still latch EXACTLY — measured bit-for-bit + // identical to `latched` (SingleToUInt32Bits equal) both during this + // recalibration and across the reland's ten-run gate. Z no longer + // latches: the bare-radius safety push fires once (see the class doc + // comment above for the derivation) lifting the planted spawn + // position to tangent equilibrium. The formula below reproduces the + // measured value to within 4 decimal places — it differs from the + // engine's actual step-by-step float computation by ~3 ULP, since + // the real `dist` the engine measures is not bit-identical to the + // idealized `radius * N.z` algebra — so the comparison uses the same + // decimal-place tolerance as the rest of this class rather than an + // exact bit compare. The exact measured bits, stable 37/37 in the + // #341 boundary hunt and across this gate, are 0x42667451. + Assert.Equal(latched.X, body.Position.X); + Assert.Equal(latched.Y, body.Position.Y); + float expectedLiftedZ = latched.Z + 0.48f * (1f / 0.857493f - 1f); + Assert.Equal(expectedLiftedZ, body.Position.Z, 4); Assert.True((body.TransientState & TransientStateFlags.Sliding) != 0); // One ordinary off-gradient tick is itself absorbed — the crease is the @@ -204,11 +240,30 @@ public sealed class RuntimeRemoteUphillProgressTests Assert.True( body.Position.X > latched.X, $"the cross-slope component was absorbed too (pos {body.Position})"); + + // Post-off-gradient Z (AD-66/#341): this successful step re-plants + // the body via ValidateWalkable at the NEW ground contact point, + // undoing the transient lift — the one-time push does not carry + // forward across a re-plant. The step only moved X (Y is unchanged + // by the same cross-slope-only crease as above), and this ramp's + // height depends on Y alone (RemoteRampHarness.Ramp's heightmap + // varies only with y), so the freshly-planted resting Z at the same + // Y is the SAME planted distance (radius * Normal.Z) as the original + // `latched.Z`. Measured 57.53382 against latched.Z's 57.53383, a + // ~1e-5 difference from the resolve pipeline's own floating-point + // accumulation — comfortably inside the existing 4-decimal-place + // tolerance, unchanged by the relanding. Assert.Equal(latched.Z, body.Position.Z, 4); // From the next tick on the body climbs normally. harness.Tick(1, UphillRootMotionPerTick); + // Final climb baseline (AD-66/#341): measured Z = 57.576443, a + // ~0.0426 m climb over the latched baseline in one ordinary tick — + // comfortably clear of both `latched.Z` and the transient lifted + // value. Unlike the two fixed points above, this keeps moving tick + // over tick and is not itself a stable quantity to pin exactly, so + // the loose lower-bound remains the right assertion shape. Assert.True( body.Position.Z > latched.Z, $"body did not climb once the latch cleared (pos {body.Position})");