merge: Campaign P Slice P2 response-layer (TS-1 resolved, AP-7 ported, TS-4 stopped at escape valve)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/architecture/retail-divergence-register.md
This commit is contained in:
Erik 2026-07-30 08:33:18 +02:00
commit 26e0334af3
9 changed files with 746 additions and 63 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;
}

View file

@ -1985,6 +1985,23 @@ public sealed class Transition
// PrecipiceSlide. CliffSlide deflects motion along the ridge
// between current-steep and last-known-walkable; gravity then
// produces visible downhill drift.
//
// TS-1 gap #3 (register AD-54, Campaign P Slice P2 2026-07-30):
// retail's raw SPHEREPATH::edge_slide has NO steepness branch here
// — `if (walkable != null) { ... precipice_slide(...) }` unconditionally
// (acclient_2013_pseudo_c.txt:364-370 per the P2 research quote). The
// LandingZ permissive acceptance itself IS retail-faithful — confirmed
// by TransitionalInsert's own Path-4 Collide branch
// (TransitionTypes.cs, `DoCheckWalkable(PhysicsGlobals.LandingZ, engine)`
// above) and TS-4's BSPTREE::find_collisions read
// (pc:323740-323783: `sphere_path.walkable_allowance = LandingZ`
// unconditionally, no slope test) — so a steep roof really is
// "walkable" in retail too. What is NOT independently verified from
// the raw decomp is whether retail's OUTER caller (transitional_insert)
// absorbs a same-polygon-standing Collided from precipice_slide via
// its own retry loop rather than needing this reroute; see
// docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §2
// gap #3.
if (sp.WalkablePlane.Normal.Z < PhysicsGlobals.FloorZ)
{
var cliffPlane = sp.WalkablePlane;
@ -2015,6 +2032,32 @@ public sealed class Transition
// Retail back-probes from the current sphere center to rediscover the
// walkable polygon we just left, then restores the failed candidate and
// runs precipice_slide against that polygon.
//
// TS-1 gap #1 research (Campaign P Slice P2, 2026-07-30,
// docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §2):
// retail's SPHEREPATH::edge_slide back-probe branch re-caches
// walkable_check_pos/localspace_sphere here — SPHEREPATH::get_walkable_pos
// (0050a8f0) -> SPHEREPATH::cache_localspace_sphere (0050c9d0) ->
// SPHEREPATH::set_walkable_check_pos (00509ce0), acclient_2013_pseudo_c.txt
// :274318-274326 (0050b4e0-0050b507) — before its second precipice_slide
// call. That machinery re-projects the restored check_pos sphere into the
// walkable polygon's OWN per-cell local frame via Position::localtolocal,
// and precipice_slide itself then applies a LandDefs::get_block_offset
// landblock correction (pc:274341) before testing find_crossed_edge.
// Deliberately NOT ported here: acdream's SpherePath.WalkableVertices/
// WalkablePlane are populated in UNIFIED WORLD SPACE at assignment time
// (SetWalkable/SetWalkableTransformed above bake worldOrigin+scale in
// immediately), and GlobalSphere is likewise always world-space
// (SetCheckPos/RestoreCheckPos). Both operands BSPQuery.FindCrossedEdge
// compares are therefore already commensurable with no landblock/cell
// reprojection needed — retail's local-frame recache is a no-op
// correction under this architecture, and FindCrossedEdge never reads a
// sphere radius, so retail's walkable_scale radius correction has no
// acdream counterpart either. Pinned by
// EdgeSlideBackProbePrecipiceSlideTests (direct SpherePath.PrecipiceSlide
// state test): a walkable polygon rediscovered near GlobalCurrCenter,
// tested against GlobalSphere[0] restored to the original failed target,
// crosses the edge and slides — it does not wedge into Collided.
Vector3 backToCurrent = sp.GlobalCurrCenter[0].Origin - sp.GlobalSphere[0].Origin;
sp.AddOffsetToCheckPos(backToCurrent);
@ -2048,6 +2091,25 @@ public sealed class Transition
// deflection. Using LastWalkable preserves the prior flat-ground
// plane across continuous-slope frames; world-up gives a guaranteed
// non-zero deflection when no walkable history exists at all.
//
// TS-1 gap #2 (register AD-53, Campaign P Slice P2 2026-07-30): retail's
// raw CTransition::cliff_slide (pc:272397, 0050a6d0) uses
// this->collision_info.last_known_contact_plane.N DIRECTLY as the second
// cross-product operand — no fallback chain. Confirmed by a fresh read of
// last_known_contact_plane's own maintenance
// (acclient_2013_pseudo_c.txt:272659-272668, pc ~0050ad07): retail
// overwrites last_known_contact_plane from contact_plane UNCONDITIONALLY
// on every validate_transition pass, the same "gets overwritten by
// whatever's current, including a steep plane" behavior this file's
// ContactPlane/LastKnownContactPlane tracking already has — retail does
// NOT maintain a separately-preserved flat-ground history there either.
// This three-source chain (LastWalkablePlane -> LastKnownContactPlane ->
// UnitZ) is therefore a genuine acdream invention, not a retail-matching
// read — kept because it compensates for AP-4's incomplete OnWalkable
// bookkeeping (see DO-NOT-RETRY item 9 in
// docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §0)
// and removing it reintroduces the degenerate-cross "stay on the roof"
// wedge the L.4 session (2026-04-30) fought. See that doc's §2 gap #2.
Vector3 referenceNormal;
string refSource;
if (sp.HasLastWalkablePolygon && sp.LastWalkablePlane.Normal.Z >= PhysicsGlobals.FloorZ)