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:
Erik 2026-07-30 08:17:32 +02:00
parent 325fee7cbb
commit 4f7e29f7cf
3 changed files with 174 additions and 30 deletions

View file

@ -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 &amp; 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 &lt; 0:
/// velocity -= fVar1 * groundNormal (remove inward normal component)
/// scalar = pow(1 - friction, dt)
/// velocity *= scalar
/// angle = dot(velocity, contactPlane.N)
/// if angle &gt;= 0.25f → return (moving away fast enough — no friction)
/// velocity -= angle * contactPlane.N (remove inward normal component, unconditional)
/// friction = this-&gt;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
/// &lt; 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 &gt; 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;
}