fix(physics): AP-7 - port calc_friction's retail 0.25f threshold; retire AP-7, file AD-55
Campaign P Slice P2 step 3 (docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §1, §6 Step 5). The named retail decomp (CPhysicsObj::calc_friction, pseudo-C:276694-276822, 0050ee70) independently re-confirms the 0.25f threshold (derived twice, once per BN-rendered branch); the in-code claim that "the decompile uses 0.0" traced to the older, unnamed FUN_0050f940 Ghidra chunk at a different address -- per CLAUDE.md the named decomp wins. calc_friction now reads angle = dot(Velocity, GroundNormal); if (angle >= 0.25f) return; then unconditionally removes the normal-aligned velocity component, then applies the existing (already-present but previously unreachable) PhysicsState.Sledding-gated friction overrides. The BN-rendered "two duplicated branches" around the state check is adopted as a single linear function matching ACE's PhysicsObj.calc_friction shape -- the branch split is most likely a BN decompiler artifact around one `if (state & SLEDDING_PS)` block (ACE-derived, Ghidra-verify; low implementation risk either way since ACE's reading is adopted regardless). Why this doesn't repeat the reverted 2026-04-30 L.3c regression (naive 0.0 -> 0.25f bump dropped forward locomotion 3 -> 0.16 m/s): that test predates the 2026-07-17 R6 "local player animation-owned grounded movement" landing. PlayerMovementController (Runtime/Gameplay, out of this slice's scope) zeroes Velocity.X/Y to exactly zero every tick before calc_friction runs whenever animation root motion drives the walk, so friction has nothing horizontal left to hammer on the production graphical local-player path. Pinned at the PhysicsBody level (the only file this slice may touch) by GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests. The headless/get_state_velocity path and remote/NPC movers still feed real velocity into this function and remain the ones to watch if a similar regression resurfaces there -- flagged in the retired AP-7 row for future sessions working in Runtime/Gameplay. Left an open, explicitly-flagged discrepancy: the raw decomp's Sledding slope-flatness test computes cos(10 deg) (~0.984808) while ACE's port (and acdream's prior dead code) compares GroundNormal.Z > 0.99999536f (~0.175 deg from flat) -- physically different tests, neither confirmed this pass (Ghidra MCP down). Kept 0.99999536f provisionally (least churn) and filed AD-55 for just that constant rather than silently picking one. Register: AP-7 retired with a corrected citation; AD-55 filed for the cos(10 deg) question. Core.Tests: 3916 passed, 2 skipped (both pre-existing and unrelated), 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
325fee7cbb
commit
4f7e29f7cf
3 changed files with 174 additions and 30 deletions
|
|
@ -62,12 +62,13 @@ accepted-divergence entries (#96, #49, #50).
|
|||
|
||||
---
|
||||
|
||||
## 2. Adaptation (AD) — 46 rows (AD-53/AD-54 filed 2026-07-30 at Campaign P Slice P2, split out of the retired TS-1 row — CliffSlide's three-source reference-normal fallback chain and the walkable-steepness reroute to CliffSlide; AD-52 filed 2026-07-29 at Campaign N slice N6 — the fragment-assembler 60 s partial TTL + completed-sequence ring; AD-51 filed 2026-07-29 at Campaign N slice N4 — the reclaimed-word pool for ACE's fresh-sequence cleartext RejectRetransmit; AD-50 filed 2026-07-29 at Campaign N slice N2 — the inbound-watermark ACE init; AD-49 stays reserved for Campaign N §5's blob-layer ordering deferral, filed when its slice lands; AD-47 and AD-48 filed 2026-07-29 at Campaign V slice V11 — the MSAA sample-position and present-pacing rows the campaign's risk register scheduled for the GL deletion; AD-11 retired 2026-07-23 — exact low-bit ItemUses predicate; AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover)
|
||||
## 2. Adaptation (AD) — 47 rows (AD-55 filed 2026-07-30 at Campaign P Slice P2, split out of the retired AP-7 row — the open cos(10°)-vs-0.99999536f Sledding slope-flatness constant; AD-53/AD-54 filed the same slice, split out of the retired TS-1 row — CliffSlide's three-source reference-normal fallback chain and the walkable-steepness reroute to CliffSlide; AD-52 filed 2026-07-29 at Campaign N slice N6 — the fragment-assembler 60 s partial TTL + completed-sequence ring; AD-51 filed 2026-07-29 at Campaign N slice N4 — the reclaimed-word pool for ACE's fresh-sequence cleartext RejectRetransmit; AD-50 filed 2026-07-29 at Campaign N slice N2 — the inbound-watermark ACE init; AD-49 stays reserved for Campaign N §5's blob-layer ordering deferral, filed when its slice lands; AD-47 and AD-48 filed 2026-07-29 at Campaign V slice V11 — the MSAA sample-position and present-pacing rows the campaign's risk register scheduled for the GL deletion; AD-11 retired 2026-07-23 — exact low-bit ItemUses predicate; AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover)
|
||||
|
||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||
|---|---|---|---|---|---|
|
||||
| AD-53 | `Transition.CliffSlide`'s reference-normal cross-product operand tries THREE sources in priority order (`LastWalkablePlane` if `Normal.Z >= FloorZ`, then `LastKnownContactPlane` at the same threshold, then world-up `UnitZ`); retail's `CTransition::cliff_slide` uses `this->collision_info.last_known_contact_plane.N` directly, with no fallback chain | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CliffSlide`, the `referenceNormal`/`refSource` selection above the cross-product) | Filed 2026-07-30 splitting TS-1's retirement (Campaign P Slice P2). A fresh read of `last_known_contact_plane`'s own maintenance (pc:272659-272668) confirms retail overwrites it unconditionally from `contact_plane` every `validate_transition` pass — including with a steep plane — so retail keeps NO separately-preserved flat-ground history there either; this fallback chain is a genuine acdream invention, not a retail-matching read. Kept because it compensates for AP-4's incomplete `OnWalkable` bookkeeping (L.4-cliffslide-fallback, 2026-04-30): without it, `cross(currentSteep, lastKnownSteep)` degenerates to zero after >1 frame on a continuous steep slope, and CliffSlide returns `OK` (no deflection) instead of downhill drift — the "stay on the roof" wedge the L.4 session fought | If AP-4's `OnWalkable` reordering is ever completed/removed, `last_known_contact_plane` should carry the same information retail's does and this fallback chain becomes unneeded ballast (or, worse, silently picks a stale `LastWalkablePlane` over the now-correct current one) — re-audit together with AP-4 | `CTransition::cliff_slide` pc:272397 (0050a6d0); `last_known_contact_plane` maintenance pc:272659-272668 (~0050ad07); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §2 gap #2 |
|
||||
| AD-54 | `Transition.EdgeSlideAfterStepDownFailed` reroutes to `CliffSlide` instead of `PrecipiceSlide` when the stored walkable polygon itself is steeper than `FloorZ` (`sp.WalkablePlane.Normal.Z < PhysicsGlobals.FloorZ`); retail's raw `SPHEREPATH::edge_slide` has no steepness branch here — `if (walkable != null) { ... precipice_slide(...) }` unconditionally | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`, the `L.4-walkable-steep` block) | Filed 2026-07-30 splitting TS-1's retirement (Campaign P Slice P2). The permissive `LandingZ` walkable-acceptance threshold that lets a steep roof become "walkable" in the first place IS confirmed retail-faithful (`BSPTREE::find_collisions` unconditional `walkable_allowance = LandingZ`, pc:323740-323783, TS-4's own citation) — so a steep-roof walkable polygon is a real state retail also reaches. What is NOT independently verified is whether retail's outer `transitional_insert` caller absorbs a same-polygon-standing `COLLIDED_TS` from `precipice_slide` (its raw `find_crossed_edge` returning false while standing on, not crossing, the polygon) some other way that avoids the acdream "stuck in a Collided revert loop" this reroute prevents | If retail's outer retry loop turns out to already handle the no-crossed-edge-while-standing-on-a-steep-poly case without a reroute, this branch is an unnecessary compensating layer that could route a genuinely PrecipiceSlide-bound case (a shallow polygon edge that happens to sit at exactly `FloorZ`) into CliffSlide instead | `SPHEREPATH::edge_slide` pc:273001-273090 (0050b3d0, direct walkable branch quoted at pc:364-370 in the P2 research doc); `BSPTREE::find_collisions` pc:323740-323783 (0053a730, unconditional `LandingZ`); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §2 gap #3 |
|
||||
| AD-55 | `calc_friction`'s Sledding slope-flatness test compares `GroundNormal.Z > 0.99999536f` (≈0.175° from flat); the raw retail decomp literally computes `__fcos(0.17453292519943295)` (= cos(10°) ≈ 0.984808) and compares that against `contact_plane.N.z` — physically very different tests (0.175° accepts only essentially-perfectly-flat ground; 10° accepts any modest slope) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`, the Sledding near-flat branch) | Filed 2026-07-30 splitting AP-7's retirement (Campaign P Slice P2). Two hypotheses, neither confirmed this pass: (a) BN misdecompiled a raw float-constant load as an `__fcos()` call (a known BN artifact class), or (b) ACE's own port made an independent error and cos(10°) is correct. `0.99999536f` is kept provisionally — least churn, since it is what acdream's own prior (structurally unreachable) dead code already had — pending a live Ghidra decompile of `0050ee70` checking whether the FCOS opcode is real or a raw `FLD` of one of these two constants | Currently harmless in production: nothing sets `PhysicsState.Sledding` client-side (see #166 research), so this branch is unreachable either way. The moment a data-authored Sledding toggle exists, the wrong constant changes which slopes get the light 0.2f sled-friction override vs. the heavier default | `CPhysicsObj::calc_friction` pc:276694-276822 (0050ee70), the `__fcos(0.17453292519943295)` slope-flatness comparison; ACE `PhysicsObj.calc_friction` PhysicsObj.cs:2120-2141 (`0.99999536f`); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §1, §7 item 3 |
|
||||
| AD-46 | **LIVE. Reframed at Campaign V slice V11 (2026-07-29), when GL was deleted and the comparison that discovered this row ceased to exist.** Dense alpha-blended distant scenery (the treeline) may read slightly denser than retail's, because the anisotropic TAP PATTERN is implementation-defined and acdream's Vulkan driver does not tap identically to retail's D3D9 one. Both request the same sampler state — trilinear, clamp-and-repeat, the device's maximum anisotropy. **What changed at V11 is only the left-hand side of the comparison**: this was measured GL-vs-Vulkan (~15% of the pixels in the band), and it is now a Vulkan-vs-retail question against the D3D oracle in the last column. The measurement below is retained as the evidence that the residual is a tap pattern and not a bug, even though one of its two arms no longer exists. | `src/AcDream.App/Rendering/Wb/WorldTextureArray.cs` (`RhiWorldTextureArray.WorldArrayAnisotropy`); measured in plan §5.5.19, reframed §5.5.24 | Not assumed — narrowed by measurement while both backends still existed, on an offline capture with no session, no entities and both clocks pinned. Anisotropy 1 → 41,509 differing pixels in the tree band; anisotropy 16 (GL's value, and retail's `m_D3DCaps.MaxAnisotropy`) → 22,266, and the rest of the frame fell to 497 px of 563,200, i.e. 8.8e-04, inside the campaign's 0.001 threshold. The residual was not a sub-pixel shift (an integer shift search found none), not a sharpness change (high-frequency energy matched within 5%), and not depth precision (forcing Vulkan's window-depth range to GL's compressed [0.5, 1] moved it by 3%). Monotone improvement toward GL's own anisotropy with no knob left is what made it a driver property rather than a bug. | Distant foliage shimmers or reads denser than retail's. The class is confined to alpha-blended dense overlap: opaque terrain, roofs, walls, water, statics, the character and the whole retained UI are inside threshold. **Now unfalsifiable by self-differential** — with GL gone, the only way to retire this row is a side-by-side against the retail client, not against another acdream backend. | `RenderDeviceD3D::SetDefaultD3DStates @ 0x005a3800`, whose `SetSamplerState(stage, 0xA /* D3DSAMP_MAXANISOTROPY */, m_D3DCaps.MaxAnisotropy)` at `0x005a4230` is the value acdream requests |
|
||||
| AD-47 | **Filed at Campaign V slice V11 (2026-07-29); the campaign's risk register scheduled this row here.** Multisample resolve sample POSITIONS are unspecified by both the Vulkan and D3D9 specifications, so acdream's MSAA-on silhouette edges do not match retail's pixel-for-pixel even at the same sample count. acdream's strict pixel gates therefore run with MSAA forced OFF on every arm, and MSAA-on gets only a relaxed visual smoke. | `src/AcDream.App/RuntimeOptions.cs` (`ACDREAM_MSAA_SAMPLES`); forced to 0 in `tools/run-offline-pixel-gate.ps1` | Measured, not assumed: plan §5.5.16 compared two backends at 4x and found **8.83% of the frame differing — 81,359 px of 921,600 — essentially all of it hugging foliage and silhouette edges**, which is ninety-fold over the 0.001 gate threshold. That is two implementations' sample patterns, not a renderer divergence, which is why forcing MSAA off is what makes the remaining difference attributable rather than a threshold relaxation. | Edge quality on thin geometry (fence rails, foliage, distant railings) differs from retail at the sub-pixel level whenever MSAA is on, which is the ordinary player configuration. Because the gates run MSAA off, **a real regression confined to the multisample path would not be caught by them** — that is the actual exposure this row records. | D3D9 `D3DRS_MULTISAMPLEANTIALIAS` / `D3DMULTISAMPLE_TYPE` as set by `RenderDeviceD3D::SetDefaultD3DStates @ 0x005a3800`; retail's sample pattern is the driver's, exactly as ours is |
|
||||
| AD-48 | **Filed at Campaign V slice V11 (2026-07-29).** Presentation is paced by the Vulkan swapchain present mode (FIFO, i.e. VSync) or by a refresh-rate software pacer when uncapped, rather than by retail's D3D9 `Present` with its own frame-rate limiter. Frame delivery cadence, and therefore input-to-photon latency, is a property of our present path rather than a port of retail's. | `src/AcDream.App/RuntimeOptions.cs:98-100`; `src/AcDream.App/Rendering/Gpu/Vk/VulkanSwapchain.cs` | Retail's limiter and ours both bound the frame rate to the display; the simulation is fixed-step and clock-driven, so gameplay timing does not ride on presentation cadence. The uncapped path exists for measurement and is not the shipping default. | A pacing mismatch shows up as judder or input latency that differs from retail's feel without any visual difference in a captured frame — invisible to every pixel gate by construction. Issue **#235** (the capped/RDP jump-presentation cadence alias) is the known live instance of this class. | D3D9 `IDirect3DDevice9::Present`; retail's frame limiter in `RenderDeviceD3D` |
|
||||
|
|
@ -116,7 +117,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
|
||||
---
|
||||
|
||||
## 3. Documented approximation (AP) — 93 active rows
|
||||
## 3. Documented approximation (AP) — 92 active rows (AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55)
|
||||
|
||||
Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84
|
||||
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
|
||||
|
|
@ -129,7 +130,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| AP-3 | Step-down chain triggered only when contact is invalid OR steeper than walkable; retail's `transitional_insert` OK-path ALWAYS runs it | `src/AcDream.Core/Physics/TransitionTypes.cs:1197` | Conditional preserves the observed-to-matter cases (edge departure, steep cliff-slide) without running the chain every step (per pc:273191 agent reports) | Steps where retail runs step-down despite a valid walkable contact (bump maintenance, edge-slide arming) are skipped — float-off or missed edge slides in untested geometry | `transitional_insert` OK-path pc:273191 |
|
||||
| AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` → restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK — body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) |
|
||||
| AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 |
|
||||
| AP-7 | `calc_friction` threshold 0.0 with retail's state gate missing; retail uses 0.25 gated by an undecoded state check | `src/AcDream.Core/Physics/PhysicsBody.cs:307` | Bumping the threshold without the gate hammered normal walking (3 → 0.16 m/s); as-read 0.0 kept; locomotion probably state-exempted in retail. Filed L.3c-followup | Friction engages under different conditions — post-landing slides, knockback decay, sledding speeds mismatch retail's deceleration | pc:276702-276705 (state gate + 0.25) |
|
||||
| ~~AP-7~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the "state gate" was a BN decompiler artifact, not a locomotion exemption.** `calc_friction` now ports retail's confirmed 0.25f threshold (`if (angle >= 0.25f) return;`) unconditionally, no special-cased gate. The "state check at pc:276702" the old row cited is `PhysicsState.Sledding` (confirmed via ACE's `PhysicsObj.calc_friction`, references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2120-2141, and `SLEDDING_PS=0x800000` in acclient.h:2838) — it gates the 1.5625/6.25/near-flat friction-value OVERRIDE, not the threshold return itself; acdream had no live Sledding setter then or now (see #166 research, docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §3), so the branch was simply unreachable dead code, not an exemption for ordinary walking. The reverted 2026-04-30 L.3c attempt (naive 0.0→0.25 bump, forward locomotion 3→0.16 m/s in `PlayerMovementControllerTests`) does not reproduce on the production graphical local-player path post-R6: `PlayerMovementController` zeroes `Velocity.X/Y` to exactly zero every tick before `calc_friction` runs whenever animation root motion drives the walk, so friction has no horizontal velocity left to hammer (pinned at the PhysicsBody level by `GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests`). The headless/`get_state_velocity` movement-controller path and remote/NPC movers still feed real velocity into this function and remain the ones to watch if a similar regression resurfaces there. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`); `tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs` (AP-7 test block) | — | — | `CPhysicsObj::calc_friction` pc:276694-276822 (0050ee70); ACE `PhysicsObj.calc_friction` PhysicsObj.cs:2120-2141; `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §1 |
|
||||
| AP-10 | Dry-corner water depth: retail's 0.1 m allowed sink-in collapsed to 0 | `src/AcDream.Core/Physics/TerrainSurface.cs:481` | The 0.1 offset destabilizes the feet-exactly-on-plane contact-touch check (dist > EPSILON → SetContactPlane never fires → float/fall); retail's ~10 cm sink-in is visually indistinguishable | Masks a contact-touch epsilon fragility — other water-depth values exercising the same instability could oscillate shoreline walkable validation; retail's wet/dry corner sink-in visual absent | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port) |
|
||||
| AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80–350 m) when the Region dat isn't loaded yet | `src/AcDream.Core/World/SkyState.cs:167` | A renderable sky is needed during boot before the Region dat parses; safety net on region-load failure | Any window where the fallback is active shows sky/fog lighting only roughly resembling retail's dat-driven values | SkyTimeOfDay keyframes, Region dat 0x13000000 |
|
||||
| AP-12 | Enchantment family-stacking tiebreak by largest SpellId; retail picks highest Generation, tie-broken by latest cast | `src/AcDream.Core/Spells/EnchantmentMath.cs:89` | `ActiveEnchantmentRecord` doesn't carry Generation; SpellId correlates with generation level in practice | Where spell ids don't track power within a family (or same-generation re-cast), the wrong buff wins — vital-max / stat values diverge from retail | `CEnchantmentRegistry::EnchantAttribute` 0x00594570 (pc:416110) |
|
||||
|
|
|
|||
|
|
@ -12,9 +12,12 @@ namespace AcDream.Core.Physics;
|
|||
// FUN_00511ec0 set_velocity — store + clamp to MaxVelocity
|
||||
// FUN_00511fa0 set_local_velocity — body→world transform then set_velocity
|
||||
// FUN_00511de0 set_on_walkable — set/clear OnWalkable transient flag
|
||||
// FUN_0050f940 calc_friction — ground-contact friction
|
||||
// FUN_00515020 update_object — per-frame top-level driver
|
||||
//
|
||||
// calc_friction is now cited against the NAMED retail decomp instead of the
|
||||
// older unnamed FUN_0050f940 chunk — see its own doc comment below
|
||||
// (CPhysicsObj::calc_friction, acclient_2013_pseudo_c.txt:276694, 0050ee70).
|
||||
//
|
||||
// Cross-checked against ACE PhysicsObj.cs and PhysicsGlobals.cs.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -542,48 +545,87 @@ public sealed class PhysicsBody
|
|||
calc_acceleration();
|
||||
}
|
||||
|
||||
// ── FUN_0050f940 ───────────────────────────────────────────────────────
|
||||
// ── CPhysicsObj::calc_friction (0050ee70) ───────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Apply friction deceleration to the velocity when the body is standing
|
||||
/// on a walkable surface.
|
||||
///
|
||||
/// Decompiled logic (FUN_0050f940):
|
||||
/// AP-7 resolved (Campaign P Slice P2, 2026-07-30,
|
||||
/// docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §1).
|
||||
/// The named retail decomp (<c>CPhysicsObj::calc_friction</c>,
|
||||
/// pseudo-C:276694-276822, 0050ee70) independently re-confirms the
|
||||
/// <b>0.25f</b> threshold (derived twice, once per BN-rendered branch) —
|
||||
/// the OLD in-code claim that "the decompile uses 0.0" traced to the
|
||||
/// unnamed, superseded <c>FUN_0050f940</c> Ghidra chunk at a DIFFERENT
|
||||
/// address; per CLAUDE.md the named decomp wins. Cross-checked against
|
||||
/// ACE <c>PhysicsObj.calc_friction</c>
|
||||
/// (references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2120-2141),
|
||||
/// which reads as ONE linear function rather than the BN-rendered "two
|
||||
/// duplicated branches" — the branch split is most likely a BN decompiler
|
||||
/// artifact around a single <c>if (state & SLEDDING_PS)</c> block
|
||||
/// (ACE-derived, Ghidra-verify: see the research doc §7 items 1-2 for the
|
||||
/// still-open BN-artifact-vs-genuine-duplication question; low
|
||||
/// implementation risk either way since ACE's single-linear-function
|
||||
/// reading is adopted regardless).
|
||||
///
|
||||
/// Decompiled logic (retail, ACE-derived shape):
|
||||
/// if NOT OnWalkable → return
|
||||
/// fVar1 = dot(groundNormal, velocity)
|
||||
/// if fVar1 < 0:
|
||||
/// velocity -= fVar1 * groundNormal (remove inward normal component)
|
||||
/// scalar = pow(1 - friction, dt)
|
||||
/// velocity *= scalar
|
||||
/// angle = dot(velocity, contactPlane.N)
|
||||
/// if angle >= 0.25f → return (moving away fast enough — no friction)
|
||||
/// velocity -= angle * contactPlane.N (remove inward normal component, unconditional)
|
||||
/// friction = this->friction (same baseline in every case)
|
||||
/// if Sledding: velocityMag2-banded override (see below)
|
||||
/// velocity *= pow(1 - friction, dt)
|
||||
///
|
||||
/// The threshold (0.0 from _DAT_007c78a0) means any velocity with a
|
||||
/// downward component relative to the normal gets friction applied.
|
||||
/// Positive dot means moving away from the surface — no friction.
|
||||
/// L.3c attempt (2026-04-30, REVERTED): a bare 0.25f bump with no other
|
||||
/// change dropped measured forward locomotion from ~3 m/s to ~0.16 m/s
|
||||
/// in PlayerMovementControllerTests — friction engaged EVERY tick because
|
||||
/// flat-ground walking has dot(velocity, groundNormal) ≈ 0, which is
|
||||
/// < 0.25f. The research pass's math check: DefaultFriction=0.95f (both
|
||||
/// PhysicsBody.cs:120 and ACE PhysicsGlobals.cs:15 agree — not a divergent
|
||||
/// constant), so pow(0.05, dt) at 60 Hz over 1s ≈ 0.951^60 ≈ 4.9% velocity
|
||||
/// remaining — matches the observed hammering almost exactly.
|
||||
///
|
||||
/// Cross-checked with ACE PhysicsObj.calc_friction which uses 0.25f as
|
||||
/// the threshold instead; the decompile uses 0.0. We match the decompile.
|
||||
/// Why this is safe to land now: the L.3c test predates the 2026-07-17
|
||||
/// "local player animation-owned grounded movement" landing (R6).
|
||||
/// PlayerMovementController.cs (~line 1742) zeroes Velocity.X/Y to 0
|
||||
/// immediately before UpdatePhysicsInternal runs whenever animation root
|
||||
/// motion drives the walk (the production graphical local-player path
|
||||
/// since R6) — walking displacement comes from the animation Frame delta
|
||||
/// applied directly to Position, not from integrating Velocity. Friction
|
||||
/// decaying an already-zero horizontal Velocity is a no-op, so the L.3c
|
||||
/// mechanism does not reproduce on that path. The `else` branch (no
|
||||
/// animation root motion — headless/test-controller movers using
|
||||
/// `get_state_velocity`, and remote/NPC movers) DOES still feed real XY
|
||||
/// speed into Velocity and remains exposed; see
|
||||
/// GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests for
|
||||
/// the regression pin on the root-motion path specifically.
|
||||
///
|
||||
/// L.3c attempt (2026-04-30, REVERTED): tried bumping to 0.25f per
|
||||
/// retail acclient_2013_pseudo_c.txt:276705. Build green but
|
||||
/// PlayerMovementControllerTests showed forward locomotion dropping
|
||||
/// from ~3m/s to ~0.16m/s — friction now hammers normal walking.
|
||||
/// Retail's friction block is gated by an additional state check at
|
||||
/// line 276702 (`(this->state & ...) == 0`) that we didn't decode
|
||||
/// fully; locomotion is probably skipped from the friction path
|
||||
/// while actively walking. Filed as L.3c-followup; keeping the
|
||||
/// matching-the-decompile-as-read 0.0 threshold for now.
|
||||
/// ⚠️ Constant discrepancy (Ghidra-verify, NOT resolved this pass): the
|
||||
/// raw decomp's Sledding slope-flatness test literally computes
|
||||
/// __fcos(0.17453292519943295) (= cos(10°) ≈ 0.984808) and compares
|
||||
/// against contact_plane.N.z; ACE's port instead compares
|
||||
/// ContactPlane.Normal.Z > 0.99999536f directly (≈0.175° from flat, NOT
|
||||
/// 10°) — physically very different tests. Neither hypothesis (a BN
|
||||
/// misdecompile of a raw float load as __fcos, vs. an independent ACE
|
||||
/// port error) is confirmed; a live Ghidra decompile of 0050ee70 settles
|
||||
/// it. 0.99999536f is kept provisionally (least churn — it's what
|
||||
/// acdream's own prior dead code already had); do not silently resolve
|
||||
/// this without the Ghidra check. See register row AD-55.
|
||||
/// </summary>
|
||||
public void calc_friction(float dt, float velocityMag2)
|
||||
{
|
||||
if ((TransientState & TransientStateFlags.OnWalkable) == 0)
|
||||
return;
|
||||
|
||||
float dot = Vector3.Dot(GroundNormal, Velocity);
|
||||
if (dot >= 0f)
|
||||
float angle = Vector3.Dot(Velocity, GroundNormal);
|
||||
if (angle >= 0.25f)
|
||||
return;
|
||||
|
||||
// Remove the component of velocity that presses into the ground normal.
|
||||
Velocity -= dot * GroundNormal;
|
||||
// Unconditional past the threshold check — no separate inner guard.
|
||||
Velocity -= angle * GroundNormal;
|
||||
|
||||
float friction = Friction;
|
||||
|
||||
|
|
@ -592,7 +634,7 @@ public sealed class PhysicsBody
|
|||
{
|
||||
if (velocityMag2 < 1.5625f) // 1.25² — slow sled
|
||||
friction = 1.0f;
|
||||
else if (velocityMag2 >= 6.25f && GroundNormal.Z > 0.99999536f) // near-flat
|
||||
else if (velocityMag2 >= 6.25f && GroundNormal.Z > 0.99999536f) // near-flat, Ghidra-verify (see doc comment)
|
||||
friction = 0.2f;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ namespace AcDream.Core.Tests.Physics;
|
|||
/// <summary>
|
||||
/// Unit tests for PhysicsBody — the C# port of CPhysicsObj's core simulation
|
||||
/// from acclient.exe (FUN_005111d0, FUN_00511420, FUN_00511ec0, FUN_00511fa0,
|
||||
/// FUN_00511de0, FUN_0050f940, FUN_00515020).
|
||||
/// FUN_00511de0, FUN_00515020, and the named
|
||||
/// <c>CPhysicsObj::calc_friction</c> at 0050ee70).
|
||||
/// </summary>
|
||||
public sealed class PhysicsBodyTests
|
||||
{
|
||||
|
|
@ -454,6 +455,106 @@ public sealed class PhysicsBodyTests
|
|||
Assert.Equal(1f, body.Velocity.X, precision: 4);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// AP-7 (Campaign P Slice P2, 2026-07-30): calc_friction's 0.25f threshold
|
||||
// docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §1
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public void calc_friction_dot_between_zero_and_quarter_now_engages_friction()
|
||||
{
|
||||
// dot(velocity, groundNormal) = 0.1 — ABOVE the old 0.0 threshold
|
||||
// (no friction pre-fix) but BELOW the new retail 0.25f threshold
|
||||
// (friction now engages). This is exactly the window the 0.0 -> 0.25f
|
||||
// port changes; pinning it here documents the intentional behavior
|
||||
// change the AP-7 register row used to warn about.
|
||||
var body = MakeGrounded();
|
||||
body.GroundNormal = Vector3.UnitZ;
|
||||
body.Friction = 0.95f;
|
||||
body.Velocity = new Vector3(5f, 0f, 0.1f);
|
||||
float mag2 = body.Velocity.LengthSquared();
|
||||
|
||||
body.calc_friction(1f / 60f, mag2);
|
||||
|
||||
Assert.True(body.Velocity.Length() < 5f,
|
||||
"Retail's 0.25f threshold means dot=0.1 (below 0.25) engages friction, " +
|
||||
"unlike the old 0.0 threshold which would have returned early here.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void calc_friction_dot_at_quarter_threshold_returns_early_no_change()
|
||||
{
|
||||
// dot(velocity, groundNormal) = 0.25 exactly -> angle >= 0.25f is true
|
||||
// -> early return, matching ACE's `if (angle >= 0.25f) return;`.
|
||||
var body = MakeGrounded();
|
||||
body.GroundNormal = Vector3.UnitZ;
|
||||
body.Velocity = new Vector3(5f, 0f, 0.25f);
|
||||
var before = body.Velocity;
|
||||
float mag2 = body.Velocity.LengthSquared();
|
||||
|
||||
body.calc_friction(1f / 60f, mag2);
|
||||
|
||||
Assert.Equal(before, body.Velocity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests()
|
||||
{
|
||||
// Campaign P Slice P2 research finding: the reverted 2026-04-30 L.3c
|
||||
// regression (forward locomotion 3 -> 0.16 m/s) cannot reproduce on
|
||||
// the production graphical local-player path post-R6, because
|
||||
// PlayerMovementController zeroes Velocity.X/Y to exactly zero every
|
||||
// tick BEFORE UpdatePhysicsInternal/calc_friction runs whenever
|
||||
// animation root motion drives the walk (walking displacement comes
|
||||
// from the animation Frame delta applied directly to Position, not
|
||||
// from integrating Velocity). This test pins that specific state at
|
||||
// the PhysicsBody level (the only file this slice may change):
|
||||
// Velocity.XY == 0 on flat ground is IDENTICAL after calc_friction
|
||||
// whether the threshold is the old 0.0 or the new retail 0.25 --
|
||||
// friction has nothing to hammer because there is no horizontal
|
||||
// velocity for it to act on. Only the residual vertical (gravity)
|
||||
// component may be affected by the normal-removal step, exactly as
|
||||
// retail's own contact handling expects.
|
||||
var body = MakeGrounded();
|
||||
body.GroundNormal = Vector3.UnitZ;
|
||||
body.Friction = 0.95f;
|
||||
// Root-motion path's exact per-tick shape: horizontal zeroed, only
|
||||
// the world Z survives (a small residual downward settle velocity).
|
||||
body.Velocity = new Vector3(0f, 0f, -0.05f);
|
||||
|
||||
body.calc_friction(1f / 60f, body.Velocity.LengthSquared());
|
||||
|
||||
Assert.Equal(0f, body.Velocity.X, precision: 5);
|
||||
Assert.Equal(0f, body.Velocity.Y, precision: 5);
|
||||
Assert.True(MathF.Abs(body.Velocity.Z) < 0.05f,
|
||||
$"Root-motion horizontal speed must stay exactly at full (zero) " +
|
||||
$"speed under the new threshold; got Velocity={body.Velocity}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void calc_friction_sledding_state_gate_reachable_with_new_threshold()
|
||||
{
|
||||
// The Sledding-gated overrides (1.5625/6.25/near-flat) were already
|
||||
// present but structurally unreachable in production (nothing sets
|
||||
// PhysicsStateFlags.Sledding, see #166 research §3) -- this test only
|
||||
// confirms the branch still behaves once the outer 0.25f gate is
|
||||
// passed, so a future data-authored Sledding toggle lands on tested
|
||||
// code.
|
||||
var body = MakeGrounded();
|
||||
body.GroundNormal = Vector3.UnitZ;
|
||||
body.State |= PhysicsStateFlags.Sledding;
|
||||
body.Velocity = new Vector3(3f, 0f, -0.5f); // velocityMag2 = 9.25, >= 6.25
|
||||
float mag2 = body.Velocity.LengthSquared();
|
||||
|
||||
body.calc_friction(1f / 60f, mag2);
|
||||
|
||||
// friction should be 0.2f (near-flat, fast sled) rather than the
|
||||
// default 0.95f -- less decay, so speed should stay closer to 3.
|
||||
Assert.True(body.Velocity.Length() > 2.9f,
|
||||
$"Fast near-flat sledding should use the light 0.2f friction override; " +
|
||||
$"got speed {body.Velocity.Length()}");
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// update_object — per-frame driver
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue