Commit graph

199 commits

Author SHA1 Message Date
Erik
e0d2492cbb feat(R4-V1): command-selection family + state widening (closes M2-mechanics, M11, M12, M15)
MovementParameters gains the verbatim selection family:
GetCommand 0x0052aa00 (the walk-vs-run cascade INCLUDING the CanCharge
0x10 fast-path ACE dropped - retail's default can_charge=false + the
fast-path present, the A13+A15 canceling-pair trap avoided; inclusive
threshold edge per the raw), TowardsAndAway, GetDesiredHeading per the
live Ghidra decompile (fwd-towards 0 / fwd-away 180 / back-towards 180
/ back-away 0), FromWire/FromWireTurnTo (UnPackNet semantics, all 18 A4
masks round-tripped).

New MoveToMath: HeadingDiff per the live Ghidra decompile of 0x00528fb0
(the 360-diff NOT-TurnRight mirror + F_EPSILON 0.000199999995f - the
BN "arg unused" artifact corrected), HeadingGreater (the visible
TurnRight idiom), PositionHeading/Get/SetHeading reusing the codebase's
single yaw-heading convention (P5), CylinderDistance (PDB arg order;
planar-minus-radii shape documented as the interpretation - the raw's
x87 body is garbled; seam noted).

MovementType gains Invalid + retail 6/7/8/9; MovementStruct widened
(ObjectId/TopLevelId/Pos/Radius/Height/Params, additive); WeenieError
+= 0x0B/0x36/0x37/0x38/0x3D with retail-meaning doc comments.

148 new conformance tests. Full suite: 3,860 passed.

Implemented by a dedicated agent against the V0-pinned spec; scope +
suite independently verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:13:15 +02:00
Erik
fb7beb706b feat(R3-W6): LOCAL PLAYER UNIFICATION — edge-driven retail input; UpdatePlayerAnimation + the synthesis layer DELETED (closes J15)
The local player now drives the SAME pipeline remotes use. The
controller's per-frame RawMotionState rebuild (level-triggered D6.2) is
replaced by retail's CommandInterpreter altitude: key EDGES fire
DoMotion/StopMotion (0x00528d20/0x00528530) and the Shift edge fires
set_hold_run (0x00528b70, caller-0x006b33ca shape) — the interpreter's
own RawState mutates via ApplyMotion/RemoveMotion and every dispatch
routes the funnel + DefaultSink into the sequencer. GameWindow's
UpdatePlayerAnimation (169 lines) is DELETED with both
LocalAnimationCommand/LocalAnimationSpeed MovementResult fields and all
three synthesis sites (K-fix5 pacing, Walk→Run cycle selection, the
auto-walk overrides): run pacing now comes from apply_run_to_command's
my_run_rate promotion — the same source remotes use; airborne-Falling
falls out of contact_allows_move (the funnel's second dispatch).
Auto-walk's #69 turn-phase legs re-expressed as DoMotion(Turn*) edges;
teleport idle = StopCompletely (retail's full stop) + an edge-tracker
reset so held keys walk straight out of a portal.

Map discovery R1 fixed: ChargeJump() now fires at charge START (the
0x0056afac input-boundary site) — StandingLongJump had NEVER armed in
production despite the W3 port.

TWO root-cause fixes the cutover surfaced (each would have been
invisible under the old synthesis layer):
- CurrentHoldKey was a shadow FIELD synced only on the raw dispatch
  branch; set_hold_run writes RawState.CurrentHoldKey, so an edge-driven
  Shift toggle followed by DoMotion read a stale None and lost the run
  promotion. Now an alias of RawState.CurrentHoldKey (retail's
  adjust_motion reads raw_state.current_holdkey directly).
- The controller's grounded velocity + jump-launch writes used
  set_local_velocity's default autonomous:false, silently clearing
  LastMoveWasAutonomous and flipping the A3 dual dispatch to the
  interpreted branch for the local player. Both marked autonomous.

EXPECTED-DIFFS (map §6, for the R3 visual pass): #45's 1.248x sidestep
factor + ACDREAM_ANIM_SPEED_SCALE died with UpdatePlayerAnimation —
local sidestep pacing now matches how remotes have always played;
auto-walk-at-run plays walk-pace legs until R4; ApplyServerRunRate's
apply_current_movement goes live (Branch-2 fast re-speed absorbs the
echo — retail's own mid-run speed-change mechanism).

Wire: outbound values stay input-derived (the L.2b-verified byte
stream untouched; RawState-sourcing needs the map §2b cdb TODO and R7
owns outbound). MotionStateChanged = the old comparison minus the dead
localAnimCmd leg, OR any edge fired.

Full suite green: 3,731 passed. Live smoke: in-world, user-driven
input through the new path (walk/turn/strafe), 12k entities, zero
exceptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:19:56 +02:00
Erik
df7b096d6f feat(R3-W5): DoMotion family verbatim + the ONE DoInterpretedMotion + per-op zero-tick flush (closes J3, J4, J9, J14)
The two parallel DoInterpretedMotion implementations MERGE into one
verbatim pair (0x00528360/@305639): contact gate, StandingLongJump
state-only branch, Dead → RemoveLinkAnimations, the jump_error_code
double-check WITH the DisableJumpDuringLink 0x20000 leg (W2's TODO(W5)
resolved — MovementParameters now flows), ModifyInterpretedState
gating, CurCell-null tail, AddToQueue with the real ContextId;
StopInterpretedMotion's post-stop Ready node + raw-mirror RemoveMotion.
Legacy overloads + ApplyMotionToInterpretedState DELETED. The funnel's
public surface unchanged (183-case suite compiles + passes as-is).

DoMotion 0x00528d20 verbatim: cancel_moveto interrupt; SetHoldKey(key,
cancelMoveToBit) BEFORE adjust_motion; params re-default for the
interpreted call; combat-stance gates on the ORIGINAL id (Crouch/Sit/
Sleep → 0x3f/0x40/0x41; & 0x2000000 → 0x42 outside NonCombat); action
depth cap ≥6 → 0x45; raw mirror ORIGINAL id. StopMotion 0x00528530
mirror shape. StopCompletely 0x00527e40 with the A9 snapshot quirk
(motion_allows_jump on the OLD forward command, stashed in the queued
node) + the J9 fix (sidestep/turn SPEEDS untouched — the 1.0 resets
were a divergence). PerformMovement flushes zero-tick completions after
every dispatched op via the new CheckForCompletedMotions seam
(0x0050fe30) — bound App-side per entity (remotes via
EnsureRemoteMotionBindings, player at the sequencer bind site).

PORT DISCOVERY (not in the W0 pins): retail's DoInterpretedMotion
RESULT gates both add_to_queue AND the state write — a void sink let
the style dispatch's apply-only failure clobber ForwardCommand before
the forward axis read it, regressing 74/183 live-trace cases.
IInterpretedMotionSink.ApplyMotion/StopMotion now return bool
(documented on the interface with the raw anchors); one funnel
assertion corrected with raw-line citations (airborne ForwardCommand
ends at Falling under the fully-wired verbatim algorithm).

62 new gate-table tests. Full suite: 3,729 passed.

Implemented by a dedicated agent against the W0-pinned spec; seam
bound + suite independently re-verified by the orchestrator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 08:57:33 +02:00
Erik
e214acdf23 feat(R3-W4): ground transitions + lifecycle verbatim; K-fix18 DELETED (closes J8, J10, J11-shape, J12, J13, J18, J19)
Core (dedicated agent, independently reviewed): HitGround 0x00528ac0 /
LeaveGround 0x00528b00 verbatim (creature+gravity gates, the
RemoveLinkAnimations seam — K-fix18's retail mechanism — velocity via
GetLeaveGroundVelocity with the autonomous flag, jump-state resets,
apply_current_movement re-sync); enter_default_state 0x00528c80 per A8
(fresh states, InitializeMotionTables seam, sentinel APPENDED without
draining pending_motions — pinned, Initted=1, LeaveGround tail);
Initted gates; the A3 IsThePlayer dual dispatch in
apply_current_movement / ReportExhaustion / SetWeenieObject /
SetPhysicsObject (a remote player routes INTERPRETED — the
ACE-divergence pin); set_hold_run 0x00528b70 + SetHoldKey 0x00528bb0
(XOR guard, None-only-from-Run); adjust_motion creature guard wired
(TS-34 retired); PhysicsBody.LastMoveWasAutonomous +
set_local_velocity(autonomous). Port discovery: retail's
apply_raw_movement 0x005287e0 / apply_interpreted_movement 0x00528600
ARE the already-shipped D6.2a/funnel functions — the dual dispatch
composes them instead of duplicating.

App cutover (orchestrator): the skipTransitionLink flag + both K-fix18
call sites DELETED (AP-74 retired). MotionInterpreter.DefaultSink routes
apply_current_movement's interpreted branch through the REAL funnel
dispatch when a sink is bound — so a remote's LeaveGround engages
Falling via the contact-gated funnel, replacing the forced SetCycle
(J19); the per-remote MotionTableDispatchSink is now PERSISTENT
(EnsureRemoteMotionBindings: DefaultSink + RemoveLinkAnimations +
InitializeMotionTables seams, idempotent from both the UM and
VectorUpdate paths; wire velocity re-applied after LeaveGround so it
stays authoritative). Player: seams bound to the player sequencer; the
controller's grounded→airborne EDGE now fires LeaveGround (jump()
clears OnWalkable and the same frame's transition detection fires it —
retail's order; walk-off-a-ledge gets the momentum fallback + link
strip it never had); the manual jump-block LeaveGround deleted;
LastMoveWasAutonomous set at the controller chokepoint (W6 refines).

Trace S8 re-expressed as the retail mechanism (Falling dispatch +
RemoveAllLinkAnimations = same final state the flag produced). 43 new
lifecycle tests. Registers: TS-34 + AP-74 retired; TS-38, AP-77, AP-78
added. Full suite: 3,665 passed. Live smoke: in-world clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:01:31 +02:00
Erik
af4764443f feat(R3-W3): verbatim jump family — charge gates, jump_is_allowed chain, epsilon fixes; StandingLongJump misattribution retired (closes J5, J6, J7-interp-side, J16-epsilons)
JumpChargeIsAllowed 0x00527a50 (CanJump→0x49 before posture; Fallen +
Crouch..Sleeping→0x48), ChargeJump 0x005281c0 — now the ONLY place
standing_longjump arms (grounded Contact+OnWalkable + forward Ready +
no sidestep/turn, raw @305453-305466); the S2a-flagged side effect
inside contact_allows_move is DELETED (J6) with regression pins that
contact checks never touch the flag (the funnel's StandingLongJump
branch is local-player scope; the controller wires ChargeJump in W4/W6
— remotes never charge client-side, no interim regression).

jump_is_allowed 0x005282b0 full chain replaces the 15-line
approximation: IsFullyConstrained→0x47 (new PhysicsBody stub, TS-35)
BEFORE the pending-head peek (A2 ordering, pinned); head peek fires
whenever the queue is non-empty (no ACE Count>1 gate; nonzero
jump_error_code short-circuits the whole chain — test proves
JumpStaminaCost is never consulted); retail entry shape verbatim
(non-creature-weenie and gravity-off skip the ground gate; null
physics obj → 0x24 NOT 8 per A10); charge → MotionAllowsJump(fwd)
double-check → JumpStaminaCost gate (new IWeenieObject member,
always-affordable PlayerWeenie stub — TS-5 extended).

GetJumpVZ/GetLeaveGroundVelocity: epsilon corrected to retail's
0.000199999995f (was 0.001 — regression-pinned at extent 0.0005);
A5 shape (clamp 1.0, weenie-null 10.0f); A6 momentum fallback fires
only when ALL THREE components are sub-epsilon and overwrites all
three with global→local(m_velocityVector).

Jump 0x00528780: InterruptCurrentMovement no-op seam (TS-36, →R4
cancel_moveto), fires unconditionally; standing_longjump cleared ONLY
on failure (success keeps it — pinned). IWeenieObject.IsThePlayer()
(PlayerWeenie true) lands for W4's A3 dispatch.

51 new tests + 1 re-pin; superseded pre-W3 jump tests removed. Full
suite: 3,623 passed. Registers: TS-5 extended, TS-35/36/37 added.

Implemented by a dedicated agent against the W0-pinned spec; arming
gates + entry shape verified against the quoted raw text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:37:07 +02:00
Erik
371679915e feat(R3-W2): pending_motions lifecycle + the MotionDone consumer — the R2 seam lands (closes J1, J17)
MotionInterpreter implements IMotionDoneSink: pending_motions
(LinkedList<MotionNode>, AD-34 wording) + add_to_queue 0x00527b80 +
motions_pending 0x00527fe0 + MotionDone 0x00527ec0 verbatim (action-class
head → UnstickFromObject no-op seam (→R5 StickyManager) + BOTH states'
action-FIFO pops; head popped UNCONDITIONALLY — A7: never match-by-id,
params unread in this build) + HandleExitWorld 0x00527f30 (the raw
decompile's null-physics-obj branch would infinite-loop if translated
literally — adjudicated as retail dead code, documented on the method) +
is_standing_still 0x00527fa0 + MotionAllowsJump 0x005279e0 with the
A1-pinned literal blocklist (28-case boundary table test: Fallen blocks,
Falling passes — ACE's transposition NOT copied).

Queue producers wired into the funnel per the decomp anchors
(re-verified from the raw text): DispatchInterpretedMotion's success
path computes jump_error_code via retail's double-check shape
(motion_allows_jump(motion), then forward_command when non-action;
the DisableJumpDuringLink params leg is TODO(W5) — no MovementParameters
threaded yet) + add_to_queue; apply_interpreted_movement's turn-stop
re-queues the Ready node (@305766-305785 — the plan's producer #2 and #3
are the SAME site; StopInterpretedMotion's internal add_to_queue @305657
is W5 scope). ContextId=0 with TODO(W5): retail's own compiled code
reads an UNINITIALIZED local for it at this call site.

GameWindow: MotionDoneTarget now binds the entity's REAL consumer —
player via PlayerMovementController.Motion (new internal property),
remotes via RemoteMotion.Motion — resolved AT FIRE TIME (a remote's
RemoteMotion can be created after its first anim tick; an eager capture
would drop completions forever). Despawn runs BOTH layers' exit-world
drains (manager then interp) per §4. AD-36 narrowed (consumed for
creature-class; doors/statics recorder-only until R5).

51 new conformance cases incl. the end-to-end chain test:
MotionTableManager.AnimationDone → sink → interp pending head pops in
step. 183-case observer-trace + funnel suites green unchanged (queue
side effects additive). Full suite: 3,582 passed.

Implemented by a dedicated agent (MotionInterpreter side) against the
W0-pinned spec; producer placements + MotionAllowsJump branch algebra
independently reviewed against the raw decomp; GameWindow wiring by the
orchestrator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:24:56 +02:00
Erik
8664959152 feat(R3-W1): retail state completion — action FIFOs, MovementParameters, MotionNode, WeenieError renumber (closes J2, J16-codes)
InterpretedMotionState + the unified RawMotionState (LegacyRawMotionState
folded away) gain retail's action FIFO + ApplyMotion/RemoveMotion, ported
verbatim from the raw named decomp (0x0051e790/0x0051eb60 region, pc
293252-293703) including two genuine retail quirks pinned by tests and
independently re-verified against the raw text before commit:
- RawMotionState::ApplyMotion's RunForward (0x44000007) dead branch — a
  cycle-class apply of literal RunForward writes NOTHING (retail encodes
  run as WalkForward + HoldKey_Run on the raw state; same family as the
  D6 apply_run_to_command early-return).
- InterpretedMotionState::RemoveMotion's exact-match-only TurnRight/
  SideStepRight handling (left variants fall through to the style/
  forward branches).

MovementParameters verbatim (A4 pin): 18 named flags per the absolute
mask table, ctor = 0x1EE0F expansion + distance_to_object 0.6 /
fail_distance FLT_MAX / speed 1 / walk_run_threshhold 15 / hold_key
Invalid — with the two ACE-divergence traps ported RETAIL-side
(can_charge FALSE where ACE defaults true; threshold 15.0 not 1.0).
MotionNode {ContextId, Motion, JumpErrorCode} (acclient.h:53293) — the
pending_motions node W2 consumes.

WeenieError renumbered to retail's numeric semantics (A10 table):
NotGrounded=0x24, GeneralMovementFailure 0x24→0x47, new 0x3f/0x40/0x41
combat-stance rejects + 0x42 chat-emote + 0x45 action-depth; the
airborne jump/action returns corrected 0x48→0x24 per the A10 sweep
(incl. jump_is_allowed's null-physics-obj 0x24-not-8 divergence from
ACE). Codes are local-only — wire untouched.

Outbound packer proof: RawMotionStatePacker unmodified; all 6
golden-byte RawMotionStatePackTests pass unmodified. TS-24 register row
updated (capability landed; outbound wiring still open).

Implemented by a dedicated agent against the W0-pinned spec; quirks,
scope, and suite independently verified. Full suite green: 3,531
(374+425+713+2015+4 pre-existing skips).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:12:33 +02:00
Erik
d82f07d4e5 feat(R2-Q5): RemoteMotionSink DELETED — funnel dispatches straight into PerformMovement; AP-73 retired
The funnel's retail-ordered dispatches now go directly into the entity's
motion-table stack via Motion/MotionTableDispatchSink (Core):
ApplyMotion → PerformMovement(InterpretedCommand), StopMotion →
PerformMovement(StopInterpretedCommand). No axis collection, no
single-cycle priority pick, no Commit pass, no HasCycle probe, no
Run→Walk→Ready fallback chain — GetObjectSequence 0x00522860 +
is_allowed decide (closes H4, H5-callers, H11-callers, H15, H17-carry).
Run-while-turning now blends for real: the turn is a Branch-4 modifier
combined over the untouched run substate (AP-73 DELETED from the
register).

AnimationSequencer gains the public PerformMovement passthrough (lazy
initialize_state + locomotion velocity synthesis on success — AP-75;
remote body translation via PositionManager.ComputeOffset depends on
CurrentVelocity) and InitializeState(); HasCycle DELETED (the miss
hazard is structurally gone — GetObjectSequence checks the cycle before
any surgery; new conformance test pins sequence+state untouched on a
miss).

GameWindow: sink swap with the TurnApplied/TurnStopped ObservedOmega
callbacks (H17 carried verbatim — register AP-76, retire R6); spawn +
door sites run retail's enter-world order (initialize_state installs
the table default, the wire's initial motion dispatches unguarded — the
L.1c fallback chains deleted); despawn drains the pending queue via
HandleExitWorld (MotionDone success:false per entry, 0x0051bda0).

5 new MotionTableDispatchSink conformance tests (lazy init, Branch-4
turn blend + callback, Case-B stop unwind, Case-A stop re-drive,
miss no-op). Full suite green: 3,462 passed.

Live smoke vs ACE: in-world, NPC emote/Ready UMs dispatching through
the new path, [MOTIONDONE] completions firing across entities — first
live proof of the Q3→Q4→Q5 chain. Zero exceptions.

Registers: AP-73 deleted; AP-76 added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:55:13 +02:00
Erik
3b9d9bb6be feat(R2-Q4): adapter cutover — SetCycle/PlayAction rehosted on PerformMovement; Fix B / fast-path / stop-anim fallback / G17 gate DELETED
AnimationSequencer becomes a thin shim over the verbatim R2 stack:
SetCycle dispatches the style change (Branch 1) then the motion (Branch
2/3/4) through MotionTableManager.PerformMovement; PlayAction is the
same dispatch (actions rebuild via Branch 3; modifiers are Branch 4
physics-only combine_motion — the AP-73 turn-blend mechanism now runs
for real). CurrentStyle/CurrentMotion/CurrentSpeedMod are read-only
mirrors of MotionState (post-adjust_motion, signed mod). Lazy
initialize_state on first drive (retail lazy-create analog) keeps
undriven sequencers do-nothing.

DELETED legacy inventions (net -648 lines): the adapter fast-path
(replaced by Branch-2 fast re-speed: change_cycle_speed +
subtract/combine_motion), Fix B + IsLocomotionCycleLowByte (replaced by
remove_redundant_links on the pending queue — the retail mechanism the
2026-05-03 cdb trace pointed at), the stop-anim low-byte fallback
(replaced by get_link's reversed-key double-hop — retail's actual
backward-walk settle plays the windup link REVERSED), GetLink/BuildNode/
EnqueueMotionData + the G17 HasVelocity/HasOmega gate (add_motion sets
unconditionally), MultiplyCyclicFramerate + the G13 composite,
PlayAction's insert-before-tail machinery, SCFAST/SCFULL diagnostics.
KEPT at the adapter (register rows): the boundary adjust_motion remap +
locomotion velocity/omega synthesis (AP-75, retire R3-W6/R6), K-fix18
skip-link as post-dispatch RemoveAllLinkAnimations (AP-74, retire
R3-W4 when LeaveGround fires it).

GameWindow queue-drain wiring (the §4 G6 seam): each drained AnimDone
hook → manager.AnimationDone(true) (retail AnimDoneHook::Execute
0x00526c20 → Hook_AnimDone 0x0050fda0 chain), UseTime once per tick
(0x00517d57/0x00517d67); IMotionDoneSink bound to an
ACDREAM_DUMP_MOTION recorder (AD-36 — R3 rebinds to
MotionInterpreter.MotionDone).

Conformance: the 11-scenario pre-cutover trace suite (a6235a36) replays
green with six EXPECTED-DIFF annotations (double-hop walk-run routing,
reversed settle links, Branch-4 physics-only modifiers, Branch-1
style-change links, post-adjust mirrors) — everything unannotated is
byte-identical. Legacy suites repaired by a dedicated agent (fixtures
gain the retail-mandatory StyleDefaults + class-bit-tagged ids;
reflection state-poking replaced with real dispatch; zero category-D
regressions — the suspected cursor bug was a fixture class-bit gap),
plus two vacuously-passing tests fixed. Register: stale IA-4 deleted
(R1-P1 ported the negative-factor swap).

Full suite green: 3,458 passed (374+425+713+1946).

Closes H6, H7, H8, H9, H10-adapter, H16-wiring (r2-port-plan.md §3 Q4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:41:27 +02:00
Erik
a6235a36f5 test(R2-Q4a): pre-cutover adapter trace goldens
11 scripted scenarios (spawn idle, idle→walk link, walk→run cyclic-to-
cyclic, fast re-speed, backward-walk remap, stop-settle fallback, emote
action, K-fix18 skip-link, turn modifier, style change, link-drain
sentinel count) snapshotting the CSequence core's list state after every
SetCycle/PlayAction against the LEGACY adapter (fast-path + Fix B +
stop-anim fallback + G17 gate). These are the parity bar for the Q4
PerformMovement cutover — intentional post-cutover changes get
EXPECTED-DIFF annotations, everything else must stay byte-identical.

Notable pre-cutover behavior captured: the S6 link-before-link stacking
(old reversed link kept mid-drain while the settle link appends behind).

Adds the internal AnimationSequencer.Core test seam.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:12:15 +02:00
Erik
aa65990a1d feat(R2-Q3): verbatim MotionTableManager — the pending-animation queue (closes H3, H15-core)
Ports retail's MotionTableManager (0x0051bxxx; r2-motiontable-decomp.md
§11) above the Q2 CMotionTable: add_to_queue 0x0051bfe0 (append +
immediate collapse), remove_redundant_links 0x0051bf20 (TAIL-ANCHORED
single scan, NOT ACE's restructured loop — cycle-tail block mask
0xb0000000 = STYLE|MODIFIER|ACTION, style-tail 0x70000000 =
CYCLE|MODIFIER|ACTION; the decomp's prose gloss of those masks was
imprecise, the literal constants are ported), truncate_animation_list
0x0051bca0 (zero NumAnims IN PLACE from the tail through the matched
node's successor + CSequence.RemoveLinkAnimations), AnimationDone
0x0051bce0 (counter-driven countdown chain: one call can pop MULTIPLE
entries; action-class pops MotionState's action FIFO; drained-list
counter reset), CheckForCompletedMotions 0x0051be00 / UseTime (zero-tick
sweep, success hardcoded true), initialize_state 0x0051c030 (0x41000003
sentinel), HandleEnterWorld/HandleExitWorld drains (MotionDone
success:false; enter also strips link anims), PerformMovement 0x0051c0b0
(error codes 7 / 0x43 / 0; StopCompletely queues the Ready sentinel
UNCONDITIONALLY).

IMotionDoneSink is the R2 seam standing in for CPhysicsObj::MotionDone —
R3 binds MotionInterpreter.MotionDone (r2-port-plan.md §4 contract).

This queue + remove_redundant_links IS the retail mechanism our old
'Fix B' rapid-motion collapse approximated — Q4 deletes Fix B and routes
SetCycle/PlayAction through PerformMovement.

47 conformance tests: countdown-chain tables, truncate blocked/allowed
matrices for both masks, zero-tick vs counter sweep, world drains, the
walk-run-walk-run collapse trace, the 2026-05-03 walk-to-run golden
(both nodes queued, truncate not firing), PerformMovement error matrix.

Register: AD-34 extended (pending_animations managed LinkedList);
AD-35 added (NotHandled sentinel vs retail's dead-code pointer-leak
default case).

Implemented by a dedicated agent against the committed Q3 spec; diff
scope, mask semantics, truncation range, counter reset, and
PerformMovement paths independently verified against the decomp raw
text before commit. Build + full suite green (3,447 passed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:01:50 +02:00
Erik
98f58db913 feat(R2-Q2): verbatim CMotionTable — the GetObjectSequence dispatcher
CMotionTable (Core/Physics/Motion, 692 lines) wrapping the dat
MotionTable, per r2-motiontable-decomp.md + Q0 pins:

- GetObjectSequence (0x00522860): entry guards; modifier-class no-op
  fast path; Branch 1 style-change (exit link + style link +
  default_style double-hop + re_modify); Branch 2 cycle (UNCONDITIONAL
  default-style retry per label_522ae6, is_allowed gate, same-substate
  re-speed fast path = ChangeCycleSpeed + SubtractMotion(old) +
  CombineMotion(new), clear-modifiers bit0, direct-link vs !SameSign
  double-hop, A2 signedSpeed, outgoing-modifier re-registration,
  re_modify, outTicks per A3); Branch 3 action (direct link OR the
  4-layer out-and-back with the base cycle re-added at the OLD
  substate_mod; outTicks WITHOUT ACE's double-count, A4-#1); Branch 4
  modifier (PHYSICS-ONLY CombineMotion + AddModifier stop-then-re-add —
  the AP-73 retirement mechanism).
- get_link (A1 pin: either-negative -> swapped keys — the adapter's
  field-validated port re-homed), is_allowed (Bitfield & 2; A5
  CONFIRMED on DatReaderWriter 2.1.7), re_modify (deep-copy snapshot
  termination bound), StopSequenceMotion, SetDefaultState,
  DoObjectMotion/StopObjectMotion/StopObjectCompletely (A4-#4 return).
- Free functions: AddMotion (UNCONDITIONAL velocity/omega set — the
  G17 core), CombineMotion/SubtractMotion (physics-only),
  ChangeCycleSpeed (verbatim incl. the A4-#2 retail gap), SameSign.

44 conformance tests pinning H1/H4/H5/H7/H8/H10-H14 + all Q0 pins,
incl. the run-while-turning gated-cycle -> Branch-4 physics-only test
(retail's actual turn-blend mechanism) and missing-cycle ->
sequence-untouched (retires the HasCycle fallback rationale).

Implemented by a dedicated agent against the Q2 spec; diff + key
branches reviewed, suite re-verified (3934 green) before commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:48:26 +02:00
Erik
2345da30e9 feat(R2-Q1): verbatim MotionState (gap H2)
Style/Substate/SubstateMod + the two independent MotionList chains with
retail's exact disciplines: modifier PUSH-FRONT stack
(add_modifier_no_check 0x00525ff0; add_modifier 0x00526340 refusing
duplicates AND the current base substate; remove_modifier by node;
clear_modifiers) and action TAIL-APPEND FIFO (add_action 0x005260a0;
remove_action_head 0x00526120 returning the popped motion, 0 when
empty; clear_actions). Deep-copy ctor clones both chains (Q0-pins
A4-#5: re_modify's snapshot is a termination bound, never shared).

11 discipline-table tests. Next: Q2 (CMotionTable + free functions —
the GetObjectSequence dispatcher).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:31:16 +02:00
Erik
a987cad182 feat(R1-P6): root-motion Frame seam + dead-API removal — R1 COMPLETE
- Advance(dt, Frame? rootMotionFrame) overload: retail's actual root-
  motion contract (CSequence::update(quantum, Frame*) 0x00525b80) —
  every crossed frame's pos_frame combines into the caller's Frame plus
  the sequence velocity/omega via apply_physics. This is the seam R6's
  retail per-tick order (CPartArray.Update -> adjust_offset ->
  Frame.combine) consumes.
- DELETED: ConsumeRootMotionDelta + the dead adapter accumulator fields
  (zero external callers; gap-map API-migration table).
- Root-motion test now asserts REAL accumulation through the wired
  Frame path (replaces the P5 inert-stub pin).
- Phase R plan: R1 stage marked SHIPPED with its commit trail.

Full suite green (3346). R1 done: P0 research/pins -> P1 node -> P2
container -> P3 physics -> P4 advance core -> P5 adapter cutover ->
P6 wiring. Next: R2 (GetObjectSequence + MotionTableManager; extraction
workflow already running).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:24:01 +02:00
Erik
9147344a6f feat(R1-P5): AnimationSequencer rehosted on the verbatim CSequence core
The adapter keeps its full public surface (every consumer compiles
unchanged) while the internals move to Motion.CSequence:

DELETED (legacy invented mechanisms, per the R1 gap map + Phase R
mandate): the AnimNode class + adapter queue/_currNode/_firstCyclic/
_framePosition, the ACE-fabricated FrameEpsilon boundary math, the
safety=64 advance cap, ClearCyclicTail surgery, the stale-head
_currNode relocation block, per-node IsLooping/Velocity/Omega, the
adapter-side AdvanceToNextAnimation/ApplyPosFrame/ExecuteHooks, the
per-node !IsLooping AnimationDone gate (now the core's list-structure
head != first_cyclic gate, G5).

REHOSTED: SetCycle rebuild = RemoveCyclicAnims (+ClearAnimations for
K-fix18) + retail add_motion appends (framerate-only AnimData scaling,
G10 loop membership: the LAST appended node is the cyclic tail) + Fix B
via RemoveAllLinkAnimations (the core's snap-forward IS Fix B's
behavior); Advance = core.Update(dt, null) with hooks flowing through
an IAnimHookQueue adapter into the existing ConsumePendingHooks drain;
fast path = core.MultiplyCyclicAnimationFramerate + the adapter-level
velocity rescale (R2's change_cycle_speed composite stand-in, G13).
Kept byte-identical: adjust_motion remap, GetLink + stop-anim fallback,
K-fix18, velocity synthesis, SlerpRetailClient, the #61 render-side
clamp, SCFAST/SCFULL diagnostics.

Tests: 2 updated with decomp citations — the reverse-start boundary now
pins retail's bare-int HighFrame+1 (0x00525c80, no epsilon, G1); the
root-motion accumulation test pins the inert-stub state pending P6's
Frame wiring (G7). All 44 sequencer tests + the 56 core tests + full
suite green (3346).

Implemented by a dedicated agent against the P5 spec; diff + tests
reviewed, suite re-verified before commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:20:58 +02:00
Erik
658b91d8aa feat(R1-P4): verbatim update_internal / update / advance_to_next_animation
The CSequence frame-advance core (gaps G3/G4/G5/G6/G8/G9/G19),
ACE-verified skeleton + retail constants + the P0-pinned leftover carry:

- update_internal (0x005255d0): frame_number += framerate*dt; overshoot
  clamps to the RAW high/low frame with leftover-time computation +
  animDone; the pose/physics/hook triple fires for EVERY crossed integer
  frame (ascending fwd / descending rev, strict > < boundaries, NO
  epsilon, NO safety cap); AnimDone is a LIST-STRUCTURE gate (head !=
  first_cyclic) queuing the global AnimDoneHook; iterative loop carries
  the leftover into the next node (P0 pin — a lag spike fast-forwards
  through multiple queued nodes in one tick).
- advance_to_next_animation (0x005252b0): four pose ops per transition
  (subtract1 outgoing @ current frame + residual physics; step — fwd
  wraps to first_cyclic, REVERSE wraps to the LIST TAIL, asymmetric by
  design; reseed from the incoming direction-aware boundary; combine
  incoming + physics). Pose ops run in BOTH directions — ACE's
  framerate-sign gates are ACE-isms, decomp is unconditional modulo the
  degenerate-framerate guard.
- update (0x00525b80): non-empty -> update_internal + apricot; empty +
  frame -> accumulated-physics free motion (G8).
- execute_hooks (0x00524830): direction filter (Both or match) QUEUING
  into the IAnimHookQueue host seam (stands in for CPhysicsObj.anim_hooks
  + the AnimDoneHook singleton; drain placement moves in R6). Null part
  frame guarded (documented safe divergence vs retail's latent deref).
- FrameOps.Combine/Subtract1: the AFrame pose composition (verified
  math from the pre-R1 port, now against DatReaderWriter Frame).

10 conformance tests: single-tick goldens, exact-integer boundary
sit (the old #61 flash class), cyclic wrap without AnimDone,
link->cycle fast-forward proving hook order (link hooks -> ANIMDONE ->
cycle hooks) AND the carry, reverse descending hooks with the OOB
high+1 start, reverse tail-wrap, zero-framerate physics-only,
empty-list free motion, pos-frame root motion into the caller Frame,
apricot trim via update. Full suite 3346 green.

R1 remaining: P5 (adapter cutover — AnimationSequencer rehosted on the
core, legacy epsilon/stale-head/safety-cap DELETED) + P6 (root-motion
wiring + API narrowing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:05:04 +02:00
Erik
5138b8fb01 feat(R1-P3): verbatim apply_physics + Frame rotate/grotate
- CSequence.ApplyPhysics (0x00524ab0): copysign semantics — magnitude
  from the quantum arg, sign from the sign-source arg (call sites pass
  1/framerate + signed elapsed); origin += velocity*signed, then
  rotate(omega*signed).
- FrameOps.GRotate (0x005357a0): axis-angle quaternion (angle=|v|,
  half-angle sin/cos) PREMULTIPLIED onto the orientation — incremental
  rotation in WORLD space; |v|^2 < F_EPSILON^2 skipped.
- FrameOps.Rotate (0x004525b0): local rotation vector mapped through
  the frame's local->global rotation, then GRotate.

7 numeric conformance tests (copysign matrix, global-space composition,
local->global mapping equivalence, epsilon gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:53:32 +02:00
Erik
778744bf3e feat(R1-P2): verbatim CSequence container + list surgery
CSequence (Core/Physics/Motion): anim-node list with retail's exact
cursor semantics —
- append_animation (0x00525510): first_cyclic slides to the JUST-
  APPENDED node on EVERY call (the cyclic tail is always the last
  appended node); curr_anim seeds to head + get_starting_frame only
  when null; unresolvable anims discarded (G10);
- remove_cyclic_anims (0x00524e40): removed curr_anim snaps BACK to
  prev at get_ending_frame (or 0.0); first_cyclic = new tail;
- remove_link_animations/remove_all_link_animations (0x00524be0/
  0x00524ca0): removed curr_anim snaps FORWARD to first_cyclic at
  get_starting_frame (G11);
- apricot (0x00524b40, PDB-verified retail name): consumed-head trim
  bounded by curr_anim AND first_cyclic;
- clear (0x005255b0) resets placement fields too — raw body is
  authority over the gap map's G20 note;
- sequence-level velocity/omega with set/combine/subtract (G12);
- multiply_cyclic_animation_fr touches framerates ONLY (G13 —
  velocity rescale belongs to R2's change_cycle_speed composite);
- placement frame family + floored accessors (G14).

Register: AD-33 (double vs x87 long double frame_number, G15),
AD-34 (managed LinkedList vs intrusive DLList).

17 list-surgery state-table tests; 39 total R1 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:50:19 +02:00
Erik
1371c2a14c feat(R1-P0/P1): CSequence research base + verbatim AnimSequenceNode
P0 — research + pins: full CSequence-family verbatim extraction (1756
lines, per-function raw pseudo-C + cleaned flow, decomp line anchors),
ACE cross-reference (9 ranked divergences; headline: retail frame_number
is x87 long double — ACE's float is the worst case, our double the best
available; ACE's frame-boundary epsilon is an ACE fabrication, NOT
retail), current-sequencer map, and the R1 gap map (20 gaps, 13 keeps,
P0-P6 port order). Pinned the one decomp ambiguity (leftover-time carry
after advance_to_next_animation — ACE reading adopted; cdb confirmation
protocol recorded, non-blocking).

P1 — AnimSequenceNode verbatim (gap G1/G2/G16/G18):
- direction-aware BARE-INT boundary pair (get_starting_frame 0x00525c80 /
  get_ending_frame 0x00525cb0): reverse starts at high+1, ends at low —
  NO epsilon;
- multiply_framerate (0x00525be0) swaps low/high on negative factor;
- set_animation_id (0x00525d60) retail clamp order (high<0 -> num-1;
  low>=num -> num-1; high>=num -> num-1; low>high -> high=low);
- ctors with retail defaults (30f/-1/-1; AnimData copy + clamp);
- get_pos_frame null out-of-range (retail; ACE returns identity),
  floor double overload; get_part_frame same discipline;
- NO per-node IsLooping/Velocity/Omega — loop membership is list
  structure, physics accumulators live on the sequence (G16).

22 conformance tests (clamp table, boundary mirror table, swap
round-trip, bounds/floor semantics).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:45:56 +02:00
Erik
7b0cbbda2c feat(L.2g-S2a): CMotionInterp inbound funnel in Core + live-retail conformance harness (DEV-1 core)
Port the inbound funnel verbatim into MotionInterpreter:
- MoveToInterpretedState (0x005289c0): raw-state style adopt, FLAT
  copy_movement_from overwrite, apply, then action replay under the
  15-bit server_action_stamp wraparound gate (local player skips its
  own autonomous echoes).
- ApplyInterpretedMovement (0x00528600): my_run_rate cache from
  RunForward speed, then retail dispatch order style -> forward-or-
  Falling -> sidestep(-stop) -> turn(-stop), turn early-return.
- DispatchInterpretedMotion (0x00528360): contact-gated sink dispatch
  (IInterpretedMotionSink = the GetObjectSequence backend the App
  implements); blocked non-action motions take the apply-only path —
  retail's real mechanism behind K-fix17's 'airborne remotes keep
  their cycle' empirical guard.
- contact_allows_move REWRITTEN VERBATIM from the real 0x00528240
  (pseudo-C 305471): Falling/0x40000011 + turns always allowed,
  non-creature weenies + no-gravity bypass, else Contact+OnWalkable.
  The previous body conflated jump_charge_is_allowed (0x00527a50)
  posture checks — the 2026-06-04 deep-dive divergence, now retired.
  Six tests that pinned the misattributed behavior corrected
  (grounded posture does NOT block motion; airborne accepts
  Falling/turns only).
- IWeenieObject.IsCreature (default true) for the gate's non-creature
  bypass.

Conformance harness (the user-requested 'prove it equals retail'
apparatus, layer 1): RetailObserverTraceConformanceTests parses the
LIVE cdb trace of a retail observer (Fixtures/l2g-observer-trace.log,
captured via tools/cdb/l2g-observer.cdb) into 183 golden cases —
each [MTIS] input state replayed through our funnel must produce
retail's exact [DIM] dispatch sequence. 183/183 conformant, including
the airborne jump case (replayed contact-free; pre-gate vs post-gate
accounting + HitGround second-pass truncation documented in-test).
13 synthetic funnel tests cover the branches the trace missed
(actions, stamps, longjump, sidestep).

GameWindow integration (S2b) follows; funnel not yet wired.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:50:09 +02:00
Erik
cb74e64343 feat(L.2g-S1): retail movement-event staleness gate (DEV-6)
Port retail's three-stamp inbound gate for 0xF74C UpdateMotion:

- MotionSequenceGate (Core/Physics): CPhysicsObj::is_newer (0x00451ad0)
  wraparound u16 compare, verbatim per ACE PhysicsObj.is_newer (the BN
  pseudo-C setcc returns are garbled; ACE + branch structure are the
  oracle). Gates: INSTANCE_TS at dispatch (stale incarnation drops
  before any stamp is touched), MOVEMENT_TS strictly-newer (stamped
  BEFORE the server-control check, per CPhysics::SetObjectMovement
  0x00509690), SERVER_CONTROLLED_MOVE_TS drop-when-stored-newer.
- Seed from CreateObject's PhysicsDesc timestamp block (index 1 =
  ObjectMovement now parsed; ACE WorldObject_Networking.cs:411-420
  order) — without seeding, entities whose movement sequence is past
  0x8000 at spawn would drop every UM against a zero stamp.
  Adopt-on-first / advance-only-after, so the #138 rehydrate replay of
  retained spawns cannot regress live stamps.
- UpdateMotion + EntitySpawn now carry instance/movement/serverControl
  sequences + isAutonomous (was parsed-past; isAutonomous feeds the
  S2 funnel's last_move_was_autonomous). Gate wired at the top of
  OnLiveMotionUpdated before any state mutation; [UM_STALE] diag under
  ACDREAM_DUMP_MOTION / ACDREAM_REMOTE_VEL_DIAG; gate dropped with the
  entity on DeleteObject.

Register: AD-32 added (adopt-newer-incarnation instead of retail's
QueueBlobForObject); TS-26 updated (UM side closed, UP side open).
Deviation map: docs/research/2026-07-02-inbound-motion-deviation-map.md.

19 new gate tests + parser coverage; full suite 3276 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:44:06 +02:00
Erik
0f099bb652 feat(D6.2a): port CMotionInterp motion normalization + unify local velocity/turn/jump
Ports retail's raw->interpreted motion normalization and routes the local
player's velocity through it, so backward/strafe come from the retail source
instead of hand-mirrored controller code (retires register TS-22 + UN-5).

MotionInterpreter (decomp-verbatim, Ghidra/ACE-cross-checked):
- adjust_motion (0x00528010): WalkBackward->WalkForward (x-0.649999976),
  TurnLeft->TurnRight (x-1), SideStepLeft->SideStepRight (x-1), sidestep scale
  0.5*(WalkAnimSpeed/SidestepAnimSpeed), RunForward early-return, per-channel
  holdkey fallback to CurrentHoldKey.
- apply_run_to_command (0x00527be0): WalkForward->RunForward when speed>0 +
  UNCONDITIONAL *runRate; TurnRight *1.5; SideStepRight *runRate clamp +/-3.0.
- apply_raw_movement (0x005287e0): copy raw->interpreted, adjust the 3 channels
  with per-channel hold keys.
- WalkAnimSpeed unified to the retail-exact 3.11999989f (was 3.12f).

PlayerMovementController: build ONE RawMotionState from MovementInput at
forward_speed=1.0 (apply_run_to_command applies the run rate — a pre-scaled
speed would double-scale), run apply_raw_movement, then take grounded + jump
velocity straight from get_state_velocity (now correct for all directions) and
drive the keyboard turn omega from the interpreted turn_speed (BaseTurnRate x
turn_speed = the same pi/2 formula the remote path uses; numerically identical
to the old TurnRateFor). Deleted the hand-mirrored backward(x-0.65) /
strafe(x+-1.25) formulas in both the grounded and jump blocks. Mouse turn and
the auto-walk path are untouched.

Retail-faithful behavior changes (confirm in smoke): strafe is now
1.25 * ~1.248 * runRate = ~1.56*runRate, clamped via SideStepSpeed<=3.0 so
|v.X|<=3.75 (was 1.25*runRate, no scale/clamp); backward is unchanged
(3.12*0.65*runRate); backward/strafe-left velocity now comes from the pipeline
instead of returning zero. Forward run pace unchanged (4.0*runRate).

Tests: MotionNormalizationTests (17, adjust_motion/apply_run_to_command) +
MotionVelocityPipelineTests (10, apply_raw_movement->get_state_velocity golden
velocities incl. the strafe clamp + turn speeds). Full suite green (3255).

Register: TS-22 + UN-5 deleted (retired); TS-34 added (adjust_motion creature
guard is a no-op — IWeenieObject has no IsCreature; correct for the only caller,
the player). Pseudocode: docs/research/2026-07-01-d6-motion-interp-pseudocode.md.

NEXT D6.2b: the echo-gated wire forward_speed=1.0 question (evidence suggests
ACE relays rather than recomputes; verified via the smoke echo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:57:17 +02:00
Erik
2c8620ea94 feat(L.1b): dual MotionCommand catalog — AceModern runtime + Retail2013 conformance
Splits MotionCommandResolver's single ACE-modern lookup into an
IMotionCommandCatalog seam with two implementations:

- AceModernCommandCatalog (runtime default): built cleanly from the
  DatReaderWriter MotionCommand enum (mirrors ACE + local DAT MotionTables)
  with a documented class-priority tiebreak. The old blind 0x016E-0x0197
  per-range override is DELETED — verified the ACE matrix (LifestoneRecall
  0x0153 to 0x10000153, MarketplaceRecall 0x0166, AllegianceHometownRecall
  0x0171, OffhandSlashHigh 0x0173) resolves correctly straight from the enum
  with no override. Stale shift-start comment corrected to SnowAngelState
  (0x43000115 to 0x43000118), not AllegianceHometownRecall.
- Retail2013CommandCatalog (conformance/reference): full verbatim extraction
  of command_ids[0x198] at 0x007c73e8 (acclient_2013_pseudo_c.txt:1017259-1017667),
  direct wire-low to full index lookup. 408 entries, anchors verified against
  source ([0x150]=0x10000150, [0x153]=0x09000153, [0x197]=0x10000197).

MotionCommandResolver.ReconstructFullCommand stays a static facade delegating
to an AceModern singleton — all ~10 runtime callers unchanged.

Removing the override exposed a pre-existing bug: CombatAnimationPlanner's
late-combat command block uses 2013 numbering, not ACE/DRW. Corrected the one
catalog test assertion pinned to the override's output (wire 0x0170 to
0x09000170 IssueSlashCommand, its true DRW identity) and filed the planner bug
as #159 rather than silently patching out-of-scope code.

Tests: +catalog matrices (ACE/2013), class-priority collisions, boundary
cases, real-DAT availability (gap-doc hit counts reproduced). Build + full
suite green (Core.Tests 1718, no regressions).

Spec: docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md (1)
Research: docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:13:36 +02:00
Erik
989cc25d80 fix(physics): register BSP-only furniture weenies -- drop premature cyl/sphere/radius gate
RegisterLiveEntityCollision had a premature gate at the top of the method:
  if (!hasCyl && !hasSphere && !hasRadius) return;
This fired BEFORE ShadowShapeBuilder.FromSetup ran. The builder emits a BSP shape
for every Part whose GfxObj has a PhysicsBSP, regardless of CylSpheres/Spheres/Radius.
A furniture weenie with only a physics-BSP mesh (candle holder, candelabra, etc.)
has no CylSpheres, no Spheres, and Radius=0 -- so it was always dropped, making it
fully passable (invisible wall that lets the player walk through it).

Fix: remove the premature gate. The three `bool` locals (hasCyl, hasSphere, hasRadius)
are retained -- `hasRadius` is still used by the Radius fallback lower in the method
for entities with no CylSphere/Sphere/BSP but a non-zero setup.Radius. The correct
final gate at shapes.Count==0 (after builder + Radius fallback) handles all cases:
  - BSP-only entity: builder emits BSP shape -> shapes.Count>0 -> registered.
  - Truly shapeless (no BSP, no cyl, no sphere, no radius): builder empty, no Radius
    fallback fires -> shapes.Count==0 -> return (not registered, passable). Correct.

Retail anchor: CPhysicsObj::FindObjCollisions (acclient_2013_pseudo_c.txt:276917) --
the gate at pc:276917 is on the MOVER's CPartArray, not a target-side shape filter.
CPartArray::FindObjCollisions (pc:286236) iterates ALL parts; each part's
find_obj_collisions tests physics_bsp when present. There is no retail equivalent of
our premature gate that skips BSP-only targets.

Tests (ShadowShapeBuilderShapeSourceTests): two new cases.
  Setup_WithBspPart_NoCylSpheres_EmitsBspShape -- proves the builder emits the shape
    the premature gate was discarding.
  Setup_WithPartButNoBsp_NoCylSpheres_YieldsEmptyShapeList -- regression guard proving
    truly shapeless entities are still not registered (the shapes.Count==0 gate holds).
Full Core suite: 1595 pass / 0 fail / 2 skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:04:32 +02:00
Erik
1a7c5aa006 fix(physics): port retail Layer-2 ethereal override -- open doors passable (pc:276961)
CPhysicsObj::FindObjCollisions at pc:276961-276989 has a two-layer mechanism for
ethereal doors. Layer 1 (BSPQuery Path-1 sphere_intersects_solid, pc:323742) already
ported in Task 3 handles the open-gap case. Layer 2 (pc:276963-276977) is a
force-reset that catches the residual case: when the player's sphere CENTER crosses
a thin ethereal slab, sphere_intersects_solid can still return Collided via
HitsSphere (polygon contact on the slab face). Without Layer 2, the opened door
remains an invisible wall until the player's center passes ~0.48m beyond the slab.

Port: in FindObjCollisionsInCell, immediately after the shape dispatch (BSP / Sphere /
Cylinder branches), insert the Layer-2 gate:
  if (result != OK && sp.ObstructionEthereal && !sp.StepDown && (obj.State & 0x1u) == 0)
    { result = OK; ci.CollisionNormalValid = false; }
The STATIC_PS (0x1) guard (pc:276969) ensures static-ethereal env geometry still
blocks. Cylinder/Sphere shapes already return OK from their own ObstructionEthereal
early-outs so Layer 2 is effectively BSP-only in practice, but is written
unconditionally matching retail.

Tests (ObstructionEtherealTests): three new BSP Layer-2 cases using a synthetic
wall polygon in local-space coordinates. (A) ethereal non-static: passable. (B)
ethereal+static: still blocks. (C) non-ethereal: still blocks. All 11 tests pass;
DoorCollision/CellarUp/CornerFlood/HouseExitWalk regression gate: 25/25 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:03:08 +02:00
Erik
6c7e3ef1ab feat(physics): D4+W1 — entry-restrictions register row + named PvP/missile dispatch terms
Part A (D4): CObjCell::check_entry_restrictions (pc:309576) gate is omitted from
FindEnvCollisions. Investigation confirmed the gate requires restriction_obj (per-cell
access-lock entity id) + weenie-object-table dispatch (CanMoveInto / CanBypassMoveRestrictions)
— neither exist in acdream. CellPhysics has no restriction_obj field; DatReaderWriter
models no per-cell access locks. Gap is inert in all dev content (ACE starter area has
no access-locked env cells). Documented as AP-50 in the retail divergence register.

Part B (W1): Replace the hardcoded-false smell in BspOnlyDispatch with named internal
helpers PvpExempt() and MissileIgnore() that return false with retail oracle citations
(pc:276808-276841 and pc:274385 respectively). BspOnlyDispatch now folds all three
terms in retail's exact predicate structure. Behavior is byte-identical in M1.5 scope
(both stubs false ⇒ reduces to HAS_PHYSICS_BSP_PS check alone, same as before).
A6.P7 door dispatch unchanged.

Tests: 3 new guard tests in A6P7DispatchRulesTests — W1_PvpExempt_ReturnsFalseInM15Scope,
W1_MissileIgnore_ReturnsFalseInM15Scope, W1_BspOnlyDispatch_DoorStateStillDispatchesBspOnly.
Suite: 1590 pass / 0 fail / 2 skip (was 1587 + 3 = 1590).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:45:39 +02:00
Erik
a9f8775110 fix(physics): D8 — RemoveCellsForLandblock; cell transforms rebase per apply
Mirrors RemoveBuildingsForLandblock (#146) for indoor CellPhysics entries.
_cellStruct is first-wins (CacheCellStruct's ContainsKey guard); without
eviction a dungeon's BSP WorldTransform is permanently locked to the
_liveCenter value at first streaming — a teleport recenter leaves cells at a
stale offset (~source↔dest distance), and foot-sphere collision queries miss
the geometry that visually renders correctly.

PhysicsDataCache.RemoveCellsForLandblock iterates _cellStruct.Keys and
TryRemove-s every entry whose high-word matches the evicted landblock prefix.
PhysicsEngine.RemoveLandblock now calls it alongside ShadowObjects.RemoveLandblock
so cell BSPs rebase on the next CacheCellStruct pass, same as buildings.

No divergence register row needed: this closes a gap introduced when the #146
building-eviction pattern was created without the symmetric cell eviction.

Tests: RemoveCellsForLandblockTests (3 cases): evicts-matching-prefix,
empty-cache no-throw, no-matching-cells leaves-others. Core suite: 1587 pass / 0 fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:34:16 +02:00
Erik
dc1e927080 fix(physics): Task 3 follow-up — obstruction_ethereal consume for Cylinder+Sphere shapes
Retail oracle greps confirmed:
- CSphere::intersects_sphere @ 0x00537ae4 (pc:321692): the ethereal branch
  is `void __thiscall` — all paths return void (no COLLIDED). The function
  performs a proximity check only; no blocking result is produced.
- CCylSphere::intersects_sphere @ 0x0053b4a0 (pc:324573): same void-return
  pattern — ethereal branch calls collides_with_sphere (check only, no slide),
  all returns are void = passable.

Change: added `if (sp.ObstructionEthereal) return TransitionState.OK` at the
top of SphereCollision and CylinderCollision in TransitionTypes.cs, mirroring
the void-return semantics of both retail functions. The existing per-object
clear at pc:276989 (line 2837) still fires after the early OK return.

Before this fix: an ethereal-alone NPC/ghost with a Cylinder or Sphere shadow
shape would BLOCK the player (regression introduced when Task 3 made ETHEREAL-
alone fall through ShouldSkip instead of instant-skipping). After: all three
shape types — BSP (via BSPQuery Path 1), Sphere, and Cylinder — correctly pass
through when obstruction_ethereal is set.

Tests: added 4 tests to ObstructionEtherealTests.cs verifying:
- Ethereal Cylinder → passable (sweep passes through, no CollisionNormalValid)
- Ethereal Sphere → passable (same)
- Non-ethereal Cylinder → still blocks (regression guard)
- Non-ethereal Sphere → still blocks (regression guard)
Full Core suite: 1584 pass, 0 fail, 2 skip (pre-existing dat skips).

Pseudocode doc updated with confirmed cyl/sphere ethereal contracts and the
complete set/clear/consume flow summary.

Retail refs:
- CSphere::intersects_sphere @ 0x00537ae4 / acclient_2013_pseudo_c.txt:321692
- CCylSphere::intersects_sphere @ 0x0053b4a0 / acclient_2013_pseudo_c.txt:324573

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:30:59 +02:00
Erik
3361a8d776 fix(physics): #137 Task 3 — port obstruction_ethereal verbatim; retire AD-7 shim
Retail CPhysicsObj::FindObjCollisions (0x0050f050) only instant-skips when
BOTH ETHEREAL_PS (0x4) AND IGNORE_COLLISIONS_PS (0x10) are set (pc:276782).
ETHEREAL-alone sets sphere_path.obstruction_ethereal=1 (pc:276806) and
continues to the shape dispatch. BSPTREE::find_collisions (0x0053a496) routes
Path 1 (sphere_intersects_solid) when the flag is set (pc:323742): the open
door has no solid leaf at the doorway, so the test returns OK → player passes
through. CEnvCell::find_env_collisions (0x0052c144) clears the flag first so
ENV walls are never weakened (pc:309580, "D5 clear").

Changes:
- CollisionExemption.ShouldSkip: require BOTH bits for Gate-1 early-out
  (previously ETHEREAL alone returned true — the AD-7 shim). Divergence
  register row AD-7 deleted.
- SpherePath: add ObstructionEthereal field (mirrors retail
  SPHEREPATH.obstruction_ethereal).
- FindObjCollisionsInternal loop: set sp.ObstructionEthereal=(target&0x4)!=0
  before shape dispatch; clear it after (per-object clear pc:276989).
  Also clear at the null-BSP continue site to keep flag clean.
- FindEnvCollisions: clear sp.ObstructionEthereal=false at top (D5 clear
  pc:309580) — ENV cell walls are always solid.
- BSPQuery.FindCollisions Path 1: change `obj.Ethereal` (ObjectInfo.Ethereal,
  always false — dead code) to `path.ObstructionEthereal`. Gate now correctly
  mirrors retail pc:323742: PLACEMENT_INSERT || obstruction_ethereal.

Consume site change (BSPQuery.cs before/after):
  BEFORE: if (path.InsertType == InsertType.Placement || obj.Ethereal)
  AFTER:  if (path.InsertType == InsertType.Placement || path.ObstructionEthereal)
Mirrors retail pc:323742 exactly. obj.Ethereal was dead code (ObjectInfo.Ethereal
is never set true anywhere); the correct flag is SpherePath.ObstructionEthereal.

Tests: 1580 pass / 0 fail / 2 skip (was 1576/0/2 + 4 new in ObstructionEtherealTests.
CellarUp, CornerFlood, DoorCollision, HouseExitWalk all green — no wall regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:20:02 +02:00
Erik
78e5758185 feat(physics): Task 2 — true sphere collision primitive (CSphere::intersects_sphere)
Setup.Spheres were previously coerced to short cylinders (CylHeight=2*r),
which is geometrically wrong: a cylinder has flat caps; a sphere does not.
This ported CSphere::intersects_sphere (0x00537A80) so sphere-typed shadow
entries are tested as spheres — 3-D distance, no height clamping.

Changes:
- ShadowObjectRegistry.cs: added ShadowCollisionType.Sphere (enum value 2).
  The BuildFloodSpheres anyCyl dedup at :232 is unaffected: only Cylinder
  sets anyCyl=true; Sphere shapes fall through to the BSP-fallback path
  (anyCyl=false → included), which is correct.
- ShadowShapeBuilder.cs: FromSetup now emits ShadowCollisionType.Sphere
  (CylHeight=0) for Setup.Spheres instead of a short Cylinder.
- CollisionPrimitives.cs: added SweptSphereHitsSphere — quadratic swept
  solve ported from ACE Sphere.cs::FindTimeOfCollision, which is a C# port
  of retail's CSphere::intersects_sphere @ 0x00537A80. Sign convention
  confirmed against the decomp: retail negates the root to produce a
  forward t ∈ (0,1].
- TransitionTypes.cs: added Sphere narrow-phase branch between BSP and
  Cylinder in FindObjCollisionsInCell; uses 3-D distance for overlap
  (not XY-only). Added SphereCollision() method implementing the 3-D
  wall-slide response. Updated diagnostic logging at :2734 to cover Sphere.
- Updated ShadowShapeBuilderTests for new Sphere type assertion.
- New SphereIntersectsSphereConformanceTests: 9 geometrically-anchored
  cases (head-on, tangent, perpendicular-miss, lateral-near-miss,
  sweep-away, beyond-step, degenerate-zero-sweep, already-overlapping,
  vertical-sweep).

Retail oracle: CSphere::intersects_sphere @ 0x00537A80 (named-retail);
ACE Sphere.cs::FindTimeOfCollision (C# port, cross-confirmed).
Build: 0 errors, 10 warnings (pre-existing).
Tests: 1576 pass / 0 fail / 2 skip (1578 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:08:53 +02:00
Erik
79dee342f2 fix(physics): Slice 1 — delete render-mesh-AABB synthetic collision; DAT-only shape authority
Retail oracle: CPartArray::InitParts@0x00517F40, CGfxObj::Serialize@0x00534970 (physics_bsp
gated on serialized-flags bit-0), CPhysicsPart::find_obj_collisions@0x0050D8D0 (returns OK /
passable when physics_bsp==null). Render-mesh bounds never enter collision.

Changes:
- GameWindow.cs: delete the ~200-line VISUAL mesh-bounds collision block (the
  isPhantomSetup / isPhantomGfxObj locals + the if-block computing worldMin/worldMax
  AABB + the ShadowObjects.Register call that capped and registered the synthetic
  cylinder). Also removes dead counter variables scHaveBounds/scRegistered/scNoBounds/
  scTooThin; trims the ProbeBuildingEnabled summary line accordingly.
- PhysicsDataCache.cs: delete IsPhantomGfxObjSource (the predicate that only existed
  to fence the mesh-AABB synthesis; the "phantom" concept is now the default — no DAT
  shape means no registration, verbatim with retail).
- PhysicsDataCachePhantomSourceTests.cs: deleted (tested the removed method).
- ShadowShapeBuilderShapeSourceTests.cs: new guard test — a Setup with parts but
  hasPhysicsBsp=false and no CylSpheres/Spheres yields an empty shape list, locking
  the DAT-only rule in the builder.
- retail-divergence-register.md: AP-2 row deleted (divergence retired).

Objects with no DAT physics shape (no CylSpheres, no Spheres, no part with a
PhysicsBSP) now register no collision shape and are passable, verbatim with retail.
Objects with real DAT shapes (BSP parts, CylSpheres) are unaffected.

dotnet build green, 22/22 tests passing (ShadowShapeBuilderShapeSourceTests +
CellarUpTrajectoryReplay + CornerFlood + Issue147ArwicBuildings replay harnesses).

Visual gate pending: walk Holtburg + open world; objects that become passable must
match retail (DAT has no physics shape — trees with real CylSpheres still solid).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:57:46 +02:00
Erik
9743537e62 fix(physics): #147 — far-town city/perimeter walls had no collision (portal-less buildings skipped)
A town's outer/perimeter walls have NO collision even on a fresh login: the
player walks straight through them while houses block fine. Confirmed NOT a
frame issue — #146's bldOrigin probe showed Arwic buildings are correctly
framed (~12 m from the player, not km off), so the "#145 far-town frame"
premise was wrong here.

Root cause (dat-confirmed, Issue147ArwicBuildingsDumpTests): a perimeter wall
is stored in LandBlockInfo.Buildings as a doorless shell — 16 of Arwic's 30
buildings are PORTAL-LESS, ringing the town at 24 m intervals (x=12/132,
y=12/108). The building-collision cache loop skipped them via
`if (building.Portals.Count == 0) continue;` — a filter meant only for the
transit/entry feature (CellTransit.CheckBuildingTransit) that also dropped the
collision shell. Retail's find_building_collisions (0x006b5300) tests the shell
BSP independent of the portal list, so a doorless wall still collides.

Fix: don't skip portal-less buildings — cache them with an empty portal list
(no transit) but their collision shell (ModelId) intact. User-verified: Arwic
perimeter walls now block (Collided/Slid). Adds the dat-dump fixture test.

Suites green: Core 1569(+2 skip), App 468(+2 skip), UI 425, Net 317.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:24:48 +02:00
Erik
02f4be72c0 fix(teleport D): cell-march preserves seed landblock id when no resident LB (no more lbX=0 outbound)
BuildCellSetAndPickContaining discarded the bool from TryGetTerrainOrigin — when
the current landblock's terrain hadn't been applied yet (priority-apply in flight
after a teleport or dungeon exit), blockOrigin was silently set to (0,0,0). The
AdjustToOutside/GetOutsideLcoord math treated world-frame sphere coordinates as
block-local and marched the cell one landblock per tick in the direction of movement
until lbX or lbY underflowed to 0x00. ACE rejected every subsequent move as a
failed transition.

Fix: honor the bool return. When terrain is unregistered for an OUTDOOR seed
(low < 0x0100), return currentCellId verbatim — "no block-local frame →
preserve". This mirrors the NO-LANDBLOCK verbatim contract in PhysicsEngine.Resolve
and is correct: the cell stays last-known-correct until terrain registers.
Indoor seeds are explicitly excluded (blockOrigin is never consumed by the indoor
pick path; outdoorPickAllowed=false for indoor seeds).

Reproduce + verify via CellMarchLandblockPreservationTests (two new FAILING-before
tests: WestEdge and SouthEdge with empty cache, no anchor → lbX/lbY preserved).
TeleportFarTownRunawayTests updated: no-anchor path now also preserves (pre-fix it
marched south to 0x59; post-fix returns currentCell unchanged).
CellTransitFindCellSetTests, Issue112MembershipTests, PhysicsEngineTests: added
RegisterTerrain for the streaming-center block (in production it is always resident
before outdoor resolves run; tests that used blockOrigin=(0,0,0) as an implicit
fallback now register the block explicitly). All 1567 tests pass.

Divergence AD-30 added to retail-divergence-register.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 13:38:48 +02:00
Erik
aba882cec6 feat(teleport B): PhysicsEngine.IsLandblockTerrainResident worldReady query
Adds a high-16-bit-prefix landblock residency check used as the teleport
worldReady gate — true once the destination landblock's terrain+cells
have been registered via AddLandblock, regardless of whether the caller
passes a canonical (0xFFFF), cell-resolved, or bare landblock id.

Two TDD tests confirm: false before registration, true after, and
that a cell-resolved id on the same landblock returns true.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 13:05:38 +02:00
Erik
dd2eb8b39d revert(teleport): drop the Slice 2 outdoor readiness-gate hold
User-tested: the Slice 2 'hold outdoor until landblock loaded' gate made
EVERY outdoor teleport a ~10 s freeze, because the destination landblock
does NOT load fast during the hold (lbs=0 the whole time — the #138
streaming gap + _datLock starvation from the CreateObject flood). The hold
was band-aiding a broken/slow foundation rather than fixing it, and it never
actually prevented the #145 edge cascade anyway (it force-snapped onto
NO-LANDBLOCK after the timeout regardless).

Reverts ad8c24e..c880973 to the pre-Slice-2 state (00ef47e): outdoor places
immediately again (fast teleports). The genuine bug found along the way —
IsLandblockLoaded queried the wrong key form (& 0xFFFF0000 vs the stored
| 0xFFFF) — is preserved in the history (c880973) and will be re-applied when
we re-introduce a proper hold ON A FIXED FOUNDATION.

Decision (user, 2026-06-21): fix the foundation FIRST — fast/complete
streaming during teleport (#138), the post-teleport lost-collision bug, and
the FPS leak (Work item C) — then revisit the teleport-flow animation. Slice 1
(the pure TeleportAnimSequencer) stays in (dormant, unwired, harmless).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:46:24 +02:00
Erik
c8809735f3 fix(teleport #145): IsLandblockLoaded key mismatch — outdoor gate was permanently NotReady
The Slice 2 outdoor readiness gate queried IsLandblockLoaded(destCell &
0xFFFF0000) = e.g. 0x7D640000, but streaming stores landblocks under the
EncodeLandblockId form (low 16 = 0xFFFF), e.g. 0x7D64FFFF. The raw
ContainsKey never matched, so the outdoor teleport gate could NEVER flip
Ready and every outdoor arrival ran to the 600-frame (~10 s) timeout and
force-placed. The cascade was still prevented (the timeout force-place lands
cleanly), but the gate did no work — the 10 s freeze the apparatus showed
was this bug, NOT the #138 streaming stall I first suspected.

Root cause found via the apparatus re-test (3-agent investigation
wf_8b67a9d1-35c, all high-confidence) + verified against StreamingRegion.cs:99
(EncodeLandblockId | 0xFFFF), PhysicsEngine.cs:79 (stores as-is),
GameWindow.cs:5530 (queries & 0xFFFF0000).

Fix: IsLandblockLoaded normalizes its arg to the canonical 0xFFFF landblock
key, so the prefix form, any contained cell id, and the dat-id form all
resolve. Added the regression test the original Slice 2 test missed (it had
checked the same 0xFFFF form it added; the real caller passes the 0x..0000
form). Red on the prefix/cell forms before the fix, green after. 9/9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:52:23 +02:00
Erik
ad8c24ef8b feat(slice2): add PhysicsEngine.IsLandblockLoaded — readiness gate §3.4
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:10:22 +02:00
Erik
7605439efa test(physics #145): continuous-tracking test — refutes the review staleness claim
Adversarial review flagged a possible CellPosition staleness on indoor->outdoor
transition. Verified against source: false positive (SnapToCell isn't called on
building entry; the body is world-space so the delta is frame-invariant; the anchor
disengages indoors). Added a test proving CellPosition tracks the outdoor cell under
the world position across a multi-cell, cross-landblock walk and stays canonical.
Core cell-sync 6/6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:38:47 +02:00
Erik
6349ba49aa fix(physics #145): Slice 3 — carried-anchor membership; closes the far-town cascade
The outdoor membership pick derived the landblock origin from the terrain
registry, which returns (0,0) for an UNSTREAMED neighbour — so a fresh far-town
teleport at a landblock edge marched the cell id one block per physics tick
(the cascade; the 17410 ACE rejects is its wire artifact).

Fix: thread the CARRIED cell-relative frame anchor (body.Position -
body.CellPosition.Frame.Origin) into the pick via SpherePath.CarriedBlockOrigin.
That anchor IS the true landblock world origin, correct even for an unstreamed
neighbour, so the pick re-derives the SAME (consistent) cell and never marches.
- CellTransit.FindCellSet/BuildCellSetAndPickContaining: Vector3? carriedBlockOrigin
  (null default = legacy TryGetTerrainOrigin → every existing caller/test untouched).
- PhysicsEngine.ResolveWithTransition: set the anchor from a SEEDED OUTDOOR body
  whose carried landblock matches the resolve cell (else null → legacy).
- PlayerMovementController.SetPosition: 3-arg overload seeds CellPosition from the
  wire's (cell, local) via SnapToCell; 2-arg delegates with cellLocal=pos (anchor
  (0,0,0) == legacy → zero test churn).
- GameWindow.CellLocalForSeed: the placement seam (_liveCenter used ONCE here to
  derive the cell-local; physics carries it forward without _liveCenter).
Regression: TeleportFarTownRunawayTests (south + east edge, unstreamed neighbour).
Core 1529 / App 480, zero regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:51:04 +02:00
Erik
7928b445ab fix(physics #145): Slice 2a — canonicalize outdoor seed + re-derive cell index every tick
The wire local is LANDBLOCK-relative [0,192); the cell low word = floor(local/24),
so a consistent (cell,local) pair must keep them in lockstep. Two retail-faithful
corrections vs the first pass:
 - SnapToCell canonicalizes the OUTDOOR seed via AdjustToOutside (retail
   SetPositionInternal/adjust_to_outside @0x00504A40 — the #107 'never trust a
   server (cell,pos) pair' protection). Indoor seeds stay verbatim (BSP-validated).
 - SyncCellPositionDelta calls AdjustToOutside on EVERY delta, not just on 192 m
   crossings, so intra-landblock 24 m cell-index changes track (needed by Slice 3
   membership). Idempotent within a cell.
Tests rewritten to verify both (the earlier test paired an inconsistent cell+local).
Core 1527 passed / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:55:27 +02:00
Erik
afe495b9e6 feat(physics #145): Slice 2a — PhysicsBody carries CellPosition; setter delta-syncs into the cell frame
Add CellPosition (retail Position type) alongside the world Vector3 Position.
The Position setter mirrors each world delta into the cell-local origin and
calls AdjustToOutside only when the local coord crosses a landblock boundary
([0,192) on X or Y), so the within-block cell id is preserved from the wire
seed. SnapToCell seeds both positions from the wire's (cell, local) pair
verbatim — no streaming center involved. Unseeded bodies (ObjCellId==0) and
indoor cells are no-ops in the delta path. UpdatePhysicsInternal's existing
`Position +=` desugars through the new setter automatically; no call sites
changed. 4 new unit tests; full Core suite 1526 passed / 0 failed / 2 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:49:39 +02:00
Erik
c980763322 refactor(physics #145): Slice 1 — rename Frame->CellFrame to avoid DatReaderWriter.Types.Frame collision
The new value type collided with DatReaderWriter.Types.Frame (used in
physics-adjacent code like ShadowShapeBuilder), which the structural fix
(per-file using-aliases across 6 files) would have re-incurred in every
later physics slice. Renamed the TYPE to CellFrame; the Position.Frame
MEMBER keeps retail's name. Restored the 5 alias-only files to their
pre-Slice-1 state; synced spec + plan. Core 1522 passed / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:34:36 +02:00
Erik
438bb681a5 feat(physics #145): Slice 1 — Position/Frame types + LandDefs.GetBlockOffset (0x0043e630)
Introduces the two value types (Frame, Position) that represent retail's
cell-relative position pair (acclient.h:30647/30658). Types are unused
by consumers yet — zero behavior change. Also ports LandDefs::get_block_offset
(pc:69189, @0x0043e630): world-meter offset between two named landblock ids,
the ONLY cross-cell translation primitive in retail physics. Conformance tests:
same-landblock→Zero, south-neighbour→(0,-192,0) (the exact #145 cascade cell),
east-neighbour→(+192,0,0), diagonal→(+192,+192,0). 4/4 pass; full Core suite
1522 passed / 0 failed. DatFrame alias added to 4 files that had using
DatReaderWriter.Types + using AcDream.Core.Physics in scope simultaneously.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:30:20 +02:00
Erik
2ce5e5c862 fix(G.3a): validated-claim placement keeps the claim's landblock prefix (#133)
The #111 validated-claim branch returned lbPrefix | (cellId & 0xFFFF), where
lbPrefix is found by searching resident landblocks for one containing the
candidate position. A dungeon EnvCell's local Y can be negative, so the dungeon
landblock fails the [0,192) bounds test and the loop matches a neighbouring
(e.g. Holtburg) resident block -> the validated claim 0x00070143 got re-stamped
0xA9B30143, making the client mis-resolve the player to the wrong landblock and
spam ACE with rejected moves. The validated claim's full id is authoritative;
return it directly. Byte-identical for the login case (position in the claim's
own landblock); fixes the far-teleport dungeon case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:27:45 +02:00
Erik
bf18a54369 fix #116 (partial, Ghidra-confirmed): slide_sphere degenerate guard uses F_EPSILON, not EpsilonSq
The user brought up Ghidra; its decompiler (patchmem.gpr, full PDB)
resolved the Binary-Ninja `test ah,5` x87 branch-sign ambiguity that
blocked the desk read. CSphere::slide_sphere (0x00537440) decompiles
cleanly to:

  fVar3 = |cross(collisionNormal, contactPlane.N)|²;
  if (::F_EPSILON <= fVar3) {                       // crease exists
      ... offset = cross * dot(cross,gDelta)/fVar3;
      if (|offset|² < ::F_EPSILON) return COLLIDED_TS;   // degenerate guard
      ... add_offset_to_check_pos -> SLID_TS
  }

Retail compares the SQUARED magnitudes against F_EPSILON
(0.000199999995 ~= 0.0002 = PhysicsGlobals.EPSILON). Our port compared
against EpsilonSq (0.0002^2 = 4e-8) - a ~5000x too-tight threshold (the
BN pseudo-C rendered the comparison as `test ah,5` after an x87 FCMP,
which is sign-ambiguous; agent reads disagreed). Fixed both comparisons
at TransitionTypes.cs:3098,3105 to EPSILON.

Effect: crease-exists now needs >=0.81 deg between the wall and contact
normals (was 0.011 deg - which routed near-parallel pairs through the
numerically unstable projection); the degenerate guard now hard-stops
slides under ~1.41 cm like retail (was 0.2 mm). Branch POLARITY was
already correct - no change there.

No regression: full physics suite (612) + full Core (1443) green. Not a
register deviation (no row existed; this is an undocumented porting
error corrected to match retail).

This does NOT close #116 - it fixes a tangential constant, not either
reported shape. Ghidra also settled the two shapes' diagnosis (recorded
in ISSUES.md #116 + physics digest):
- Shape-1: our cn=UnitZ default IS retail-faithful (validate_transition
  0x0050aa70 has the identical `if (collision_normal_valid==0)
  set_collision_normal(UnitZ)`). The real divergence is upstream -
  tick-22760 our collision_normal_valid was false where retail's was
  true (it recorded the door-face normal). Needs the instrumented
  tick-22760 replay.
- Shape-2 (D4 stays skipped, note sharpened): slide_sphere slides
  in-frame (SLID_TS) so Z=1.92 is faithful and the D4 Z=2.0 hard-stop
  pin is the suspect half; the threshold fix didn't move D4 (real slide,
  not degenerate). Needs a cdb trace of an airborne wall hit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 10:21:51 +02:00
Erik
007af1391c #108-residual apparatus: vertical cellar-ascent viewer harness - membership/viewer layer EXONERATED
The handoff's 'eye-below-grade membership demote' diagnosis is REFUTED.
The harness drives the production stack headlessly per step of the
A9B4 corner-building cellar ascent (0x0174 -> 0x0175 -> 0x0171, path
fitted from the cellar-up live captures): FindCellList on the
foot-sphere center for the player pick + the PhysicsCameraCollisionProbe
SweepEye chain mirrored verbatim (AdjustPosition at pivot ->
ResolveWithTransition IsViewer|PathClipped|FreeRotate|PerfectClip ->
both fallbacks) with per-step branch attribution.

Result: 0 outdoor/null viewer resolutions while the eye is below grade,
0 sweep failures, 0 fallback branches, across boom distance {2.61, 5}
x damping lag {0, 0.3 m}. The viewer enters the main-floor room at eye
z 94.01 - exactly as the head pops above grade (the stairwell portal
sits AT grade), matching the user's report wording. The root is
INTERIOR for the whole grass window; #108-residual is render-side
(fix in the next commit). Tests stay as the healthy-layer
characterization pin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:05:06 +02:00
Erik
0cb97aa594 UN-2 RESOLVED: GetMaxSpeed x4 is byte-verified retail; doc-comment was the misread
The register's UN-2 row recorded a contradiction: the GetMaxSpeed XML doc
claimed the bare run rate was retail-correct (~5.9 m/s catch-up, calling
the xRunAnimSpeed multiply a misread), while the implementation multiplied
by RunAnimSpeed citing ACE. Settled against the binary, not the pseudo-C:

- BN pseudo-C (acclient_2013_pseudo_c.txt:305127) renders get_max_speed as
  void with a bare `this->my_run_rate;` because it DROPS x87 instructions.
- Disassembling the PDB-matched v11.4186 binary at VA 0x00527cb0: all THREE
  return paths end `fld <rate>; fmul dword ptr [0x007C8918]; ret`, and the
  .rdata dword at 0x007C8918 is 4.0f. Sibling get_adjusted_max_speed
  (0x00527d00) carries the same trailing fmul. Verifier committed at
  tools/verify_un2_fmul.py (PE parse + byte decode, rerunnable).
- Retail paths: weenie null -> 1.0 x4; InqRunRate ok -> queried x4;
  InqRunRate failed -> my_run_rate x4. ACE MotionInterp.cs:665-676 matches.

Changes:
- Doc-comment rewritten: the implementation is retail-correct; the catch-up
  speed 2 x get_max_speed ~= 23.5 m/s at run 200 IS retail. The 1-Hz
  remote-blip symptom the old comment attributed to this multiply is
  therefore UNEXPLAINED by it (if it recurs: #41 family, not this).
- Weenie-null path aligned to retail's LITERAL 1.0 default (was MyRunRate).
- Tests re-pinned to the three retail paths (the old NoWeenie test pinned
  the non-retail fallback).
- Register: UN-2 row deleted per the retire rule (6 -> 5 UN rows);
  shortlist renumbered.

This is the 2nd confirmed instance of the BN x87-dropout artifact class
(memory: feedback_bn_decomp_field_names) deciding a register row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:17:50 +02:00
Erik
ca4b482f8b T6 (BR-7) C4: straddle-only outside-add (A6.P5 widening DELETED) + #90 stickiness removed
The two remaining flagged workarounds retired, per the BR-7 plan +
the WF1 [MEDIUM] correction (re-gate, do NOT delete the outside-add):

1. A6.P5 hasExitPortal topology widening DELETED. Outdoor cells enter the
   collision cell array ONLY on the retail straddle gate - |dist| <
   radius + F_EPSILON against an exterior portal plane
   (CEnvCell::find_transit_cells Ghidra 0x0052c820, gate 0052c9d6,
   live-binary verified) - the same flag that already gated the
   membership pick (#112 rider). The widening existed so outdoor-
   registered doors stayed findable from indoor cells under the old flat
   registry query; with per-cell shadow lists the door is found in the
   straddle-admitted outdoor cell's own list (tick-13558 pin holds).
   The hasExitPortal out-param + plumbing deleted from
   FindTransitCellsSphere; the AddAllOutsideCells call in
   BuildCellSetAndPickContaining re-gated on exitOutsideStraddle
   (once-per-walk = retail CELLARRAY.added_outside).

2. #90 ResolveCellId sphere-overlap stickiness REMOVED (the 4ca3596
   workaround, deferred-to-A6.P4 in the physics digest). It was dead
   code: the method's only caller is FindEnvCollisions' cache-null TEST
   fallback, and the indoor branch (where the stickiness lived) required
   a non-null DataCache. Production membership flows exclusively through
   the collide-then-pick advance whose ordered-array hysteresis (current
   cell at index 0, interior-wins-break) is the retail mechanism the
   workaround approximated. ResolveCellId reduced to the bare
   prefix-preserving outdoor re-derive, documented test-only.

Test updates (pins of the deleted behaviors inverted to retail):
- A6P5_BuildCellSetFromIndoorStart_ReachesDoorOutdoorCell (asserted the
  topology widening verbatim) -> DeepInteriorSphere_NoStraddle_
  AddsNoOutdoorCells: a deep-interior sphere admits NO outdoor cells.
- A6P5_BuildCellSetFromAlcove... -> AlcoveSphere_StraddlesExitPortal_
  ReachesDoorOutdoorCell (the captured alcove position genuinely
  straddles - the retail-positive half).
- Issue112MembershipTests straddle pin + the second-sphere straddle test
  updated to the single-flag signature.

Suites: Core 1416/0/2, App 225, UI 420, Net 294 - green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:44:49 +02:00
Erik
dbfbf8506c T6 (BR-7) C3: per-cell shadow architecture - flood registration, building channel, per-cell query; b3ce505 stopgap DELETED (closes #99)
The A6.P4 port, fused into one installment per the BR-2 half-port lesson
(registration and query are co-dependent: flood-registering shells under
the old radial query would re-open #98 through the vestibule).

REGISTRATION (ShadowObjectRegistry rewritten):
- Register/RegisterMultiPart/UpdatePosition compute the cell set via
  CellTransit.BuildShadowCellSet (the C2 find_cell_list flood) seeded by
  the entity's m_position cell id; the private 24m XY-grid rectangle and
  its single-landblock clamp are deleted. Flood spheres follow retail's
  CylSphere rule (base point + cyl radius, cap 10; BSP bounding-sphere
  fallback - Ghidra 0x0052b9f0). Statics flood with the do_not_load
  prune; dynamics (server spawns, isStatic:false) without.
- Keep-when-empty (SetPositionInternal num_cells gate, pc:283540): a
  failed flood leaves the previous registration in place.
- RefloodLandblock: streaming-race hook re-runs the flood when a
  landblock's cells hydrate (retail init_objects -> recalc_cross_cells,
  Ghidra 0x0052b420/0x00515a30); wired at GameWindow's hydration tail.
- GameWindow sites pass the server position's full cell id as the seed
  (spawn + UpdatePosition); the five static sites pass ParentCellId.

BUILDING CHANNEL (CSortCell.building shape):
- Building SHELLS are not shadow objects in retail (only caller of
  find_building_collisions is CSortCell::find_collisions 0x005340aa;
  one building per origin landcell, init_buildings 0x0052fd80 verified
  verbatim + ACE cross-ref). IsBuildingShell entities skip the registry;
  Transition.FindBuildingCollisions runs the shell part-0 BSP off
  cache.GetBuilding(cellId) with bldg_check set around it
  (find_building_collisions 0x006b5300), CollidedWithEnvironment on
  non-Contact non-OK. BuildingPhysics.ModelId = pre-resolved part-0
  GfxObj (0x02 Setups resolved at the CacheBuilding site).
- Placement/ethereal weakening: BSPQuery Path 1 passes center_solid=0
  when BldgCheck && HitsInteriorCell (BSPTREE::find_collisions 0x0053a82e
  + placement_insert 0x005399d8) so doorway crossings don't hard-fail
  against shell solids. SpherePath gains both retail fields;
  HitsInteriorCell is rebuilt at every cell-array build
  (build_cell_array reset 0x00509ef2 + find_cell_list/check_building_
  transit set sites).

QUERY (retail per-cell order, transitional_insert 0x0050b6f0):
- TransitionalInsert per attempt: env -> building (LandCell only) ->
  objects on the PRIMARY cell, then on OK the check_other_cells pass
  (env -> building -> objects per OTHER overlapped cell) + the
  carried-cell advance - the advance now happens AFTER all per-cell
  object passes (the WF1 ordering divergence), with Adjusted/Slid
  feeding the retry exactly like retail's OK_TS case.
- FindObjCollisionsInCell = CObjCell::find_obj_collisions (0x0052b750):
  iterate ONLY the asked cell's list. DELETED: the radial 9-landblock
  sweep, the +5m query pad, the b3ce505 indoor-primary gate, and the
  isViewer exemption (the camera is bounded by interior cell-BSP env
  collision - retail's own channel; CameraCornerSealReplayTests pins it
  against real dat, and the new building-channel camera test pins the
  outdoor stop).

TESTS: Core 1416/0/2 (was 1398 + 4 pre-existing #99-era fails + 1 skip),
App 225, UI 420, Net 294 - all green.
- 3 of the 4 #99-era reds flipped green as designed: the door apparatus
  (Apparatus_Grounded_50cmOffCenter_FrontApproach_Blocks) and tick-13558
  (indoor walkthrough) now assert the door BLOCKS; tick-22760 pins the
  outdoor blocking invariant.
- The 4th (BSPStepUp D4) + 22760's lateral-slide delta are NOT cell-set
  problems (probes prove the door is found + BSP-only dispatched;
  BR-7 left both byte-identical) - filed as issue #116 (slide-response
  family), D4 skipped with the issue reference.
- FindEnvCollisionsMultiCellTests migrated to the public entry (the A4
  multi-cell halt now lives at the retail call site).
- New registry pins: per-cell query surface, outdoor-footprint-never-
  indoor (#98 architectural), door-outdoor-cell membership, reflood.
- CameraCollisionIndoorTests rewritten against the building channel
  (the isViewer-exemption pins died with the exemption).

Closes #99 (doors block both ways via registration-time cell membership
+ the straddle-spanning player cell array). #97 likely closed (the +5m
radial pad that produced phantom-collision candidates is gone) - verify
at T5. #98 stays closed ARCHITECTURALLY (outdoor footprints structurally
cannot reach interior cells; the cellar harness stays green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:37:50 +02:00
Erik
abf36e2743 T6 (BR-7) C2: BuildShadowCellSet - the registration-side portal flood
Verbatim port of CObjCell::find_cell_list (Ghidra 0x0052b4e0, pc:308742)
as invoked by calc_cross_cells / calc_cross_cells_static (0x00515230 /
0x00515160) - the flood retail runs at SHADOW REGISTRATION time, minus
the containing-cell pick (registration passes a null out-cell):

- Seed: indoor -> exactly that one cell (added even when unloaded, like
  retail's null-pointer add_cell; the walk is then skipped per the
  0052b576 seed-pointer gate); outdoor -> block-crossing
  AddAllOutsideCells.
- Growing-array walk: indoor cells via FindTransitCellsSphere
  (sphere-vs-neighbor-BSP admission; exterior straddle -> outside cells
  once per walk, retail CELLARRAY.added_outside); outdoor cells via the
  CLandCell leg (0x00533800) = add_all_outside_cells (same once-guard)
  + the building bridge CSortCell -> CBuildingObj ->
  check_building_transit (0x00534060/0x006b5230/0x0052c5d0) - how an
  outdoor-positioned door reaches the vestibule's shadow list at
  registration. No XY grid, no visibility lists (the spec's
  VisibleCellIds rule was REFUTED by the WF1 verification).
- Static prune (do_not_load_cells, 0052b66e): indoor-seeded statics keep
  only {seed} + seed.stab_list (VisibleCellIds) - also strips outdoor
  cells, matching retail (interior statics never shadow into landcells;
  outdoor spheres reach them through their own array's building bridge).

11 unit tests: seeds (indoor/outdoor/unloaded), neighbor-BSP admission,
building bridge incl. the C1 negative-portal-id gate, exterior straddle
on/off, static prune drop/keep, zero-sphere no-op.

Consumed by C3 (ShadowObjectRegistry rewrite).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:02:08 +02:00