The user typed @pklite and then walked straight through other PKLite players.
Root cause: ClientObject.PublicWeenieBitfield was written exactly once, from the
0xF745 CreateObject parse, and never refreshed. ACE's only PK-change message is
PropertyInt.PlayerKillerStatus (134) over 0x02CE/0x02CD, which we parsed and
stored into Properties.Ints[134] but never translated back into the bitfield —
and ACE never re-sends a PublicWeenieDesc at all (EnqueueBroadcastUpdateObject
has zero live callers), so that property is the ONLY signal a client can learn
from. Both sides of the collision test read the frozen value, so
CollisionExemption's "4c. both PKLite -> collide" rule could never fire.
Retail's missing port: PublicWeenieDesc::SetPlayerKillerStatus @0x005AC7C0
rewrites _bitfield in place — PK(4) -> (b & 0xfddfffff) | 0x20; PKLite(0x40) ->
(b & 0xffdfffdf) | 0x2000000; Free(0x20) -> (b & 0xfdffffdf) | 0x200000; else
b &= 0xfddfffdf. Mutually exclusive, verified byte-for-byte, with input values
confirmed against retail's own PKStatusEnum (acclient.h:6412-6427), not just
ACE's. Driven from ACCWeenieObject::OnStatUpdated @0x0058DF20 case 0x86.
The fix rewrites the value at its source rather than patching consumers. Two
review rounds were needed because the first pass missed that there are TWO
snapshot stores: InboundPhysicsStateController keeps its own private _snapshots
dictionary, and every untimestamped-field merge (ApplyAcceptedObjDesc and
friends) reads `old` from THAT store, not from RuntimeEntityRecord.Snapshot.
Refreshing only the active record left the target-side shadow flags correct
until the remote's next equip or unequip — ACE broadcasts an ObjDesc on every
one — at which point the appearance path rebuilt the registration from the
frozen spawn and dropped the bit permanently. The regression test demanded by
review is what surfaced that; it is verified discriminating (reverting gives
Actual: 8 instead of 33554440).
Five stores now hold this value, kept coherent from one source by two
ObjectUpdated subscribers plus the appearance-rebuild path. The two shadow-flag
writers are the same invalidation applied at the two edges that can invalidate
it, not competing authorities — review enumerated every drift path and closed
each. That coherence invariant is new as of this commit and is recorded as
register row AP-134, with AP-133 as the precedent for filing a row when the
danger is a future writer rather than current behaviour.
Also corrects TS-23's retirement narrative, which claimed every mover-flags call
site read the mover's "real" PK bits from 2026-07-30. The bits existed but their
source was frozen, so that only became true here; the site enumeration also
missed RuntimeSetPositionMoverPreparation, a seventh site that decodes the
snapshot directly.
Unblocks #298 (melee/missile admission needs the local player's own PKLite bit).
Follow-ups filed: #300 (Properties.Ints[134] vs bitfield mirror gap), #301 (same
defect class for radar blip colour and radar behaviour), #302 (a pre-existing
PortalProjection allocation-assertion flake, 1 in 6, found while verifying this
gate), #303 (LiveEntityPvpBitfieldSync is App-resident but Runtime-owned-state).
Gates: complete Release solution 10,895 passed / 4 skipped / 0 failed (baseline
10,887 including #299). Adversarial + retail-conformance review PASS after one
FAIL round. Every new test discrimination-verified by reverting the fix.
Connected acceptance NOT run — needs a live two-client PKLite session.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CollisionExemption checked only the TARGET's IsImpenetrable, and the class doc
asserted "retail's pseudo-C only checks the target's IsImpenetrable(); acdream
follows retail" while blaming ACE for checking both. That was backwards: ACE was
retail-faithful and acdream was missing half the check.
Retail short-circuits on EITHER the mover's own state & IS_IMPENETRABLE (0x80)
OR the target's IsImpenetrable(); either alone exempts. Verified at the byte
level rather than from the decompiler's rendering — Binary Ninja shows the mover
test as `int16_t state_1 ... if (state_1 < 0)`, which reads like a 0x8000 test,
but decoding the PDB-paired binary at the mapped offset gives:
8b 43 04 mov eax,[ebx+4] ; mover object_info.state
f6 c4 01 test ah,1 ; 0x100 IsPlayer
84 c0 test al,al ; sign bit of AL = state & 0x80
78 3d js ... ; -> collide
`test al, al; js` is a byte-level sign test on AL, i.e. 0x80, not 0x8000.
Corroborated downstream in the same block (`test ah,8` = 0x800 IsPK,
`test ah,0x10` = 0x1000 IsPKLite) and by OBJECTINFO::init @0x0050cf30 setting
state |= 0x80 from the object's own IsImpenetrable().
Also corrected: ACCWeenieObject::IsImpenetrable @0x0058c8c0 returns
(_bitfield >> 0x15) & 1 — retail genuinely conflates BF_FREE_PKSTATUS with
"impenetrable", so acdream's FromPwdBitfield decode was already right.
Both retail arms set collide, so ordering between them is semantically free and
a misreading here could only ever produce spurious collisions, never a
walk-through.
Found while investigating #297; not symptom-causing on its own. No divergence
row: this retires a missing port rather than introducing a deviation, and
nothing in the register or the collision digest's DO-NOT-RETRY tables covers it.
Gates: complete Release solution 10,887 passed / 4 skipped / 0 failed
(baseline 10,867/4/0). Adversarial + retail-conformance review PASS on this
change specifically. Both new tests discrimination-verified by reverting the
branch and confirming failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign P remaining-physics-divergence, placement cutover slice C3c
(docs/plans/2026-08-02-placement-cutover.md). Both production hosts now
register every initial Create through the residence + continuation-
executor + first-entry-conductor machinery (C0-C3b):
- Graphical (route 1): RegisterEntityWithInitialResidence at Create; the
shared RuntimeFirstEntryDriveController pumps both conductors from the
placement-receipt flow; MaterializeProjection and RebucketLiveEntity
are presentation-only while a residence is ACTIVE (ExecutorCompleted is
the presentation-binding receipt); post-residence entities take the
full legacy path including the prepare_to_enter_world clock edges.
PlayerModeController attaches presentation to the Runtime-published
controller; its legacy resolve/step-heights/host-construction path is
deleted; presentation-only rollback (retail has no entry-flow rollback).
- Headless (route 8): OnSpawned registers with residence when a drive
exists; content-less sessions keep the pre-flip direct registration;
SynchronizeLocalPlayer/CreateController/ApplySetupStepHeights deleted;
prepared-collision read failure is a typed AwaitingCollisionSource
retry; far remotes outside the service window complete celless.
- RuntimeLocalPlayerMovementState.Controller setter sealed internal; all
controller mutation flows through the publication lifecycle.
Fix slices landed within this cutover, each dual-gated:
- F1: live movement-stat/server-physics application routed through the
Runtime ownership seam (post-logout ingest crash on the retired
controller eliminated; RuntimeMovementSkillProjection deleted).
- F2: login activation wedge - collision-admission prefix gate factored
out of the seal (reentrant-commit RejectedAuthority), rearm generation
identity corrected, PlayerModeAutoEntry requires the Runtime-published
controller (world reveal can no longer seal unmaterialized).
- F3: landblock-prefix 0-sentinel replaced by explicit absent-id guards;
map-corner landblocks (grid row/col 0) fully legal through admission,
park/rearm/retire, quiescence, and outdoor shadow seeds.
- F5: local-player first-entry ground contact seeded by the shared
SpawnPlacementSettler (moved App->Core) at FinalizeActivation - the
retail first-gravity-frame touch (enter_world 0x00516170 carries no
seed); the legacy unconditional force-seed is overwritten by a real
floor-found contact; airborne spawns stay airborne; outbound contact
bit verified end-to-end. Fixes the standing-cast 'You can't do that
while in the air!' rejections.
- R1 (dual-review round): login constraint leash armed at the committed
placement (HandleReceivedPosition 0x00453FD0 analog); register rows
AD-61 (settle-timing compression now covering the local player) and
AD-42 (repointed off the deleted resolve split) in this commit;
residence-conversion owner API; wire-landblock guards; drive-pending
ledger in IsConverged; route attach/detach latch; executor-drain drift
model documented + source-pinned.
Gates: Runtime 1,003, App 4,039/3 skips, Headless 79, complete solution
10,816/0 failed/4 skips (Release, -m:1); connected lifecycle/reconnect
gate PASS (logs/connected-world-gate-20260802-175401; graceful exits,
world-visible, zero airborne rejections). The nine-stop soak remains red
for the pre-existing 6b28ff99 whole-world collision-clone throughput
regression (attributed with evidence; scheduled as its own slice before
C5). Dual Opus reviews (retail-conformance + adversarial): delta PASS.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Retail jump landings BOUNCE: the floor touch records both a contact plane
(grounding) AND a collision normal (collided_with_environment), and
handle_all_collisions reflects the unmodified impact velocity off it at
5% elasticity (v += -(v.n)(elasticity+1).n, DEFAULT_ELASTICITY 0.05
@0x007c6a7c). Our transition already recorded both facts; the bounce was
suppressed by the AD-25 adaptation stack in the per-tick commit: a
Velocity.Z<=0 landing gate (needed because the resolver glued ascending
movers to the ground) plus a landing Velocity.Z=0 hand-zero whose stated
purpose was making the reflect a no-op. Downhill glided instead of
bouncing, flat-ground landings had no pop, and uphill jumps flapped
between grounded/airborne against the animation machine.
Three retail mechanisms replace the stack:
- check_contact (0x0050f5b0) seeding in ResolveWithTransition: a body in
CONTACT seeds the transition's contact only while v.contactPlane.N <=
0.0002; moving away seeds the last-known plane alone (get_object_info
0x00511cc0). Ascending jumps therefore run contact-free (ballistic, no
glue) - the gate's reason-for-being is gone. The plane requirement is
strict: Contact-without-plane is unrepresentable in retail.
- SetPositionInternal-shaped commit (0x00515330, byte-read end-to-end,
velocity-sign-FREE): contact purely from the transition's contact
plane, HitGround on the airborne->walkable edge, HandleAllCollisions
with unmodified impact velocity. Whole commit gated on Ok &&
candidateMoved (retail pc:283657 skips SetPositionInternal entirely
when the candidate did not move) - a standing body's contact state is
never re-derived, which is what keeps rest bit-stable (AD-41 updated).
- Byte decodes: gate override state&0x800000=Sledding, zero branch
state&0x20000=Inelastic, reflect strictly dot<0 - our port already had
all three correct.
Settle: real landings (>=0.25 m/s) bounce and decay geometrically;
smaller impacts are consumed by retail's unconditional small-velocity
zero, so standing never micro-bounces. Re-baselines documented in place:
landing-survival pin measures decay post-settle; LiveCompare_Tick0/376
pin the new IsOnGround=false on zero-move ticks (captured true was the
retired seed echo; tick 376's captured body carries an 11.8 m/s grounded
velocity from the deleted get_state_velocity-overwrite era); de-overlap
fixture now carries the plane real grounded bodies always have. New
pins: LandingBounceSeedingTests (ascent no-seed, rest keeps contact,
strict plane, slope 5% reversal + tangential preservation, Sledding
override).
Investigation + implementation record:
docs/research/2026-07-30-landing-bounce-family.md. Complete Release
suite: 10,031 passed / 5 skips / 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Capture bisect (docs/research/2026-07-30-265-capture-bisect.md, mined
from artifacts/matrix-session2-resolve.jsonl records 3415-3434) traced
#265's lost roof slides / permanent landing freeze and #166's missing
downhill sled to a pre-existing (2026-07-20, ten days before Campaign P
- not a regression) mechanism in PlayerMovementController.cs's grounded
quantum block: it hand-zeroed Velocity.X/Y to exactly zero every tick
once OnWalkable whenever animation root motion drives the walk (the
production graphical local-player path), discarding any residual
horizontal momentum a fall left on the body before calc_friction
(AP-7/AD-55, already correctly ported) or PhysicsBody.
UpdatePhysicsInternal's Euler integrator ever got a chance to act on it.
Two changes:
1. PhysicsEngine.cs now syncs body.GroundNormal (the vector
calc_friction dots velocity against, per retail
CPhysicsObj::calc_friction 0x0050ee70's `contact_plane.Normal` read)
from the committed ContactPlane.Normal at the same commit point that
already publishes ContactPlane. GroundNormal had zero production
writers before this and silently defaulted to Vector3.UnitZ forever
- even surviving velocity would have been tested against a fake
flat-ground normal on any real slope. Core-level, so player, remote,
ordinary, and projectile movers all benefit uniformly.
2. PlayerMovementController.cs's grounded block no longer reconstructs
Velocity at all for the animation-root-motion case (only the
headless/test-controller get_state_velocity fallback still does,
unchanged). Root motion continues to fully own commanded locomotion;
this only stops destroying whatever Velocity already holds, letting
it compose with root motion through the same ResolveWithTransition
sweep exactly as retail's CPhysicsObj::UpdatePositionInternal
composes both channels.
Symptom (a), the uphill-jump bounce, traces to a SEPARATE, byte-exact
(re-verified against acclient_2013_pseudo_c.txt:282647-282760),
already-closed retail mechanism (AD-25, PhysicsObjUpdate.
HandleAllCollisions's shouldReflect gate) - confirmed orthogonal to this
fix, not addressed here (see the research doc's as-fixed addendum §9.5).
Issue265SteepSlopeCaptureBisectTests.cs gains a composed harness
(ReplayRealRoofLandingComposed) mirroring PlayerMovementController.cs's
per-tick composition against Core types only, proving: the old model
reproduces the mined freeze exactly; the new model survives the landing
and slides continuously (the real captured geometry glides at constant
velocity per retail's own dot>=0.25 early-return - AP-7); a synthetic
dot<0.25 case shows genuine exponential decay via calc_friction; and a
synthetic uphill-bounce case proves the fix changes nothing about
HandleAllCollisions's reflection decision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Raw byte decode of MovementSystem::GetRunRate (0x006b0950, PDB-paired
binary): fild skill; fcom [800f]; fnstsw; test ah, 0x44; jp general —
the C2/C3 parity idiom whose 18/4 fall-through executes ONLY at
skill == 800 exactly. ACE read this as >= 800 ('max run speed?') and
Campaign P P1 inherited that misread when BN dropped the arithmetic,
flat-lining every maxed character at 4.5 (retail-true ~3.70, +21%) and
erasing the vitae differential (both 10200 and 15225 sat above 800).
The [stat-chain] live capture proved the enchant chain correct end to
end (vitae 0.67 -> eff run 10200 -> controller), isolating the formula.
General path byte-verified: (loadMod*(skill/(skill+200)*11)+4)/scaling/4.
InqMaxRunRate's skill=9999 probe gets ~3.6961, not 4.5.
Golden tests pin the 799/800/801 straddle and the maxed-skill vitae
differential; pseudocode doc §6 carries the decode plus a do-not-
reimport-ACE warning. Complete Release suite: 10,025 passed / 5 skips.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds tools/analyze_265_steep_slope_capture.py (segment miner for the
ACDREAM_CAPTURE_RESOLVE JSONL captures: uphill-jump-bounce and
lost-slide/edge-wedge signature scans) and
tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs (a
synthetic single-polygon PhysicsEngine that replays the EXACT real captured
ballistic approach + landing from artifacts/matrix-session2-resolve.jsonl
records 3415-3434, driving PhysicsEngine.ResolveWithTransition directly at
the Core boundary).
Mining found two dramatic real "velocity annihilation + permanent freeze"
events (records 3153/3159 and 3433/3434): a high-speed fall lands on a
moderate roof slope (normal.Z=0.857, ABOVE PhysicsGlobals.FloorZ — walkable
by threshold), and the very next tick shows Velocity forced to exactly
(0,0,0) with the position frozen byte-identical for the rest of the capture
(12,292 ticks to EOF for the second event).
No production code changes. Full Core.Tests suite: 4070 passed / 2 skipped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports CMotionInterp::get_adjusted_max_speed (0x00527D00, byte-decoded:
bare rate unless RunForward; forward_speed x 4.0 when running;
current_speed_factor proven a ctor-constant 1.0 at 0x00528C34) and swaps
all five interpolation catch-up call sites to it - retail's
fUseAdjustedSpeed_ static (.data 0x0081F418 = 1) makes this the live
branch, so standing/walking remotes now catch up at ~2x runRate instead
of 4x too fast (the #41/#165 presentation family). Autorun now hard-
forces Run for its duration and cancels on every fresh forward press
(CommandInterpreter::HandleNewForwardMovement 0x006b3d60 is literally
SetAutoRun(0,1)); the old test pin codified the divergence. AP-30
retired: retail Frame::is_equal genuinely uses the 0.0002 epsilon - the
row recorded a non-divergence. Three catch-up test pins re-baselined to
retail semantics with citations. Full Release suite 9,983/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Per docs/research/2026-07-30-ts4-116-oracle-plan.md Addendum (byte-proven
2026-07-30). Raw bytes of CPhysicsObj::calc_friction @ 0x0050ee70's
Sledding fast-sled branch (0x0050ef52-0x0050ef6a):
d9 86 38 01 00 00 fld dword [esi+0x138] ; contact_plane.Normal.Z
dd 05 28 6b 7c 00 fld qword [0x007c6b28] ; = 0.17453292519943295 (10 deg RADIANS)
d9 ff fcos ; st0 = cos(10 deg) = 0.984807753
de d9 fcompp
confirm a genuine fcos opcode over a real 10-degrees-in-radians double
literal -- not a BN misdecompile of a raw float load. Retail truly
computes cos(10 deg) ~ 0.9848078 at runtime; ACE's 0.99999536f equals
cos(0.1745 DEGREES) -- the same radian literal evaluated in degree mode,
a proven ACE porting error carried into this port provisionally.
PhysicsBody.calc_friction's Sledding near-flat override now compares
GroundNormal.Z > 0.98480775f (cos 10 deg). Feel impact: retail's 0.2f
fast-sled friction override engages on any ground within 10 degrees of
flat; the old constant engaged only within ~0.175 degrees (never, in
practice).
Tests: two new boundary pins
(calc_friction_sledding_fast_override_engages_at_5_degrees_from_flat /
..._does_not_engage_at_15_degrees_from_flat) construct a tilted
GroundNormal with velocity purely orthogonal to the tilt plane (dot=0
exactly, isolating the Sledding-band friction value from the outer 0.25f
gate and the normal-removal step) and assert the exact pow(1-friction, dt)
decay on each side of the new 10-degree boundary.
Register: AD-55 retired (struck through, retirement note with the byte
decode).
Full AcDream.Core.Tests suite: 4063 passed / 1 skipped, no regressions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per docs/research/2026-07-30-ts4-116-oracle-plan.md §3, §3.3 step 1.
Test-only change with zero production code in this commit: TS-4's
retirement (5e2be19b, the previous commit) is what actually unblocks
this test's routing.
The plan's confirming instrumentation (probes added in 5e2be19b: which
BSPQuery.cs path fires, whether Path 4's FindWalkableInternal finds a
walkable candidate) traced D4's tall-vertical-wall scenario: Path 6 fires
(SetCollide, no reposition, Adjusted) -> the retry routes to Path 4
(find_walkable), which finds NO candidate for this sheer wall
(changed=false, confirmed) -> Path 4 returns OK -> TransitionalInsert's
Phase 3 sp.Collide block runs (reachable now that Phase 1/2 both return
OK): ContactPlaneValid is false (first airborne contact) so the reset
branch fires, LastKnownContactPlaneValid is false too (first frame), so
SetCollisionNormal(sp.StepUpNormal) runs and the function returns
Collided -- a hard stop, in place, with the wall's real normal. This
exactly reproduces the test's original (pre-Skip) expectation, confirming
the oracle plan's §3.1/§3.2 structural finding without a live cdb trace.
Full AcDream.Core.Tests suite: 4061 passed / 1 skipped (the remaining
skip is the unrelated Pvs_CottageInterior_MatchesRetailCellDrawList),
no regressions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per docs/research/2026-07-30-ts4-116-oracle-plan.md §1, §4 item 2 (the
decisive TS-4 confirming run). Retail's BSP layer has NO steepness test at
all (acclient_2013_pseudo_c.txt:323783-323821, 0x0053a793) — every airborne
hit, steep or shallow, falls through to the same unconditional
`SetCollide` + `Adjusted`. The L.4 slide-tangent shortcut (worldNormal.Z <
FloorZ -> project-and-Slid, with its own SetSlidingNormal write) is deleted
from both BSPQuery.cs's and FlatBspQuery.cs's Path 6 sphere0 branch.
Fixing FlatBspQuery.cs (the flat/indexed engine Slice I6/I7 made
production-authoritative) was necessary in this same commit: it carried an
exact structural duplicate of the shortcut, caught by
FlatBspQueryDifferentialTests.InstalledDat_LargeRandomizedSweep_HasZeroBitMismatch
(graph=Adjusted vs flat=Slid) once the graph side was fixed alone. Its
sphere1 branch is also brought in line with the #116 shape-1 fix landed
in db2889af (direct Collided + SetCollisionNormal instead of the deferred
SetCollide/shortcut treatment) — that parity gap existed since shape-1's
commit only touched BSPQuery.cs and the randomized differential sweep
didn't happen to exercise the narrow foot-clear/head-hit case until this
session's broader change surfaced it.
DECISIVE CONFIRMING RUN (Ts4SteepRoofWedgeCaptureTests, per the plan's own
required test-first order): added
FallOntoSteepSlope_WithHorizontalVelocity_NeverFreezesForOverHalfASecond_AndReachesFloor
— the same steep-roof drop as the existing pure-vertical fixture, but with
a small residual horizontal velocity (vx=-0.3 m/s), matching the realistic
live-play input (WASD, jump momentum) that validated the shortcut on
2026-04-30. With the shortcut removed, this variant converges cleanly to
the flat floor with zero freeze. The pure-vertical fixture, run
shortcut-removed, DOES still freeze — per the oracle plan's root-cause
trace (§1.2 Step E), this is the DEGENERATE case: AdjustOffset's crease
projection (Cross(ContactPlane.Normal, SlidingNormal)) is mathematically
orthogonal to a purely-Z gravity offset, crushing it to zero every tick
before TransitionalInsert can run again — present identically in the raw
decomp, ACE's port, and this port. Renamed and re-asserted as a PINNED
known-degenerate test
(FallOntoSteepSlope_PureVertical_FreezesAtDegenerateFixedPoint_RetailParity)
rather than treated as a bug. Filed as register row AD-56.
BSPStepUpTests.C3_Path6_AirborneMoverHitsSteepSlope_ReturnsSlid pinned the
OLD shortcut's Slid-no-Collide behavior directly; renamed to
...ReturnsAdjustedAndSetsCollide and corrected to the retail-faithful
Adjusted/Collide=true outcome.
Register: TS-4 row retired (struck through, retirement note); AD-56 filed
for the pure-vertical degenerate case; the retire-next shortlist's TS-4
entry removed and renumbered.
Full AcDream.Core.Tests suite: 4060 passed / 2 skipped (D4 stays Skip-tagged
in this commit; its own un-skip is a separate, dependent test-only commit
for #116 shape-2), no regressions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add ConstraintDistance (outdoor/indoor start=10/5, max=50/20), byte-decoded
from the matching retail binary (GetStartConstraintDistance 0x0050ebc0,
GetMaxConstraintDistance 0x0050ec10 - both x87-return functions BN elided).
Deliberately omits the vestigial player-vs-remote branch the disassembly
shows loads identical constants either way. Pins the ACE-inversion (ACE's
start mapping is outdoor 5/indoor 10, the opposite of the binary - the
binary wins). Adds a full-chain conformance test proving an armed,
over-strained leash actually blocks jump_is_allowed (0x47), not just the
bare stub-property regression already covered.
See docs/research/2026-07-30-constraint-leash-constants.md.
Campaign P Slice P4 Opus review verdict: FIX-FIRST. RestrictionObjPrevalenceInspectionTests
(commit 3b5e0992) found 103,766 of 729,888 installed EnvCells (1,293 landblocks -
the whole housing estate) carry a baked RestrictionObj. The AP-71 gate's
unconditional fail-closed default (CanMoveInto unmodeled) would have locked
every apartment/cottage/villa interior for every player, including its own
owner - a live regression, not the "inert in dev content" the original
register row assumed.
Ports ACCWeenieObject::CanMoveInto (0x0058da40, pc:407982-408056) and
RestrictionDB::IsAllowedIn (0x005ae8f0, pc:444493-444516) verbatim into
ObjectInfo.CheckEntryRestrictions:
- owner_iid == 0 or == mover's own guid -> admit (open/owner)
- no RestrictionDB (retail _db == 0, i.e. never authored or not yet
received) -> admit
- present RestrictionDB -> IsAllowedIn: open-to-public flag, OR mover
shares the house's allegiance monarch, OR mover's own guid is a
guest-table member
- unresolved restriction object -> fails CLOSED, exactly retail's own
fallback when GetObjectA can't resolve it (pc:704-716)
Wire feed (Core.Net):
- CreateObject.cs: HouseOwner (WeenieHeaderFlag 0x02000000), HouseRestrictions
(0x04000000), and Monarch (0x40) PWD-tail fields were parsed-and-skipped;
now captured. Also fixes the HouseRestrictions PHashTable header
misconception: the wire is ONE packed u32 (low 24 bits = entry count),
not a separate count(u16)+numBuckets(u16) pair - verified against
Chorizite's RestrictionDB.generated.cs. The old skip's byte-count
happened to match for realistic guest-list sizes, but a future
numBuckets value >255 would have corrupted the parse; now correct
regardless.
- GameEvents.cs/GameEventWiring.cs: new House_UpdateRestrictions (0x0248)
parser + wiring - retail's live guest-list refresh, whole-unit replace.
No-ops if the house object hasn't arrived via CreateObject yet.
- ClientObject/WeenieData/ClientObjectTable: HouseOwnerId, MonarchId,
Restrictions (new HouseRestrictionRecord) fields + merge-preserving
Ingest + targeted UpdateHouseRestrictions.
Physics wiring:
- PhysicsEngine gains an Objects (ClientObjectTable?) property, mirroring
the existing DataCache pattern - acdream's GetObjectA equivalent, used
ONLY by the entry-restriction gate.
- RuntimeEntityObjectLifetime wires Physics.Engine.Objects = Objects in
all three constructors, right alongside the table's own construction -
the same canonical table every other subsystem borrows from, never a
second one. This is the production fix: without it the gate still fails
closed on every restricted cell (unresolvable object), so the wiring is
load-bearing, not cosmetic.
Register: AP-129 narrowed (not retired) to the genuine remaining residual -
House_UpdateRestrictions' Sequence byte isn't used for staleness/reordering
rejection (low-probability, self-correcting), and outdoor CLandCell
restriction (a separate DAT structure) remains unported and unaffected by
this fix.
Tests: 15 new/updated in Ap71EntryRestrictionGateTests.cs (resolved-unowned
admits, owner admits, present-list-excluded blocks, present-list-included
admits, open-to-public admits, shared-allegiance-monarch admits, unresolved
blocks via null and via an empty table, plus two new end-to-end
PhysicsEngine.Objects-wired scenarios); 2 new CreateObject parser tests +
2 new GameEventWiring tests for the wire feed.
AcDream.Core.Tests: 4049 passed, 2 skipped, 0 failed.
AcDream.Core.Net.Tests: 761 passed, 0 skipped, 0 failed.
Complete solution suite: 9,961 total, 9,956 passed, 5 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
103,766 of 729,888 EnvCells (14%, 1,293 landblocks - the entire housing
estate, 0x70xxxxxx GUIDs) carry a baked RestrictionObj. The AP-71 gate
as wired (CanMoveInto unmodeled, fail-closed) would therefore lock every
housing interior for everyone; retail's CanMoveInto (0x0058da40) is
fail-OPEN for unowned houses and for a null RestrictionDB. Fix directed
back to the P4 implementer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign P Slice P4 item 2. TerrainSurface.SampleWaterDepth now returns 0.1
(was collapsed to 0) for a partially-water cell's dry corner, matching
retail's ObjCell.get_water_depth / calc_water_depth (via ACE's unambiguous
C# port). ValidateWalkable's formula was already byte-for-byte verbatim
(ACE ObjectInfo.ValidateWalkable line 124); only the constant was collapsed.
The old collapse's justification ("0.1 destabilizes the feet-exactly-on-plane
contact-touch check because dist > EPSILON skips SetContactPlane that tick")
is structurally true of retail too - traced and confirmed this slice: in ALL
THREE implementations (retail, ACE, acdream) a skipped touch-reassertion is
NOT a fall, because Contact/OnWalkable are STICKY -
PhysicsEngine.ResolveWithTransition's onGround computation ORs the fresh
per-call ContactPlaneValid with the seeded, persistent
PhysicsBody.TransientState.OnWalkable bit (itself written back by the
caller's own sticky TransientState). PhysicsEngine.SampleTerrainWalkable's
isWater = waterDepth >= 0.45f threshold means the restore does not flip the
dry corner's water classification (0.1 still < 0.45) - only the sink-in
depth changes. Full Core.Tests suite green (4038/2 skips, up from 4026)
proves the sticky-bit argument held in practice.
WATER_CONTACT_TS (TransientStateFlags.WaterContact, declared but never
written) is now mirrored alongside CONTACT_TS/ON_WALKABLE_TS at every commit
point that writes them: PhysicsObjUpdate.ApplySetPositionContact (projectiles
+ remote teleport), PhysicsObjUpdate.CommitSetPositionTransition (remote
teleport placement), and PhysicsEngine's per-resolve body-state commit (local
player + remote dead-reckoning + ordinary movers via ResolveWithTransition -
the actual SetPositionInternal-equivalent path). No signature changes needed:
body.ContactPlaneIsWater is already fresh by the time each function runs.
CollisionShadowVerifier audit: no change needed. It diffs graph-vs-flat BSP
traversal outcomes (ObjectInfo/CollisionInfo/SpherePath fields already
including ContactPlaneIsWater); it never touches PhysicsBody.TransientState,
and the water-depth constant is computed identically upstream of both
traversal modes, so it cannot introduce a new graph/flat divergence.
Filed #264 for the three items research explicitly left open (none block
this port): no confirmed retail consumer of WATER_CONTACT_TS was found (an
xref scan wasn't attempted - bitmask reads aren't text-greppable); the
CLandCell ENTIRELY_WATER ethereal/swim exemption from terrain collision was
not cross-checked; jump-in-water/swim-animation effects were not
investigated (out of physics/collision scope).
Conformance: Ap10WaterSemanticsTests covers SampleWaterDepth golden values
(NotWater/EntirelyWater/PartiallyWater wet+dry corners), the isWater
threshold non-flip, WaterContact mirroring in both PhysicsObjUpdate
functions, and two settle-to-rest end-to-end PhysicsEngine.ResolveWithTransition
scenarios (water: sinks exactly waterDepth below the plane and sets
WaterContact; dry: rests exactly on the plane and clears any stale
WaterContact bit).
Register: retired AP-10 (92 active AP rows, down from 93).
AcDream.Core.Tests: 4038 passed, 2 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign P Slice P4 item 1. Ports retail's CObjCell::check_entry_restrictions
(pc:308873-308912, 0x0052b6d0), called FIRST by CEnvCell::find_env_collisions
(pc:309576) before any BSP work, as ObjectInfo.CheckEntryRestrictions wired at
the top of the indoor branch of Transition.FindEnvCollisions.
Resolves the research doc's open question on restriction_obj's source: the
ACE cross-check (references/ACE/Source/ACE.DatLoader/FileTypes/EnvCell.cs:32,
66-67) plus an independent reflection probe of Chorizite.DatReaderWriter
2.1.7's own EnvCell.RestrictionObj field confirm it is a plain DAT-baked
uint32 gated by EnvCellFlags.HasRestrictionObj (0x8) - not a live wire
override. The BN pseudo-C's "count for an array alloc" read at the same
UnPack offset was the mis-attributed field-name collision
feedback_bn_decomp_field_names warned about.
CellPhysics.RestrictionObj is wired from envCell.RestrictionObj in BOTH the
dev/graph-fixture path (CacheCellStruct) and the production/prepared path
(CachePreparedCellStruct) - the latter already receives a live parsed
envCell for Position/EnvironmentId, so no bake-format change was needed.
The mover's own CanBypassMoveRestrictions (BF_ADMIN 0x100000 AND
BF_IMMUNE_CELL_RESTRICTIONS 0x400000, acclient.h:6452-6454) is decoded via
the same PWD-bitfield pipeline TS-23 established for PK/PKLite/Impenetrable
(EntityCollisionFlags -> ToMoverState -> ObjectInfoState moverFlags).
Remaining gap (filed as AP-129, replacing the retired AP-71 row): CanMoveInto
(house owner IID + guest/ban list) is unmodeled, so a genuinely restricted
cell fails CLOSED for everyone, not just intruders - matching retail's own
fallback when the restriction weenie can't be resolved (pc:704-716). Outdoor
CLandCell restriction (LandblockInfo.RestrictionTables, a separate DAT
structure) is explicitly out of scope for this gate.
Conformance: Ap71EntryRestrictionGateTests covers the pure gate logic
(NPC bypass, admin bypass, fail-closed, ordinary-cell no-op), the PWD-bitfield
two-bit AND decode, and three end-to-end Transition.FindEnvCollisions
scenarios proving zero behavior change for ordinary cells.
AcDream.Core.Tests: 4026 passed, 2 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign P Slice P3 item 4. Per the plan's explicit instruction, this
is diagnose-only: the research's candidate (a)/(b) mechanisms did not
confirm, so no fix lands here.
Built the dat-free/dat-backed fixtures the plan asked for (no live
client) to test the two mechanisms a physics fixture CAN discriminate:
- (b) ruled out by code reading: RuntimeRemotePhysicsUpdater.Tick's
resolve gate reads RuntimeEntityRecord.FullCellId live. Every
FullCellId = 0 write site (TryApplyPickup, CommitAcceptedParentCellless,
CommitWithdrawal in RuntimeEntityObjectLifetime.cs) is a pickup/
parent-attach/delete path, never reachable for a live, freely moving
remote mid-session. The "one-frame grace" is genuinely first-spawn-only.
- (a) tested directly and does not reproduce, on two independent
geometries: InterpolationManager's unclamped stall-fail "tail delta"
snap (node_fail_counter > 3) can hand ResolveWithTransition an
arbitrarily large single-tick targetPos. New fixture tests replace a
proven small-step sweep (many 0.08-0.10 m ticks) with ONE resolve call
spanning the entire distance, against both a synthetic creature sphere
and the real Holtburg door BSP slab (Setup 0x020019FF/GfxObj
0x010044B5, the existing door-apparatus dat fixture) already used by
DoorCollisionApparatusTests. Both stop at the identical surface
distance the small-step tests pin, with a valid collision normal --
the sweep is not distance-limited and does not tunnel on a large
single-tick delta.
Candidate (c) -- render/interpolation presentation lag on the App side --
is the remaining hypothesis and is out of scope for a physics-fixture
pass (it's a claim about what gets drawn relative to the committed
PhysicsBody.Position, not something a Core fixture observes). #165
stays OPEN with (a)/(b) struck from the candidate list by the evidence
above and (c) named as the next concrete step (an App-layer render-vs-
physics-position diff, or a fresh live ACDREAM_PROBE_RESOLVE capture).
New tests: Issue165RemoteWallPenetrationDiagnosticTests (dat-free,
3 tests) and DoorCollisionApparatusTests.
Apparatus_SingleLargeTickJump_DeadCenter_StillBlocksOnBSP (dat-backed,
1 test, skips gracefully without the local dat directory).
dotnet build + dotnet test (Core.Tests 4012/2 skip, Runtime.Tests
425/0) green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign P Slice P3 item 3. The wire parse (CreateObject's
PublicWeenieDesc._bitfield), the decode (EntityCollisionFlagsExt.
FromPwdBitfield), the per-GUID storage (ClientObjectTable.
PublicWeenieBitfield), and the exemption logic (CollisionExemption.
ShouldSkip) all already existed and were already correct -- every
mover-flags call site just fed a GUID-prefix IsPlayer heuristic instead
of the real per-entity PK/PKLite/Impenetrable state (retail
OBJECTINFO::init 0x0050cf30 state |= 0x80/0x800/0x1000).
Port:
- EntityCollisionFlagsExt.ToMoverState translates the decoded PWD
bit-space into the ObjectInfoState bit-space FindObjCollisions
actually reads -- two different numberings that must not be
confused. Deliberately does not translate IsPlayer (every call site
already derives that correctly from its own GUID heuristic per
#184 Slice 2b).
- EntityCollisionFlagsExt.ResolveMoverPvpState is the one shared
ClientObjectTable-backed lookup (guid -> ObjectInfoState), replacing
what would otherwise have been three separate inline copies across
GameWindow/LivePresentationComposition/RemoteTeleportController.
- Threaded as a new optional moverPvpState parameter through
RuntimeRemotePhysicsUpdater.Tick/TickHidden and
RuntimeOrdinaryPhysicsUpdater.TryBegin (default None preserves every
pre-P3 caller unchanged), and as PlayerMovementController.OwnPvpFlags
for the local player's own two resolve call sites.
- TS-23 section 12b: PlayerWeenie.JumpStaminaCost's pk parameter now
reads the real PlayerKillerStatus(0x86)/LastPkAttackTimestamp(0x91)
pair against retail's 20-second recency window
(pkStatus in {4, 0x40} && (timestamp + 20.0) >= now), replacing the
P1 hardcoded false. RuntimeMovementSkillState/Snapshot and
LiveSessionEventRouter.RecomputePvpStatus push both the PWD bitfield
and the PlayerKillerStatus pair reactively, riding the SAME
ClientObject event triggers RecomputeBurden already uses.
- A conformance test caught a genuine precision bug in the first
PK-timer clock choice: DateTimeOffset.UtcNow's Unix-epoch seconds
(~1.7 billion) loses ~128 seconds of precision in a 32-bit float,
silently swallowing the entire 20-second window. Switched to
Environment.TickCount64 (small, monotonic magnitude) -- also the more
retail-plausible basis, since LastPkAttackTimestamp is itself a wire
PropertyFloat and retail's Timer::cur_time is almost certainly a
process/session-relative counter for the same precision reason, not
an absolute epoch.
Non-PK invariant (the acceptance criterion): an entity with no
ClientObjectTable row, or a row whose PublicWeenieBitfield is null or
0, resolves to ObjectInfoState.None -- a no-op OR into moverFlags,
bit-identical to every pre-P3 caller's hardcoded value. A dedicated
test drives two real ClientObjectTable rows through
CollisionExemption.ShouldSkip and confirms PK-vs-PK collides while
PK-vs-non-PK and non-PK-vs-non-PK both stay exempt (walk through).
Register: TS-23 retired (both the collision-flags and PK-timer halves);
the stale "M2 combat must land TS-23" phase-gate note removed.
dotnet build + dotnet test (Core.Tests 4008/2 skip, Runtime.Tests
425/0, App.Tests 3968/3 skip, complete solution build) all green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign P Slice P3 item 1. Retail CPhysicsObj::transition (0x00512dc0)
seeds the collision sweep from CPartArray::GetSphere (the Setup's own
<=2-sphere list, each origin+radius scaled by m_scale) via
SPHEREPATH::init_sphere (0x0050c670) -- not from a symmetric two-scalar
(radius, height) capsule reconstruction. The human Setup 0x02000001's
authored spheres are (0,0,0.475) r=.48 and (0,0,1.350) r=.48; the old
reconstruction from (0.48, 1.835) produced (0,0,0.48) + (0,0,1.355), a
5 mm head-center offset the TS-46 register row documented as a residual.
Port:
- SpherePath.InitPath gains a sphere-list overload (ImmutableArray<
FlatCollisionSphere>, scale) sharing a new InitPathCore with the
existing (radius, height) overload, which is now the degenerate
2-scalar case of the same code -- byte-for-byte unchanged, so every
captured-fixture replay (CellarUpTrajectoryReplayTests,
DoorBugTrajectoryReplayTests, CellarLipWedgeTests) keeps passing
unmodified.
- PhysicsEngine.ResolveWithTransition gains optional sphereList/
sphereScale parameters; empty/default preserves the legacy scalar
path for every pre-existing caller.
- LiveEntityMotionRuntimeController.GetSetupMoverShape is a new sibling
of GetSetupCylinder (left untouched) that resolves the Setup's own
sphere list plus Setup-derived step-up/step-down
(CPartArray::GetStepUpHeight/GetStepDownHeight, 0x005180d0/0x005180f0,
x ObjScale, 0.4 m fallback matching the pre-existing literal).
- Threaded through PlayerMovementController (both resolve call sites,
new SphereList property set by PlayerModeController.ApplyStepHeights
and the Headless world projection), RuntimeRemotePhysicsUpdater
(Tick + TickHidden), and RuntimeOrdinaryPhysicsUpdater.TryBegin.
Remote/ordinary step heights are now Setup-derived instead of a
hardcoded 0.4f literal. Projectile and camera-probe sweeps are
untouched (already single-sphere-exact).
- PlayerModeController.ApplyStepHeights also now applies the x ObjScale
multiply to the player's own step heights (previously only the
remote/ordinary paths did), closing an adjacent gap the P3 research
flagged.
Ts46SphereListConformanceTests proves the sphere-list overload sees the
exact dat spheres (not the reconstruction), that the scalar overload is
unchanged, and that ResolveWithTransition's sphereList parameter
actually drives the sweep (a decoy-scalar control pair using a
head-height obstacle sphere).
Register: TS-46 retired (both residuals it named are closed); header
count corrected to 40 active TS rows.
dotnet build + dotnet test (Core.Tests 3991/2 skip, Runtime.Tests
425/0, App.Tests 3968/3 skip, complete solution build) all green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign P Slice P2 step 2-3
(docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §4, §6
Step 3). Per the research doc's own port order, TS-4's Path-6 steep-poly
shortcut may only be removed after a fixture reproduces the original
"stuck in falling animation on a steep roof" symptom cleanly with the
shortcut disabled. No surviving live-session fixture exists from the
2026-04-30 L.4 commit (b1af56e); this adds a dat-free multi-frame capture
(Ts4SteepRoofWedgeCaptureTests) using BSPStepUpFixtures.SlopedUnwalkable's
63.4 degree slope, replayed at 30 Hz with gravity integrated between
PhysicsEngine.ResolveWithTransition calls -- the same idiom as
Issue185OutdoorStairsSeamReplayTests.
Against today's baseline (shortcut active) the capture is green, as
expected (the shortcut's explicit AddOffsetToCheckPos keeps the body
moving every tick by construction).
Scratch-removed the shortcut (both BSPQuery.cs sphere0/sphere1 branches,
not committed -- reverted after capture) and re-ran the same test: the
body falls and lands cleanly on the steep polygon at tick 17 (InContact,
OnWalkable=false, via retail's own permissive CTransition::check_walkable
LandingZ gate, pc:273202), then freezes at that exact position for the
rest of the run -- the exact historical wedge shape, tripping the test's
own >0.5s-frozen threshold at tick 33.
Root-cause diagnosis via ACDREAM_DUMP_EDGE_SLIDE=1: the freeze is upstream
of EdgeSlideAfterStepDownFailed/CliffSlide entirely (none of that
dispatch's diagnostics fire). TransitionalInsert's Phase 2 object-collision
check returns Adjusted on every retry attempt because Path 6's retail-
faithful SetCollide returns ADJUSTED_TS without repositioning the sphere
(unlike the interim shortcut, which explicitly pushes the sphere off the
face) -- the same steep polygon re-triggers Path 6 on the immediate retry,
forever, and Phase 3 (the sp.Collide handling that contains DoCheckWalkable,
the Placement re-test, and the TS-1 CliffSlide chain) is gated on Phase 1
AND Phase 2 both returning OK, so it is structurally unreachable from this
state. TS-1's completeness is moot here -- the code path that would call
into it never runs.
Per the mission's explicit escape valve: STOP here, keep the shortcut, and
report -- do not improvise a third variant. Full diagnosis, the exact
capture, and the concrete next research question (does retail's own
transitional_insert loop check sphere_path.collide on every iteration
regardless of Phase 2's own return value, or only when Phase 2 returns OK?)
are recorded in the research doc's §7 item 6 and the doc's headline; the
campaign plan's P2 section gets a matching status note.
Physics test suite: 1841 passed, 1 skipped (D4, pre-existing/unrelated), 0
failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports the retail CACQualities/EncumbranceSystem/MovementSystem chain
(named-retail decomp pc 256393/412901-414050/416169-416320/695958+) so
PlayerWeenie's run rate, jump height, jump permission, and jump stamina
cost are real functions of burden, current stamina, and vitae/skill
enchantments instead of stubs.
Core:
- New EncumbranceSystem.cs (delegates to the already-verified
BurdenMath formulas — one source of truth for the burden HUD and
movement physics) and MovementSystem.cs (GetRunRate/GetJumpHeight/
JumpStaminaCost/GetJumpPower, decomp-cited; ACE cross-referenced
where BN dropped the general-case arithmetic entirely).
- PlayerWeenie rewritten as the CACQualities-shaped composition:
CanJump gates on burden (<2.0 load, UN-8 — x87 polarity resolved by
plausibility, Ghidra MCP unavailable this slice), JumpStaminaCost
returns the real ceil((load+0.5)*power*8+2) cost and always affords
it (matches decomp — retail's own function never refuses; "weak"
jump comes entirely from the stamina==0 skill-zeroing gate inside
InqRunRate/InqJumpVelocity, not a hard refusal), SetStamina wires a
null="unknown, don't gate" sentinel preserving every pre-P1 test.
- EnchantmentMath.GetMod gained an optional StatModType flag filter
(GetSkillMod convenience wrapper) so the SAME vitae/family-stacking
machinery already used for vital-max buffs now also answers "what's
the vitae+skill-enchantment-adjusted Run/Jump skill" — reusing the
M3 active-enchantment state, not a new engine.
Runtime:
- RuntimeCharacterState now stores the pre-EnchantSkill base run/jump
skill and recomputes the adjusted value (vitae first, then matching
Skill-flagged buffs, floor 0.5, truncate) on every base push AND on
every Spellbook.EnchantmentsChanged notification — a vitae change
alone moves the produced rate without a fresh PlayerDescription.
- RuntimeMovementSkillState extended with Burden/CurrentStamina
(RuntimeMovementSkillProjection.ApplyTo pushes both through the
existing seam); LiveSessionEventRouter recomputes burden from the
same Strength+aug-property+EncumbranceVal inputs the burden HUD
already assembles (reacting to the same ClientObjectTable events)
and pushes current stamina from LocalPlayerState vital updates.
- Wires the previously dead-lettered ReportExhaustion() R3-W4 seam:
LiveSessionRuntimeFactory's OnMovementStatsUpdated callback re-
applies the current snapshot to the live controller and forces an
immediate movement re-evaluation on any skill/burden/stamina change.
Register: retires TS-5 (CanJump/JumpStaminaCost stubs) and AP-25 (no
vitae in pushed skill). Adds AP-127 (two minor unmodeled retail bonus
properties + the stamina-buff-adjusts-local-copy nuance, deliberately
out of the bounded "run/jump query path only" scope) and UN-8 (the
CanJump x87 polarity call, flagged for a future Ghidra MCP
confirmation pass). Extends TS-23 (PlayerKillerStatus not parsed) to
cover JumpStaminaCost's new pk parameter, hardcoded false pending P3.
Full pseudocode + retail citations + the vitae/skill-level finding in
docs/research/2026-07-30-stat-coupled-movement-pseudocode.md.
Release suite: Core.Tests 3977/2 skips, Runtime.Tests 425/0 skips,
App.Tests 3968/3 skips — all green. (One pre-existing, unrelated Debug-
only flake in LandblockBuildOriginTests reproduces on the pre-P1
baseline and passes in Release; not touched here.)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Campaign P Slice P2 step 1 (docs/research/2026-07-30-response-layer-edge-family-pseudocode.md
§2, §6 Step 1/2). The TS-1 register row (retail-divergence-register.md:238)
described work that is already done: SpherePath.PrecipiceSlide,
Transition.CliffSlide, and Transition.EdgeSlideAfterStepDownFailed are real,
tested ports of retail's edge_slide -> precipice_slide/cliff_slide chain
(pc:274316, pc:272397, pc:273001-273090). Its cited :1254 line was stale
stepping-loop code the file moved past.
The one real remaining gap (the back-probe fallback skipping retail's
walkable_check_pos/localspace_sphere recache before its second
precipice_slide call, pc:274318-274326 / 0050b4e0-0050b507) needed no
production code change: a fresh read of SPHEREPATH::get_walkable_pos
(0050a8f0), cache_localspace_sphere (0050c9d0), and set_walkable_check_pos
(00509ce0) shows that machinery exists to re-project a sphere across
retail's PER-CELL local coordinate frames. acdream's SpherePath.WalkableVertices
and GlobalSphere are populated in UNIFIED WORLD SPACE at assignment time
(SetWalkable/SetWalkableTransformed, SetCheckPos/RestoreCheckPos), so both
operands BSPQuery.FindCrossedEdge compares are already commensurable --
retail's 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. Documented in-code at
the back-probe site with full citations, and pinned with
EdgeSlideBackProbePrecipiceSlideTests: 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 (and the inverse case, standing inside the polygon with no edge
crossed, correctly still returns Collided matching retail's own
precipice_slide on a false find_crossed_edge).
TS-1's other two flagged gaps are real acdream-only compensating branches,
not retail reads, and get their own rows rather than being silently
retired alongside it:
- AD-53: CliffSlide's three-source reference-normal fallback chain
(LastWalkablePlane -> LastKnownContactPlane -> world-up) vs retail's
direct last_known_contact_plane.N use. A fresh read of
last_known_contact_plane's maintenance (pc:272659-272668) confirms retail
overwrites it unconditionally every validate_transition pass, including
with a steep plane -- so the fallback chain compensates for AP-4's
incomplete OnWalkable bookkeeping, not a retail-matching read.
- AD-54: the walkable-steepness reroute to CliffSlide before PrecipiceSlide
when the stored walkable polygon itself is steeper than FloorZ. Retail's
raw edge_slide has no such branch; the permissive LandingZ acceptance
that makes this state reachable IS retail-faithful (TS-4's own
BSPTREE::find_collisions citation), but whether retail's outer
transitional_insert retry loop absorbs the resulting COLLIDED_TS some
other way is not yet independently verified -- flagged open in the row.
Physics test suite: 1836 passed, 1 skipped (D4, unrelated to this change).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>