Code-quality review on commit 9c5634a flagged that the existing 4
GetMaxSpeed tests didn't cover the case where WeenieObj is null and
RunForward must fall back to MyRunRate. Without this test, a
regression that hardcoded the fallback to 1.0f would silently pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ports retail's CMotionInterp::get_max_speed (0x00527cb0). Returns
motion-table-derived max speed (m/s) for InterpretedState.ForwardCommand:
- RunForward: RunAnimSpeed (4.0) × (InqRunRate ?? MyRunRate)
- WalkForward: WalkAnimSpeed (3.12)
- WalkBackward: WalkAnimSpeed × 0.65 (BackwardsFactor from adjust_motion @ 0x00528010)
- otherwise: 0
Decomp note: Binary Ninja emits a spurious void return for x87 FPU-returning
functions; the actual float return is confirmed by both callers
(StickyManager::adjust_offset @ 0x00555430,
InterpolationManager::AdjustOffset @ 0x00555d52) which multiply the result
by 2.0 to produce a catch-up speed in m/s. The per-command switch is
consistent with get_state_velocity (0x00527d50) which uses the same constants.
Used by InterpolationManager.AdjustOffset in Task 5 as 2 × GetMaxSpeed().
Until Task 5 wires it, the method is unused — covered by 4 unit tests.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Addresses code-quality review findings on commit f43f168:
C-1: Stall detection re-implemented to match retail (acclient lines
353071-353275). Tracks _progressQuantum (sum of step values per window)
+ _distanceAtWindowStart (set at window start). Primary check:
cumulative_progress < MIN_DISTANCE_TO_REACH_POSITION (0.20m absolute).
Secondary check: cumulative_progress / _progressQuantum < 0.30.
Either failing increments fail counter; blip-to-tail at >3 consecutive
fails (already correct).
C-2: Renamed StallFailCountForBlip -> StallFailCountThreshold with
clearer XML doc explaining the > vs >= semantics (blip fires when fail
count EXCEEDS the threshold, i.e. on the 4th consecutive failed window).
I-1: _haveBaselineDistance sentinel prevents first-window false
positive that was triggering spurious fails on every new motion sequence
(old code defaulted _distanceAtWindowStart to 0, making cumulative
progress always negative on frame 5).
I-3: dt <= 0 || NaN guard at AdjustOffset entry prevents NaN
propagation into PhysicsBody.Position.
I-4: Internal field renames for clarity:
_failFrameCounter -> _framesSinceLastStallCheck
_failDistanceLastCheck -> merged into _distanceAtWindowStart
I-5: Added internal Count property + InternalsVisibleTo (via
AssemblyAttribute in .csproj) so Enqueue_DropsOldestWhenAtCap20
actually verifies cap enforcement. Added assertion that head is the
second-enqueued position after overflow.
3 new tests (AdjustOffset_FirstWindow_DoesNotFalseFail,
AdjustOffset_DtZeroOrNegative_ReturnsZero,
Enqueue_AtCap20_HeadIsSecondOriginal), 16 total. All green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pure-data class + 13 unit tests.
Ports retail's CPhysicsObj::InterpolateTo (acclient @ 0x005104F0)
and InterpolationManager::adjust_offset (@ 0x00555D30) — FIFO position-
waypoint queue (cap 20) + per-frame catch-up math walking the body
toward the head node at 2 × motion-table-max-speed (clamped, with
7.5 m/s fallback). Reach @ 0.05m. Duplicate-prune @ 0.05m.
Stall detection: every 5 frames; if progress < 30% of expected,
increment fail counter; > 3 fails → blip-to-TAIL (resolved via
decomp dive of UseTime @ 0x00555F20: tail_ is the snap target,
not head_).
Constants verified from binary at named addresses (not guesses):
MAX_INTERPOLATED_VELOCITY_MOD=2.0, MAX_INTERPOLATED_VELOCITY=7.5,
MIN_DISTANCE_TO_REACH_POSITION=0.20, DESIRED_DISTANCE=0.05.
Composed into RemoteMotion in subsequent task; not yet used.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase L.4 closes the "stuck in falling animation on a steep roof" bug
the user reported on 2026-04-30 ("I jump up, I land on it. It should not
even let me land, should just slide with a falling animation"). After
this commit the body no longer sticks to a steep roof when jumping
into it — it slides along the slope while keeping the falling animation.
Two pieces:
1. BSPQuery Path 6 steep-poly slide
When an airborne sphere hits a polygon whose world normal Z is below
FloorZ (≈ 0.6642, slope > ~49°), the previous flow was:
Path 6 SetCollide → Path 4 set_walkable → ContactPlane committed →
body "lands" on the steep poly with Contact bit + falling animation.
This left the player stuck mid-slope because OnWalkable was cleared
but Contact stayed set.
The new branch detects the steep normal in Path 6 BEFORE SetCollide
is called. Instead of entering the landing path, it removes the
into-wall component of the move (project onto the steep face), sets
CollisionNormal + SlidingNormal, and returns Slid. Same shape as
Path 5's step-up fallback and CylinderCollision. The resolver retries;
the sphere is now outside the poly; FindCollisions returns OK;
ValidateTransition commits the slid position. ContactPlane is never
set, so the body stays airborne with falling animation.
2. PlayerMovementController L.3a-bounce carve-out + Inelastic stop
Re-enables the velocity-reflection bounce when the contact normal is
upward-facing but steeper than walkable (0 < N.Z < FloorZ). The base
L.3a rule suppresses bounce on landing transitions to avoid micro-
bounce on flat terrain; that suppression also stuck the player to
too-steep roofs they shouldn't land on. This carve-out re-enables
the reflection specifically for the steep upward case.
Also lands related L.2c precipice / edge-slide work that was in flight:
- TransitionTypes EdgeSlideAfterStepDownFailed: walkable-poly-steep
cliff route + steep-ContactPlane cliff route ordering, so that
CliffSlide fires when the stored walkable polygon itself is too
steep (Path 4 had previously accepted it as a "landing" via the
permissive LandingZ threshold).
- CliffSlide reference-normal selection: prefer LastWalkable, fall back
to LastKnownContactPlane only when walkable, else use world-up. This
prevents the cross(steepN, steepN) = 0 degenerate case that left the
cliff slide as a no-op when both current and last-known were steep.
- Phase 2 / step-down branch / edge-slide branch / cliff-slide
diagnostic helpers gated on ACDREAM_DUMP_EDGE_SLIDE / ACDREAM_DUMP_STEEP_ROOF.
- Two new airborne-mover regression tests in BSPStepUpTests +
PhysicsEngineTests covering wall-slide and edge tangent motion.
DEVIATION FROM RETAIL — DOCUMENTED FOR FOLLOW-UP
The Path 6 steep slide is NOT what retail does. Retail's flow on the
same hit is:
Path 6 SetCollide (no steep check) → Path 4 find_walkable returns
nothing for steep → Phase 3 reset path: restore_check_pos +
kill_velocity → return COLLIDED → validate_transition reverts CheckPos
to CurPos and forces OK.
Net retail behavior: position reverts to pre-failed-move (typically
just below the roof in the common jump-up case), velocity zeroed,
gravity rebuilds Z next frame, body falls back down naturally with
the falling animation. The "freeze" framing I used earlier was wrong;
in the typical case retail just bounces the body off and lets gravity
take over.
Strict retail behavior would match the user's intent better in the
common case AND avoid the bounce-energy-accumulation we saw with the
slide-tangent approach (V grew to ~50 m/s in continuous-contact frames).
However, retail's behavior degenerates in the edge case of an overhead
landing onto a steep slope (body would freeze mid-air above the roof).
This commit ships the slide-tangent fix as an interim "much better"
state per user verification on 2026-04-30. Follow-up work to match
retail strictly: revert Path 6 steep-slide, audit Phase 3 reset to
ensure kill_velocity (matching OBJECTINFO::kill_velocity ->
CPhysicsObj::set_velocity({0,0,0}, 0)) actually fires, and re-test.
Refs:
- acclient_2013_pseudo_c.txt:323784-323821 (Path 6 SetCollide)
- acclient_2013_pseudo_c.txt:273191-273239 (Phase 3 reset path)
- acclient_2013_pseudo_c.txt:272563-272596 (validate_transition revert)
- acclient_2013_pseudo_c.txt:274467-274475 (kill_velocity)
- acclient_2013_pseudo_c.txt:282699-282715 (handle_all_collisions bounce)
Tests: 833/833 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Port the first retail precipice-slide slice from named retail/ACE: terrain and BSP walkable hits now preserve polygon vertices, failed step-down edges back-probe to rediscover the walkable polygon, and edge-slide can run precipice/cliff slide instead of only hard-stopping.
Adds pseudocode anchors plus regression coverage for terrain polygon context and loaded-terrain boundary edge-slide.
Co-authored-by: Codex <codex@openai.com>
Pass explicit grounded/airborne contact bytes from MovementResult into MoveToState and AutonomousPosition, and add ACDREAM_DUMP_MOVE_TRUTH logging for outbound movement plus player UpdatePosition echoes.
Co-authored-by: OpenAI Codex <codex@openai.com>
The "stuck in falling animation against walls" live-test bug (intermittent,
hard to recover from). Two compounding issues, fixed at both layers.
(1) DoStepUp cleared CollisionInfo.ContactPlaneValid unconditionally at
the start of step-up. On step-up FAILURE, RestoreCheckPos restored
the position but the contact plane stayed cleared. Added a save/
restore around the clear so a failed step-up returns the mover to
its pre-attempt grounded state.
(2) ValidateTransition propagated the current frame's invalid contact
state into LastKnownContactPlane via:
ci.LastKnownContactPlaneValid = ci.ContactPlaneValid
This destroyed the prior frame's ground memory whenever the current
contact was momentarily lost (StepUpSlide clears ContactPlane).
Changed to: only OVERWRITE LastKnown when current is valid.
(3) The same ValidateTransition then set
oi.State &= ~(Contact | OnWalkable)
when ContactPlaneValid was false, even if LastKnown was still
valid. Added an "else if (LastKnownContactPlaneValid)" branch that
sets Contact + OnWalkable from LastKnown so the animation system
sees the mover as grounded.
Combined effect: walking into a too-tall wall now consistently slides
along the wall without ever flickering to the falling animation. The
mover's grounded state survives transient ContactPlane invalidation
during the step-up retry cycle.
Retail's `transitional_insert` has different upstream invariants that
keep ContactPlane valid more often, so retail doesn't need the
acdream-specific LastKnown fallback path. ACE has the same pattern as
retail; acdream's per-frame Resolve architecture exposes the gap that
this fix closes.
Tests:
- New D1 regression test: grounded mover into too-tall wall — must
end frame with grounded state preserved.
- New D2 regression test: same scenario — execution time bounded
(<100ms) to catch any future recursion issues.
Files:
- TransitionTypes.cs DoStepUp: save+restore ContactPlane around step-up
- TransitionTypes.cs ValidateTransition: preserve LastKnown + grounded
state from last-known when current is invalid
- BSPStepUpTests.cs: D1, D2 regression tests
Test count 825 → 825 (D1+D2 added in L.2.3 patch series). Build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Port CTransition::step_up (Path 5) and SPHEREPATH::set_collide (Path 6)
from the retail decomp, turning wall-slides into proper step-up climbs
and airborne-to-roof landings.
Path 5 (grounded mover hits polygon):
- StepSphereUp calls DoStepUp which runs DoStepDown with StepUp=true
- DoStepDown now includes the retail Placement validation step
(ACE Transition.cs:731-741) — sphere must not be inside solid geometry
after finding a contact plane; this correctly blocks the tall-wall case
- FindObjCollisions now allocates a local ShadowEntry list per call to
prevent "collection modified" exceptions when DoStepUp recurses back
through TransitionalInsert → FindObjCollisions
- BSPQuery.FindCollisions passes engine through to StepSphereUp
Path 6 (airborne mover hits polygon):
- SpherePath.SetCollide: saves backup pos, records StepUpNormal, sets
WalkInterp=1 — then returns Adjusted so TransitionalInsert retries
- SpherePath.StepUpSlide: clears ContactPlane, sets SlidingNormal for
the tall-wall fallback
- TransitionalInsert Collide branch: re-tests as Placement when
ContactPlaneValid; on failure restores backup and returns Collided
Test fixes (BSPStepUpTests.cs + BSPStepUpFixtures.cs):
- Tests use foot-position convention (CurPos = foot, sphere center =
CurPos + (0,0,r)); from/to corrected from sphere-center to foot coords
- MakeTestEngine terrainZ param: 0f for grounded tests (keeps Contact
state between sub-steps), -50f for airborne/roof tests
- to.X adjusted so sub-steps land sphere inside (not exactly touching)
the wall, avoiding the EPSILON-shrink false-negative edge case
- All 12 BSPStepUp tests now GREEN; full suite 823/823
Retail refs:
CTransition::step_up — acclient_2013_pseudo_c.txt:273099 / ACE:746
CTransition::step_down — acclient_2013_pseudo_c.txt:273069 / ACE:710
SPHEREPATH::set_collide — acclient_2013_pseudo_c.txt:321594 / ACE:279
CTransition::transitional_insert Collide — pseudo_c:273193 / ACE:891
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds two files under tests/:
BSPStepUpFixtures.cs — synthetic PhysicsBSPNode trees for four canonical
collision shapes: low step (25 cm), too-tall wall (5 m), flat roof (3 m),
and steep slope (60deg). Pre-builds ResolvedPolygon dicts with correct
polygon_hits_sphere_precise winding (CCW relative to outward normal).
BSPStepUpTests.cs — 11 conformance tests:
A1-A6: baselines that pass before and after implementation (no-hit, geometry
fixture sanity checks).
B1-B3: Phase L.2.1 targets, currently RED (Path 5 wall-slides).
C1-C3: Phase L.2.2 targets, currently RED (Path 6 wall-slides).
Retail refs in test docstrings:
BSPTREE::find_collisions Path 5 acclient_2013_pseudo_c.txt:323849 /
ACE BSPTree.cs:192-196.
CTransition::step_up acclient_2013_pseudo_c.txt:273099-273133 /
ACE Transition.cs:746-777.
SPHEREPATH::set_collide acclient_2013_pseudo_c.txt:321594-321607 /
ACE SpherePath.cs:279-286.
CTransition::transitional_insert Collide branch
acclient_2013_pseudo_c.txt:273193-273239 / ACE Transition.cs:891-930.
Also adds PhysicsDataCache.RegisterGfxObjForTest() for test-only GfxObjPhysics
injection without real DAT content.
Test delta: 811 -> 823 (+12). 6 passing (A1-A6 + B2), 5 intentionally failing.
Pre-flight: object-translation plane D is in object-local space. Bug is dormant
for outdoor movement where terrain sets the world-space ContactPlane. Tagged TODO.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The retail-faithful exemption block at the top of
CPhysicsObj::FindObjCollisions
(acclient_2013_pseudo_c.txt:276782-276839,276971), ported line-for-
line as a small static helper.
Behaviour now matches retail:
- Two non-PK players walk through each other.
- Two PK players collide.
- Two PKLite players collide.
- Mismatched PK status (PK vs non-PK, PK vs PKLite) — exempt.
- Impenetrable target ("Free" PK status) — always collides.
- Player vs creature/NPC — always collides (this is what closes the
user-facing complaint that walking into a Holtburg vendor was
walking through them).
- Mover with IGNORE_CREATURES — walks through creature targets.
- Viewer (camera ray) — walks through creatures.
- Target with ETHEREAL+IGNORE_COLLISIONS — universally exempt.
CollisionExemption.ShouldSkip(targetState, targetFlags, moverState)
- new file src/AcDream.Core/Physics/CollisionExemption.cs.
- 13-test matrix covering every documented case
(CollisionExemptionTests.cs).
- Static + pure → cheap to call from the hot path.
Wiring:
- TransitionTypes.FindObjCollisions: after broadphase distance
reject, call ShouldSkip on the obj and ObjectInfo.State; on true,
`continue`. Static landblock entries (State=0, Flags=None) fall
through cheaply — no behavior change for static collision.
- PhysicsEngine.ResolveWithTransition: new optional moverFlags
parameter (default None for back-compat). PlayerMovementController
passes ObjectInfoState.IsPlayer; remote dead-reckoning leaves it
None (matches non-player movers, no PvP exemption applies).
- PK/PKLite/Impenetrable bits for the LOCAL player are not yet
sourced from PlayerDescription's PlayerKillerStatus property —
that's a follow-up. Default "non-PK player" matches ACE's
character-creation default and the user's +Acdream test
character.
Cross-checked against ACE PhysicsObj.cs:381-405 (line-for-line C# port
of the same retail block). Only intentional divergence: ACE adds
state.HasFlag(IsImpenetrable) (mover-impenetrable) to the collide list;
retail's pseudo-C only checks the target — acdream follows retail.
dotnet build green, dotnet test 1467 passing (+13 new). Live test:
+Acdream walking into Holtburg vendors now stops at their cylinder;
walking through small plants still passes (Commit B's phantom skip).
Closes the live-entity collision arc: A (plumbing) + B (registration)
+ C (exemption).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plumbing-only foundation for the upcoming live-entity (NPC / monster
/ player) collision port. No behavior change — the new fields default
to zero/None so the 5 existing static-entity Register call sites in
GameWindow.cs are untouched.
Wire layer:
- CreateObject parser now surfaces PhysicsState (acclient.h:2815 —
ETHEREAL_PS=0x4, IGNORE_COLLISIONS_PS=0x10, HAS_PHYSICS_BSP_PS=0x10000,
...) which the parser previously dropped at line ~337 with a bare
`pos += 4`.
- CreateObject parser now surfaces ObjectDescriptionFlags (the retail
PWD._bitfield trailer per acclient.h:6431-6463), where
acclient_2013_pseudo_c.txt:406898-406918 ACCWeenieObject::IsPK /
IsPKLite / IsImpenetrable read bits 5 / 25 / 21 directly. Previously
read-and-discarded.
- WorldSession.EntitySpawn carries both new fields through to subscribers.
Physics layer:
- New `EntityCollisionFlags` enum (IsPlayer / IsCreature / IsPK /
IsPKLite / IsImpenetrable) + `FromPwdBitfield` helper. Bit
positions verified against retail's SetPlayerKillerStatus (
acclient_2013_pseudo_c.txt:441868-441890) which maps
PKStatusEnum→bitfield exactly: PK=0x4→bit5, PKLite=0x40→bit25,
Free=0x20→bit21.
- `ShadowEntry` extended with `State` (raw PhysicsState bits) +
`Flags` (decoded EntityCollisionFlags). Backward-compatible — all
five existing landblock-entity Register call sites omit them.
- `ShadowObjectRegistry.UpdatePosition(entityId, pos, rot, ...)` —
fast-path for the 5–10 Hz UpdatePosition (0xF748) stream the server
emits per visible entity. Reuses the entry's existing shape +
state + flags. Mirrors retail's CPhysicsObj::SetPosition
(acclient_2013_pseudo_c.txt:284276) which keeps the same shape and
re-registers cell membership.
- `ObjectInfoState` adds `IsPK = 0x800` and `IsPKLite = 0x1000`
matching retail's OBJECTINFO::state bits (acclient.h:6190-6194).
Used by Commit C's PvP exemption gate.
Tests:
- `EntityCollisionFlagsTests` — 7 tests covering empty / each bit
alone / PK+player combo / unrelated-bit ignore.
- `ShadowObjectRegistryTests` — 5 new tests: UpdatePosition moves
entry to new cell, preserves State/Flags, unregistered no-op,
Register stores State/Flags, defaults are zero/None.
- `CreateObjectTests` — 3 new tests verifying PhysicsState + PWD
bitfield (with PK / PKLite bit cases) parse and surface.
1454 → 1454 + 15 = covered by suite. dotnet build + dotnet test
green.
Foundation for Commit B (live-entity registration) and Commit C
(PvP exemption block in FindObjCollisions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
21 commits porting retail's MoveToManager-equivalent client-side
behavior for server-controlled creature locomotion and combat
engagement. Shipped as MVP after live visual verification across
multiple iteration rounds with the user.
Highlights:
- 186a584 — initial Phase L.1c port: extracts Origin / target guid /
MovementParameters block from MoveTo packets (movementType 6/7),
adds RemoteMoveToDriver per-tick body-orientation steering with
±20° aux-turn-equivalent snap tolerance.
- d247aef — corrected arrival predicate semantics + 1.5 s
stale-destination timeout for entities leaving the streaming view.
- f794832 — root-caused "creature won't stop to attack" via two
research subagents converging on retail
CMotionInterp::move_to_interpreted_state's unconditional
forward_command bulk-copy. Lifted ServerMoveToActive flag clearing
+ InterpretedState bulk-copy out of substate-only branch so
Action-class swing UMs (mt=0 ForwardCommand=AttackHigh1) clear
stale MoveTo state and zero forward velocity.
- ff6d3d0 — RemoteMoveToDriver.ClampApproachVelocity caps horizontal
velocity at the final-approach tick so body lands EXACTLY at
DistanceToObject instead of overshooting through the player.
- 37de771 — bulk-copy ForwardCommand for MoveTo packets too (closed
the regression where MoveTo creatures stayed at default
ForwardCommand=Ready in InterpretedState and only translated via
UpdatePosition snaps).
- 34d7f4d + e71ed73 — AnimationSequencer.HasCycle query +
fallback chain (requested → WalkForward → Ready → no-op) at BOTH
the OnLiveMotionUpdated path AND the spawn handler. Prevents
ClearCyclicTail from wiping the body's cyclic tail when ACE
CreateObject carries CurrentMotionState.ForwardCommand pointing
to an Action-class motion (e.g. AttackHigh1 from a mid-swing
creature) which has no cyclic-table entry — was the "torso on
the ground" symptom for monsters seen in combat by a fresh
observer.
Cross-references: docs/research/named-retail/acclient_2013_pseudo_c.txt
(MoveToManager 0x00529680 + 0x0052a240 + 0x00529d80,
CMotionInterp::move_to_interpreted_state 0x00528xxx,
MovementParameters::UnPackNet 0x0052ac50), references/ACE/Source/
ACE.Server/Physics/Animation/MoveToManager.cs (port aid),
references/holtburger/ (cross-check on snapshot-only client
behavior), docs/research/2026-04-28-remote-moveto-pseudocode.md
(the Phase L.1c pseudocode doc).
Tests: 1404 → 1422 (parser type-7 path retention, type-6 target
guid retention, driver arrival semantics, retail-faithful
chase/flee branches, approach-velocity clamp scenarios,
HasCycle present/missing, AttackHigh1 wire layout).
Pending follow-ups (filed for future): target-guid live resolution
for type 6 packets (residual chase lag), StickToObject sticky-target
guid trailing field, full MoveToManager state machine port
(CheckProgressMade stall detector, Sticky/StickTo, use_final_heading,
pending_actions queue).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-observed regression on commit 37de771: monsters in combat with
another client appear as "just a torso on the ground" until they
move. User correctly identified this as a regression I introduced.
Cause traced to the SEQUENCER side, not the InterpretedState side.
AnimationSequencer.SetCycle (AnimationSequencer.cs:392-396)
unconditionally calls ClearCyclicTail() BEFORE looking up the
requested cycle in the MotionTable. If the cycle is missing
(_mtable.Cycles.TryGetValue returns false), the body is left without
ANY cyclic tail at all — and every part snaps to its setup-default
offset on the next Advance(). Most creatures' setup-defaults put
all limbs at the torso origin, so the visual collapses to "just a
torso on the ground" until a different (working) cycle arrives.
This is specifically a regression from commit 186a584 (Phase L.1c
port). Pre-fix, MoveTo packets fell through to fullMotion=Ready
(every MotionTable contains a Ready cycle). Post-fix, MoveTo packets
seed fullMotion=RunForward via PlanMoveToStart. Some combat-stance
creatures (e.g. monsters in HandCombat 0x003C) have no
(combat, RunForward) cycle in their MotionTable — they're meant to
walk in combat, with retail's apply_run_to_command upgrading
WalkForward → RunForward at the velocity layer rather than the
animation-cycle layer.
Fix: add `AnimationSequencer.HasCycle(style, motion)` query and gate
the SetCycle call site in GameWindow.OnLiveMotionUpdated behind it.
Fall back chain: requested motion → WalkForward → Ready →
no-op-don't-clear. The InterpretedState.ForwardCommand bulk-copy
(commit 37de771) is unchanged — body still gets RunForward velocity
even when the visible animation falls back to WalkForward or Ready.
Tests: 1420 → 1422.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-observed residual after f794832: creature stops to attack but
still runs slightly through the player before stopping.
Cause: at 4 m/s body velocity (RunAnimSpeed × ~1.0 speedMod) and a
60 fps tick (~16 ms), the body advances ~6.4 cm per tick. When dist
falls just below the 0.6 m DistanceToObject arrival threshold, the
arrival predicate fires and zeroes velocity — but the body has
already advanced one full tick INTO the threshold zone. That last
tick is the "running through" the user sees, especially when
combined with a player visual radius of ~0.5 m.
Fix: cap horizontal velocity in the steering branch so the body lands
EXACTLY at the arrival threshold instead of overshooting it. Pure
function in RemoteMoveToDriver (ClampApproachVelocity) so it's
testable; called from GameWindow.cs after apply_current_movement
sets RunForward velocity from the active cycle.
The clamp is a strict scale-down of the X/Y components; Z is left
to gravity / terrain handling. No-op for the flee branch — fleeing
has no overshoot risk by definition.
Tests: 1416 → 1420. Four new clamp scenarios: exact-landing (FP
tolerance), would-overshoot scale-down, already-at-threshold zeroing,
flee no-op.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-observed regression on commit d247aef: creature reaches melee
range and "just runs" instead of stopping to attack. Two independent
research subagents converged on the same root cause.
When ACE broadcasts a melee swing, it sends an mt=0 UpdateMotion with
ForwardCommand=AttackHigh1 (Action class, 0x10000062), motion_flags
=StickToObject, and a trailing 4-byte sticky-target guid — there is
NO preceding cmd=Ready. The swing UM IS the stop signal.
Retail's CMotionInterp::move_to_interpreted_state
(acclient_2013_pseudo_c.txt:305936-305992) bulk-copies forward_command
from the wire into InterpretedState UNCONDITIONALLY, regardless of
motion class. With forward_command=AttackHigh1, get_state_velocity
(:305172-305180) returns velocity.Y=0 because its gate is
RunForward||WalkForward — body stops moving forward. The animation
overlay (the swing) is appended on top of whatever cyclic tail is
active.
Acdream's overlay branch in GameWindow.OnLiveMotionUpdated routed
Action-class commands through PlayAction (animation overlay only) and
SKIPPED:
- ServerMoveToActive flag update — stale RunForward MoveTo state
persisted, the per-tick driver kept steering toward the prior
Origin and calling apply_current_movement.
- InterpretedState.ForwardCommand bulk-copy — even if the flag had
been cleared, the body's InterpretedState.ForwardCommand stayed
at RunForward from the prior MoveTo cycle, so
apply_current_movement kept producing forward velocity.
- MoveToPath capture — staleness-timeout band-aid masked this.
Fix: lift the _remoteDeadReckon state-update block out of the
substate-only `else` branch so it runs for both overlay and substate
paths. For non-MoveTo packets, write fullMotion + speedMod directly to
InterpretedState.ForwardCommand/ForwardSpeed (bypassing
ApplyMotionToInterpretedState, which is a heuristic helper that
silently no-ops for Action class — see MotionInterpreter.cs:941-970).
This matches retail's copy_movement_from
(acclient_2013_pseudo_c.txt:293301-293311) bulk-copy semantics.
Also corrected RemoteMoveToDriver arrival predicate to retail-faithful:
chase = dist <= DistanceToObject; flee = dist >= MinDistance. The
prior max(MinDistance, DistanceToObject) defensive port happened to
compute the right value for ACE's wire defaults but had wrong
semantics (would have failed for any retail config with MinDistance >
DistanceToObject).
Tests: 1414 → 1416. New parser test for the AttackHigh1 wire layout;
new driver tests for retail-faithful chase/flee arrival.
Defers: target-guid live resolution for type 6 packets (chase-lag
mitigation, symptom #3), StickToObject sticky-target guid trailing
field, full MoveToManager port (CheckProgressMade, pending_actions
queue, Sticky/StickTo, use_final_heading).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-observed regressions on commit 186a584:
1. "Monster keeps running in different directions when it should be
attacking" — chase oscillates around the player at melee range
instead of stopping. Root cause: arrival check used MinDistance
only (retail's algorithm), but ACE puts the melee threshold in
DistanceToObject (default 0.6) and leaves MinDistance at 0. So
our check was never satisfied; body kept re-targeting around the
player as each MoveTo refresh moved the destination.
Fix: arrival = dist <= max(MinDistance, DistanceToObject) + epsilon.
Honors retail when retail sets MinDistance > 0; falls through to
ACE's DistanceToObject when MinDistance is 0. Confirmed by
independent research (named retail decomp, ACE wire writers,
holtburger client) that DistanceToObject is the documented chase
threshold in ACE; retail's MinDistance is only meaningful when
server config overrides the default 0.
2. "Monster disappears, then runs in place" — entity left our
streaming view, server stopped emitting MoveTo, last destination
stayed cached. When entity re-entered view, body still steered
toward the stale point, eventually arrived (V=0), animation kept
playing → "running on the spot."
Fix: 1.5 s stale-destination timeout. ACE re-emits MoveTo at
~1 Hz during active chase; if no fresh packet for 1.5 s, the
entity has either left view, transitioned off MoveTo without us
seeing the cancel UM, or had its move cancelled server-side.
Clear destination + zero velocity so the next interpreted-motion
UM (or fresh MoveTo) drives the body cleanly.
Also confirmed (via dispatched research subagent against ACE writer
side, named retail MovementManager::PerformMovement, and holtburger):
the wire's "Origin" field IS the destination, not the start position.
My driver's interpretation was correct; the symptoms were arrival
threshold + staleness, not a misread of the wire.
Tests: 1412 → 1414 (ACE-melee arrival, retail-MinDistance arrival).
Origin-stale lag during active chase remains — server's Origin is
the target's position at packet-emit time, ~1 s behind the player.
For type 6 MoveToObject, the retail-faithful fix is target-guid
live resolution per HandleUpdateTarget @ 0x0052a7d0; deferred per
the pseudocode doc's "out of scope" list. For type 7 there's no
fix without target-velocity prediction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports retail's ParticleEmitterInfo / Particle::Init / Particle::Update
(0x005170d0..0x0051d400) and PhysicsScript runtime to a C# data-layer
plus a Silk.NET billboard renderer. Sky-PES path is debug-only behind
ACDREAM_ENABLE_SKY_PES because named-retail decomp confirms GameSky
copies SkyObject.pes_id but never reads it (CreateDeletePhysicsObjects
0x005073c0, MakeObject 0x00506ee0, UseTime 0x005075b0).
Post-review fixes folded into this commit:
H1: AttachLocal (is_parent_local=1) follows live parent each frame.
ParticleSystem.UpdateEmitterAnchor + ParticleHookSink.UpdateEntityAnchor
let the owning subsystem refresh AnchorPos every tick — matches
ParticleEmitter::UpdateParticles 0x0051d2d4 which re-reads the live
parent frame when is_parent_local != 0. Drops the renderer-side
cameraOffset hack that only worked when the parent was the camera.
H3: Strip the long stale comment in GfxObjMesh.cs that contradicted the
retail-faithful (1 - translucency) opacity formula. The code was
right; the comment was a leftover from an earlier hypothesis and
would have invited a wrong "fix".
M1: SkyRenderer tracks textures whose wrap mode it set to ClampToEdge
and restores them to Repeat at end-of-pass, so non-sky renderers
that share the GL handle can't silently inherit clamped wrap state.
M2: Post-scene Z-offset (-120m) only fires when the SkyObject is
weather-flagged AND bit 0x08 is clear, matching retail
GameSky::UpdatePosition 0x00506dd0. The old code applied it to
every post-scene object — a no-op today (every Dereth post-scene
entry happens to be weather-flagged) but a future post-scene-only
sun rim would have been pushed below the camera.
M4: ParticleSystem.EmitterDied event lets ParticleHookSink prune dead
handles from the per-entity tracking dictionaries, fixing a slow
leak where naturally-expired emitters' handles stayed in the
ConcurrentBag forever during long sessions.
M5: SkyPesEntityId moves the post-scene flag bit to 0x08000000 so it
can't ever overlap the object-index range. Synthetic IDs stay in
the reserved 0xFxxxxxxx space.
New tests (ParticleSystemTests + ParticleHookSinkTests):
- UpdateEmitterAnchor_AttachLocal_ParticlePositionFollowsLiveAnchor
- UpdateEmitterAnchor_AttachLocalCleared_ParticleFrozenAtSpawnOrigin
- EmitterDied_FiresOncePerHandle_AfterAllParticlesExpire
- Birthrate_PerSec_EmitsOnePerTickWhenIntervalElapsed (retail-faithful
single-emit-per-frame behavior)
- UpdateEntityAnchor_WithAttachLocal_MovesParticleToLiveAnchor
- EmitterDied_PrunesPerEntityHandleTracking
dotnet build green, dotnet test green: 695 / 393 / 243 = 1331 passed
(up from 1325).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root-causing the user-reported "monsters disappearing some time +
laggy/jittery locomotion" via systematic-debugging Phase 1: our
UpdateMotion parser kept only speed/runRate/flags from a movementType
6/7 packet and discarded Origin (destination), targetGuid, and the
distance/walkRunThreshold/desiredHeading half of MovementParameters.
The integrator consequently held Body.Velocity at zero during MoveTo
("incomplete state" stabilizer 882a07c), so the body froze with legs
animating until UpdatePosition snap-teleported it — sometimes outside
the visible window (disappearing) — and constant-velocity drift along
the old heading between snaps produced jitter on every UP correction.
The 882a07c stabilizer was deliberately conservative because the state
WAS incomplete. Completing the data plumbing makes its restriction
moot: with the full MoveTo payload captured, the body solver has every
field retail's MoveToManager::HandleMoveToPosition (0x00529d80) reads.
Why: server re-emits MoveTo packets ~1 Hz with refreshed Origin while
chasing — verified in the live log (guid 0x800003B5 seq 0x01FE→0x0204
all show different cell/xyz floats). Those are heading updates we'd
been throwing away. With the full payload retained, the per-tick driver
steers body orientation toward Origin (±20° snap tolerance, π/2 rad/s
turn rate above tolerance) and lets apply_current_movement fill in
Velocity from the existing RunForward cycle — no new motion path,
just the right heading.
Scope is the minimum viable subset: target re-tracking, sticky/StickTo,
fail-distance progress detector, and sphere-cylinder distance are
server-side concerns we don't need (server's emit cadence handles all
of them). MoveToObject_Internal target-guid resolution is also skipped
— Origin is refreshed each packet, so the effective target tracks the
real entity even without a guid lookup.
Cross-references:
- docs/research/named-retail/acclient_2013_pseudo_c.txt — MoveToManager
+ MovementParameters::UnPackNet (0x0052ac50) + apply_run_to_command
(0x00527be0). 18,366 named PDB symbols make this the primary oracle.
- references/ACE/Source/ACE.Server/Physics/Animation/MoveToManager.cs
— port aid; flagged divergences (WalkRunThreshold default, set_heading
snap, inRange one-shot) called out in the new pseudocode doc.
- docs/research/2026-04-28-remote-moveto-pseudocode.md — pseudocode +
ACE divergence flags + out-of-scope list per CLAUDE.md mandatory
workflow (decompile → cross-reference → pseudocode → port).
Tests: 1404 → 1412 (parser type-7 path retention + type-6 target guid
retention; driver arrival, in-tolerance snap, beyond-tolerance step,
behind-target shortest-path turn, arrival preserves orientation,
Origin→world landblock-grid arithmetic).
Pending visual sign-off — handoff stabilizer 882a07c was the last
commit the user tested.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Retail MovementManager::PerformMovement (0x00524440) reads MoveTo speed and runRate from the packet, MovementParameters::UnPackNet (0x0052AC50) defines the layout, and CMotionInterp::apply_run_to_command (0x00527BE0) multiplies RunForward by runRate. Parse those fields for UpdateMotion/CreateObject, seed server-controlled MoveTo locomotion with the retail speed multiplier, and avoid overriding active monster MoveTo with sparse UpdatePosition-derived velocity.
Retail MoveToManager::BeginMoveForward calls MovementParameters::get_command (0x0052AA00) and then _DoMotion/adjust_motion, so a server-controlled MoveTo begins visible forward locomotion before the next UpdatePosition echo. Seed RunForward for MoveTo packets that omit ForwardCommand, while preserving active locomotion and letting position velocity refine walk/run/stop.
Handle retail ObjectDelete (0xF747) using CM_Physics::DispatchSB_DeleteObject 0x006AC6A0 / SmartBox::HandleDeleteObject 0x00451EA0 and ACE GameMessageDeleteObject so dead creatures are removed when corpses spawn.
Route action-class ForwardCommand values through AnimationCommandRouter/PlayAction instead of SetCycle so creature attack commands 0x51/0x52/0x53 survive the immediate Ready echo, matching CMotionTable::GetObjectSequence 0x00522860 / ACE MotionTable.GetObjectSequence.
Use server-authoritative UpdatePosition velocity, or observed server position delta for non-player entities when HasVelocity is absent, to reduce monster/NPC chase lag without applying player RUM prediction to server-controlled creatures.
Six commits on the branch, three retail-decomp investigations
(in-house + two external code-review agents) converging on the
same root causes:
97fc1b5 fix(sky): translucency-as-opacity + sky fog floor + additive fog-skip
05a8a72 fix(sky): retail-faithful sun-vector magnitude for SunColor / AmbientColor
034a684 fix(sky): partition sky pass on Properties bit 0x01, not bit 0x04
375065b fix(meshing): Translucent flag overrides Additive blend per retail SetSurface
646ccca feat(sky): load Setup-backed (0x020xxx) sky objects via SetupMesh.Flatten
0c82d2c docs(issues): #28 root-caused (PES particles), #29 filed
Net effect:
* Sun + ambient colors now use retail's |sunVec| magnitude formula
from PrimD3DRender::UpdateLightsInternal at decomp 424118 — fixes
blue-white sky tint at most keyframes.
* Surface.Translucency is used DIRECTLY as opacity (not 1-x) per
D3DPolyRender::SetSurface at decomp 425255 — fixes 3× too-bright
cloud + correct rain alpha.
* Sky fog re-enabled with SKY_FOG_FLOOR=0.2 mitigation — horizon
haze visible without flat-fogging the dome at storm keyframes.
* Additive surfaces skip fog per SetFFFogAlphaDisabled at decomp
425295 — sun stays bright at horizon dusk/dawn.
* Pre/post-scene partition is bit 0x01 (post-scene placement) instead
of bit 0x04 (weather gate), per GameSky::CreateDeletePhysicsObjects
at decomp 269036. Fixes double-rendered foreground rain.
* Translucent flag forces alpha-blend over Additive when ClipMap is
set, matching retail's blend resolution at decomp 425246-425260.
Cloud surface 0x08000023 now classified correctly.
* Setup-backed sky objects (0x020xxxxx) now load via SetupMesh.Flatten
instead of being silently dropped by EnsureMeshUploaded.
Tests: 1227 pass.
User-visible improvements: foreground rain matches retail's
volumetric look, sky tint shifted from blue-white toward retail's
warm-gray, additive sun stays bright through horizon haze.
Outstanding:
* Issue #28 — PES particle rendering ("aurora light play"). Now
root-caused with implementation outline; defer to its own Phase.
* Issue #29 — residual cloud-density gap; likely rolls into #28.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
# src/AcDream.App/Rendering/GameWindow.cs
Two independent investigations (in-house decomp re-check + two
external agent reports) converged on the same root cause for the
"too blue-white sky" symptom:
acdream computed SunColor = DirColor × DirBright and AmbientColor =
AmbColor × AmbBright. Retail computes them from the magnitude of a
specially-shaped sun vector instead. Per the named retail decomp:
SkyDesc::GetLighting at 0x00500ac9 (decomp 261343-261353):
sunVec.x = sin(H_rad) × DirBright × cos(P_rad)
sunVec.y = cos(P_rad) ← NOT scaled by DirBright
sunVec.z = DirBright × sin(P_rad)
PrimD3DRender::UpdateLightsInternal at 0x0059b57c (decomp 424118):
D3DLIGHT9.Diffuse.r = sunlight_color.r × sqrt(x²+y²+z²)
SmartBox::SetWorldAmbientLight callsite at 0x0050560b (decomp 267117):
SetWorldAmbientLight(sqrt(|sunVec|²) × 0.2 + ambient_level, ...)
Y stays unscaled by DirBright on purpose, so |sunVec| ≠ DirBright in
general — the magnitude varies with sun pitch/heading. That's what
gives retail's "sun feels stronger when it's overhead, ambient warms
up at midday" behavior we were missing.
Added SkyStateProvider.RetailSunVector(kf) that builds the vector
verbatim. SkyKeyframe.SunColor / AmbientColor now compose via |sunVec|.
SunDirectionFromKeyframe normalizes the same vector (replaces our
geometrically-clean spherical convention which didn't match retail's
deliberate Y-decoupled-from-heading shape).
Tests:
- Replaced the linear-interp assumption in
Interpolate_BetweenKeyframes_LerpsColors with a test on the RAW
inputs (DirColor, AmbBright, etc.) — those still lerp linearly;
the composite SunColor doesn't, intentionally.
- Added 4 golden-value tests for the new formulas
(RetailSunVector_AtZenith, _AtHorizonNorth,
SunColor_UsesRetailMagnitudeNotDirBrightDirectly,
AmbientColor_BoostsByTwentyPercentOfSunVectorLength).
- Updated stale LoadFromRegion_SunColor_IsPrepreMultipliedByBrightness
test to LoadFromRegion_SunColor_UsesRetailSunVectorMagnitude
with the new expected magnitude.
User visually verified — acdream's sky shifted from blue-white toward
the warm tint retail shows at the same keyframe.
1227 tests pass.
Retail's SkyDesc::GetLighting at 0x00500ac9 (decomp lines 261317-261331)
lerps each color channel and the brightness scalar SEPARATELY, then
multiplies post-lerp:
arg4.r = lerp(k1.amb_color.r, k2.amb_color.r, u)
arg4.g = lerp(k1.amb_color.g, k2.amb_color.g, u)
arg4.b = lerp(k1.amb_color.b, k2.amb_color.b, u)
arg3 = lerp(k1.amb_bright, k2.amb_bright, u)
final = (arg4.rgb * arg3, ...)
acdream pre-multiplied (color × bright) at LOAD time
(`SkyDescLoader.cs:558-559`) and then lerped the product. For any
keyframe pair where both color and brightness change, the two are
mathematically distinct. Example, k1=(white, b=0.5) k2=(black, b=1.0)
at u=0.5:
- retail: color=gray(0.5), bright=0.75 → final = (0.375, 0.375, 0.375)
- acdream: lerp((0.5,0.5,0.5), (0,0,0), 0.5) = (0.25, 0.25, 0.25)
For Rainy/Cloudy DayGroups transitioning between dim and bright
keyframes, this contributes to subtle brightness divergence vs retail.
Refactor:
SkyKeyframe stores DirColor / DirBright / AmbColor / AmbBright
SEPARATELY (raw, not pre-multiplied).
Computed properties SunColor and AmbientColor return the
post-multiplied product, keeping the shader uniform interface
(uSunColor / uAmbientColor) unchanged.
SkyStateProvider.Interpolate lerps each raw channel, then constructs
a new SkyKeyframe whose computed properties yield the correct
post-lerp multiply.
SkyDescLoader now stores raw values without pre-multiplying.
GameWindow comment updated; no functional change there.
Default factory + tests updated to use the new constructor parameters
with DirBright=AmbBright=1.0 (preserving exact existing behavior).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs in calendar display (the CLOCK ITSELF was already correct):
1. **Month enum had wrong order + non-retail names.** Old enum:
Snowreap=0, ColdMeet, Leafdawning, Seedsow, Rosetide, Solclaim, ...
At day-of-year 83 this gave month index 2 = Leafdawning. Retail's
@timestamp at the same moment shows "Seedsow 24". Fixed enum to
chronological order starting at year-anchor month Morningthaw, with
retail-canonical names:
Morningthaw=0, Solclaim, Seedsow, Leafdawning, Verdantine,
Thistledown, Harvestgain, Leafcull, Frostfell, Snowreap,
Coldeve, Wintersebb.
At day-of-year 83 → month 2 = Seedsow ✓
2. **ToCalendar returned relative year, not absolute Portal Year.**
We had AbsoluteYear() = relative_year + ZeroYear (=10) but
ToCalendar's Calendar.Year was the relative one. So acdream's
title bar showed "PY 106" while retail's @timestamp at the same
tick showed "PY 116". Fixed ToCalendar to add ZeroYear so the
exposed Calendar.Year matches retail's display.
3. **GameWindow title bar now shows the calendar.** Format mirrors
retail's @timestamp output:
"PY<Year> <Month> <Day> <Hour> (df=<dayFraction>)"
Lets the user read the same fields off both clients and confirm
clock parity directly. Drift > 1 hour = real bug.
Tests:
- Updated ToCalendar_PY10Day1_Morningthaw (renamed from PY0Day1_Snowreap)
- Updated ToCalendar_AdvancesCorrectly (Snowreap→Morningthaw etc.)
- Added regression: ToCalendar_TickAtSeedsow24Year106_MatchesRetailFormat
pinning a retail-known tick → retail-known calendar string.
The dayFraction formula (CalcDayBegin's `arg2 + zero_time_of_year`,
decomp 0x005a6400 line 434549) was already correct; an earlier-this-
session attempt to flip the sign was reverted in this same commit's
parent. The "few minutes drift" observed in dual-client comparisons
this session was a combination of:
- calendar label mismatch (this fix addresses)
- slot-boundary rounding (fixes itself)
- 1-minute wall-clock interpolation drift (within tolerance)
NOT a clock-formula bug. ISSUE #3 in docs/ISSUES.md is now misnamed
("Client clock drifts from retail"); plan to re-title or close in a
follow-up commit after the visual-divergence investigation lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two should-fix items from the pre-merge code review pass:
1. Persisted settings now apply on startup unconditionally
(previously gated on ACDREAM_DEVTOOLS=1).
2. Music + Ambient volume sliders are hidden because the
underlying engine paths don't exist yet (R5 MIDI playback).
== 1. Settings load + apply outside DevToolsEnabled gate ==
Previous structure put SettingsStore construction, LoadDisplay /
LoadAudio / etc, and ApplyDisplayWindowState inside the
`if (DevToolsEnabled)` block. A user running with the env var unset
silently got WindowOptions defaults (1280x720 / VSync=false /
60° FOV) instead of their saved settings.json values — even though
the settings file existed and was valid.
Refactored: extracted LoadAndApplyPersistedSettings() that runs
unconditionally in OnLoad after _audioEngine is constructed but
before the DevToolsEnabled block. Persisted values cached as
_persistedDisplay / _persistedAudio / _persistedGameplay /
_persistedChat / _persistedCharacter fields. The Settings PANEL
construction (devtools-gated, naturally — no UI without ImGui) now
reads those fields when wiring SettingsVM.
The Settings UI gating is correct (panel needs ImGui devtools);
the persisted-runtime-state gating was the bug.
== 2. Music + Ambient sliders hidden ==
OpenAlAudioEngine has Music/MusicVolume/Ambient/AmbientVolume
properties but they're never read — PlayMusic is a stub for R5 MIDI
playback that hasn't shipped, StartAmbient reserves a handle but
doesn't start a source. Dragging those sliders moved a number that
nothing observed.
Hid the Music + Ambient sliders from RenderAudioTab; left the
AudioSettings record fields intact so settings.json round-trips
the values across phases — when R5 lands and the sliders return,
saved values will already be in place. Updated the panel's footer
note to call out the limitation. Updated
Audio_tab_when_active_renders_implemented_volume_sliders to assert
Master + SFX are present AND Music + Ambient are absent.
dotnet build green; dotnet test 1,309 / 1,309 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The InvisibleButton drag-trap inside BeginChild only catches clicks
inside that specific child. Chat had widgets OUTSIDE the inner
##chattail child (the Copy-mode Checkbox + a Separator at top, the
footer Separator + InputTextSubmit at bottom) — empty space around
those widgets fell through directly to the parent window's
window-drag init.
Fix: wrap the entire chat panel body in a single outer ##chatbody
BeginChild before drawing any content. The renderer's drag-trap
fires inside this outer child too, absorbing every empty-space
click in the chat panel body. The inner ##chattail child is now
nested inside it, which doesn't change its scroll-tail semantics
but does mean it gets its own drag-trap as a bonus.
Test fixed: Render_BeginChild_ReservesNegativeFooterFromFrameHeight
was using Single(BeginChild) — there are now two BeginChild calls
(##chatbody outer + ##chattail inner). Switched to Single(... &&
Args[0] == "##chattail") so the test still pins the footer reserve
on the inner call where it lives.
dotnet build green; 1,309 / 1,309 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported wanting to mark text in-game and copy it out (item names,
coordinates, NPC dialogue, etc). ImGui doesn't natively let you select
across multiple TextColored widgets, but a read-only multi-line
InputText is fully click-drag selectable + Ctrl+C copyable. This
commit adds a "Copy mode" toggle to ChatPanel that swaps the chat
tail's render path between the colored-line view and a single
selectable text region.
New IPanelRenderer primitive:
void TextMultilineReadOnly(string id, string content, Vector2 size);
ImGui maps this to InputTextMultiline with the ReadOnly flag — same
selection + Ctrl+C UX a user expects from any text-input widget.
FakePanelRenderer records the call for tests. The future D.2b
custom retail-look backend implements its own equivalent (likely
the same widget pattern with retail font/skin).
ChatPanel rendering:
· A "Copy mode (select text to Ctrl+C)" Checkbox at the top of
the panel toggles _copyMode.
· Off (default) — current per-line render with colored combat
entries. Visually unchanged from before.
· On — the chat tail becomes a single TextMultilineReadOnly
widget holding every visible line joined with newlines. Loses
per-line color, gains arbitrary-span text selection.
· Footer (separator + input field) renders identically in both
modes so the user can still type while in copy mode.
Existing ChatPanelLayoutTests's footer-separator probe was using
IndexOf("Separator") — which now matches the new pre-tail separator
between the Checkbox and the chat tail. Switched to LastIndexOf
which still pins the footer separator (between EndChild and
InputTextSubmit). Behaviour and intent unchanged.
DisplaySettingsTests' With_expression test was still asserting the
old "1920x1080" Default.Resolution; updated to the new "1280x720"
that the previous wire-up commit introduced (the earlier commit
forgot this one).
dotnet build green (0 warnings); dotnet test 1,309 / 1,309 green
(243 Core.Net + 393 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase L.0 polish — the Display + Character tabs were persisting to disk
but didn't yet drive runtime behavior. This commit flips the live
switches.
DISPLAY ↔ GL window:
· FOV slider (degrees) → camera FovY (radians) on Orbit + Fly + Chase,
pushed every frame so dragging is visible immediately. Brainstorm
said FOV is a live-preview slider; this delivers it.
· VSync → _window.VSync, change-detected per-frame so flipping the
checkbox is instant. Applied at startup too so saved-VSync takes
effect before the first frame.
· Resolution → _window.Size on Save (TryParseResolution parses
"WIDTHxHEIGHT"). Live preview would be too jarring; resize is on
Save only.
· Fullscreen → _window.WindowState (Silk.NET borderless mode), also
on Save only.
· ShowFps → wraps the title-bar perf string. true → full perf line;
false → just "acdream" for a cleaner alt-tab. Default true matches
pre-L.0 behavior.
Defaults rebalanced — FieldOfView 75→60° (matches Orbit/Fly/Chase
FovY = π/3), VSync true→false (matches the previous WindowOptions),
ShowFps false→true (preserves the existing perf-in-title behavior).
Net effect: a user who never opens Display tab + later opens it +
Saves without touching anything sees ZERO visual change. Tests pinned
to the new defaults.
ApplyDisplayWindowState helper consolidates the window-side
mutations. Called from the SettingsVM construction site (apply
persisted at startup) and from the onSaveDisplay callback (apply
saved on demand). Malformed resolution strings are silently ignored
to avoid crashing mid-session if settings.json gets hand-edited.
CHARACTER ↔ active toon:
· _activeToonKey field replaces the hard-coded "default" — starts as
"default" (used for any pre-login Settings interaction), gets
swapped to the actual character.Name immediately after EnterWorld
in BeginLiveSessionAsync.
· onSaveCharacter callback closes over _activeToonKey by reference
(lambda captures `this`), so saves always write to the current
toon's slot without rebinding the lambda.
· After EnterWorld lands the chosen toon's name, the host loads
that toon's bag via SettingsStore.LoadCharacter and calls a new
SettingsVM.LoadCharacterContext to swap BOTH persisted snapshot
AND draft atomically — HasUnsavedChanges stays false on login so
the user doesn't see a "pending changes" indicator just because
they switched toons.
Per-toon storage already worked at the SettingsStore layer (commit
73749d1); this commit just plumbs the actual character name through
to the toonKey instead of always using "default".
2 new tests for LoadCharacterContext: atomic persisted+draft swap,
and pending edits getting wiped on swap (so pre-login bleed-through
can't write to the new toon's slot).
dotnet build green (0 warnings); dotnet test 1,309 / 1,309 green
(243 Core.Net + 393 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase L.0 (final) — last tab on the Settings shell. Per-toon
preferences keyed by toon name in settings.json under
character[<toonName>]. With this commit the L.0 build order
finishes and every approved tab is implemented.
CharacterSettings record (4 fields):
· DefaultChatChannel (string — Local / Allegiance / Fellowship / etc)
· AutoAttack (bool — continue swinging until target dies)
· ConfirmSalvage (bool — prompt before salvaging valuable items)
· ShowPickupMessages (bool — pickup lines in chat)
AvailableChannels static list exposes the 7 retail-routing targets
for the dropdown.
SettingsStore grows LoadCharacter(toonKey) / SaveCharacter(toonKey)
using JsonNode/JsonObject for the nested-toon write — the existing
SaveSection raw-text-preservation pattern handles top-level keys
but doesn't fit the nested per-toon mutation. The character map
preserves every other toon's settings on save, and other top-level
sections (display / audio / gameplay / chat) are preserved too.
SettingsVM grows the parallel character state machine. The host
owns the toonKey (currently hard-coded to "default" in GameWindow
because we don't have a current-character source plumbed yet) —
the VM just edits whatever bag the host loaded.
SettingsPanel.RenderCharacterTab replaces the L.0-shell placeholder
— a Combo for default chat channel + 3 Checkboxes for
AutoAttack / ConfirmSalvage / ShowPickupMessages. The
RenderPlaceholder helper is now removed (no callers); the old
"Placeholder_tabs_render_coming_soon_text_when_active" test is
replaced by an "all six tabs are implemented" guard test that
fails if any future commit adds a placeholder back.
GameWindow loads/saves character settings under toonKey "default"
with a TODO comment to swap in the real toon name once
CharacterList plumbing exposes a currentCharacter source.
18 new tests:
· CharacterSettings record (4) — defaults pinned, AvailableChannels
list shape, value equality, with-expressions
· SettingsStore character (6) — missing-file / toon-not-in-file →
defaults, round-trip, multi-toon preservation, preserves other
top-level sections, all five sections coexist
· SettingsVM character (5) — initial draft, SetCharacter marks
dirty, Save invokes callback, Cancel reverts, ResetAllToDefaults
covers
· SettingsPanel character tab (3 net, after removing the
placeholder test) — combo+checkboxes render only when active,
channel combo uses AvailableChannels, all six tabs are now
non-placeholder
Phase L.0 final tally:
· 5 commits on feature/settings-retail (shell + 5 tabs)
· 6 tabs: Keybinds (Phase K) + Display + Audio + Gameplay + Chat + Character
· 5 settings sections in settings.json (display/audio/gameplay/chat/character),
coexisting non-destructively + a sixth file (keybinds.json) on the side.
dotnet build green (0 warnings); dotnet test 1,307 / 1,307 green
(243 Core.Net + 391 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase L.0 (cont.) — fourth tab on the Settings shell. Mixes retail's
CharacterOptions2 chat-channel filter bits (Hear*Chat / TimeStamp /
FilterLanguage / AppearOffline) with a font-size slider that has no
retail bitfield equivalent.
ChatSettings record (9 fields):
· 5 channel filters: HearGeneralChat, HearTradeChat, HearLFGChat,
HearRoleplayChat, HearSocietyChat
· 3 display flags: ShowTimestamps, FilterProfanity, AppearOffline
· 1 visual: FontSize (10..20 pt)
Local-only this phase per the brainstorm — Hear*Chat flags affect
client-side display filtering only; the server still streams every
channel. Server-sync arrives later when the protocol round-trip is
in place.
SettingsStore grows LoadChat / SaveChat using the existing generic
SaveSection helper. All four non-keybind sections (display, audio,
gameplay, chat) now coexist non-destructively in settings.json.
SettingsVM grows the parallel chat state machine. HasUnsavedChanges,
Save, Cancel, ResetAllToDefaults all cover chat. Constructor signature
adds two more params; existing call sites updated.
SettingsPanel.RenderChatTab replaces the L.0-shell placeholder —
8 Checkbox calls grouped under "Channel filters" + "Display"
headers, plus a font-size SliderFloat. The "Coming soon" placeholder
test was retargeted from "Chat" to "Character" since Chat is no
longer a placeholder.
GameWindow wires SettingsStore.LoadChat / SaveChat + a TODO comment
for the future ChatPanel filter integration (read SettingsVM.ChatDraft
when filtering inbound chat lines).
13 new tests:
· ChatSettings record (3) — defaults pinned, value equality, with-
expressions
· SettingsStore chat (3) — missing-file → defaults, round-trip, all
four sections coexist
· SettingsVM chat (5) — initial draft, SetChat marks dirty, Save
invokes callback, Cancel reverts, ResetAllToDefaults covers
· SettingsPanel chat tab (2) — checkboxes + slider render only when
active
dotnet build green (0 warnings); dotnet test 1,289 / 1,289 green
(243 Core.Net + 373 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase L.0 (cont.) — third tab on the Settings shell, in the Easy-wins
build order. Subset of retail's CharacterOption + CharacterOptions2
bitfield flags ported as bools (see acclient.h:3404+ enum). Local-
only this phase per the brainstorm — server sync deferred to a
later phase that will marshal the draft into the retail
CharacterOption packet.
GameplaySettings record exposes 14 named flags grouped by usage:
· Combat: AutoTarget, AutoRepeatAttack, ToggleRun, AdvancedCombatUI,
VividTargetingIndicator
· Display: ShowTooltips, SideBySideVitals, CoordinatesOnRadar,
SpellDuration, ShowHelm, ShowCloak
· Interface: AllowGive, LockUI, UseMouseTurning
Retail names + bit values are documented in field-level comments so
the future server-sync phase has a 1:1 mapping. Defaults are
typical-user starting points (NOT bit-exact to retail's
0x50C4A54A / 0x948700 masks); class-level remarks call out that
defaults will be re-anchored to retail values once the wire-format
is the load-bearing source.
SettingsStore grows LoadGameplay / SaveGameplay using the existing
SaveSection generic helper (added in the audio commit). All three
non-keybind sections (display, audio, gameplay) now coexist in
settings.json with non-destructive cross-section saves — verified
by a new "all three sections coexist" round-trip test.
SettingsVM grows the parallel gameplay state machine
(gameplayPersisted / gameplayDraft / SetGameplay / onSaveGameplay).
HasUnsavedChanges, Save, Cancel, ResetAllToDefaults all cover
gameplay too. Constructor signature adds two more params; existing
call sites (App startup + tests) updated.
SettingsPanel.RenderGameplayTab replaces the L.0-shell placeholder —
14 Checkbox calls grouped under three Text+Separator headers, plus
a footer note explaining the local-only-this-phase scope. The
"Coming soon" placeholder test was retargeted from "Gameplay" to
"Chat" since Gameplay is no longer a placeholder.
GameWindow construction site loads gameplay on startup + writes via
the SettingsStore on Save. Server-sync packet wiring is left as a
TODO comment in the onSaveGameplay callback (next phase, after the
protocol round-trip is in place).
14 new tests:
· GameplaySettings record (3) — defaults pinned, value equality,
with-expressions
· SettingsStore gameplay (4) — missing-file → defaults, round-trip,
partial-file fallback, all-three-sections coexist
· SettingsVM gameplay (5) — initial draft, SetGameplay marks dirty,
Save invokes callback, Cancel reverts, ResetAllToDefaults covers
· SettingsPanel gameplay tab (2) — 8 spot-checked Checkboxes render
only when active
dotnet build green (0 warnings); dotnet test 1,276 / 1,276 green
(243 Core.Net + 360 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase L.0 (cont.) — second tab on the Settings shell, in the Easy-wins
build order. Audio is the live-preview poster child: dragging a slider
is audible immediately, Save persists, Cancel reverts and the engine
catches up on the next frame.
AudioSettings record: Master / Music / Sfx / Ambient (all 0..1 floats).
Defaults match the OpenAlAudioEngine constructor values exactly so a
user who never opens the tab gets identical behaviour to the
pre-Phase-L env-var-only world (Master=1.0, Music=0.7, Sfx=1.0,
Ambient=0.8).
SettingsStore grows LoadAudio / SaveAudio + a generic SaveSection
helper that consolidates the unknown-top-level-key preservation logic.
Display and Audio sections coexist in settings.json:
{ "version": 1, "display": { ... }, "audio": { ... } }
Saving one section preserves the other on disk; a future Gameplay /
Chat / Character section drops in the same way without touching
existing data.
SettingsVM gains a parallel audio state machine (audioPersisted /
audioDraft / SetAudio / onSaveAudio callback). HasUnsavedChanges
covers all three buckets now (keybinds + display + audio); Save /
Cancel / ResetAll are atomic across all of them.
GameWindow wiring is the live-preview mechanism — every render frame
pushes the VM's AudioDraft into _audioEngine.MasterVolume etc. Cheap
(four float assignments) and unconditional. SetListener still applies
MasterVolume each frame too via the existing Phase E.2 code path, so
listener gain stays in sync. Persisted audio is applied to the engine
ONCE at startup before the first frame so the user's saved values
take effect before any sound plays — startup-time apply happens during
the same SettingsVM construction site that does the LoadDisplay +
LoadAudio.
SettingsPanel.RenderAudioTab replaces the L.0-shell placeholder — four
SliderFloat calls clamped to [0, 1], plus a footer note explaining the
live-preview UX. The "Coming soon" placeholder test was retargeted
from "Audio" to "Gameplay" since Audio is no longer a placeholder.
16 new tests:
· AudioSettings record (3) — defaults pin engine constants, value
equality, with-expressions
· SettingsStore audio round-trip (5) — missing-file → defaults,
round-trip all fields, partial-file per-field fallback, save-audio-
preserves-display, save-display-preserves-audio
· SettingsVM audio state (5) — initial draft tracks persisted,
SetAudio marks dirty, Save invokes audio callback, Cancel reverts,
ResetAllToDefaults covers audio
· SettingsPanel audio tab (3) — four sliders render only when active,
no SliderFloat emitted on inactive tabs, slider range is [0, 1]
dotnet build green (0 warnings); dotnet test 1,262 / 1,262 green
(243 Core.Net + 346 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase L.0 (cont.) — first concrete tab on the new Settings shell, in
the Easy-wins build order agreed in the brainstorm
(Display → Audio → Gameplay → Chat → Character).
DisplaySettings (immutable record): Resolution / Fullscreen / VSync /
FieldOfView (30-120°) / Gamma (0.5-2.0) / ShowFps. Six common 16:9
resolutions in the dropdown. Defaults: 1920×1080, windowed, vsync on,
75° FOV, gamma 1.0, FPS off — matches the brainstorm UX agreement.
SettingsStore: JSON persistence at %LOCALAPPDATA%\acdream\settings.json
(coexists with keybinds.json — own load/save path stays put, no
migration needed). LoadDisplay falls back per-field when keys are
missing (partial-file tolerant) and falls back to defaults when the
file is corrupt or the JSON is unparseable. SaveDisplay round-trips
preserved — unknown top-level keys (e.g. an `audio` section written
by a future client) are kept on save so older builds don't silently
drop newer-tab data.
SettingsVM gains a parallel display-state machine: persistedDisplay +
draftDisplay, SetDisplay mutator, HasUnsavedChanges checks both
keybinds and display deltas, Save/Cancel/ResetAll cover both
atomically from the user's POV (one Save commits everything, one
Cancel reverts everything). Constructor signature extends with two
new params; existing keybinds-only callers updated.
SettingsPanel.RenderDisplayTab replaces the L.0-shell placeholder —
Combo for resolution, Checkboxes for fullscreen/vsync/show-fps,
SliderFloat for FOV + gamma. Live-preview note in the panel body
matches the agreed UX: FOV + gamma update visibly while the user
drags; resolution / fullscreen / vsync apply on Save (live preview
would be too jarring).
GameWindow wires SettingsStore into the existing SettingsVM construct
site — load on startup, save on each tab Save. Errors print to
console and don't crash the panel.
19 new tests:
· DisplaySettings record (4) — defaults pinned, value equality, with-
expressions, AvailableResolutions sorted ascending
· SettingsStore (6) — round trip, missing-file → defaults, corrupt-
file → defaults, partial-file → per-field fallback, unknown-key
preservation, DefaultPath shape
· SettingsVM display (6) — initial draft tracks persisted, SetDisplay
marks dirty, Save invokes display callback, Cancel reverts,
ResetAllToDefaults covers display, Save-then-Cancel is no-op
· SettingsPanel display tab (3) — widgets render only when active,
resolution combo uses AvailableResolutions, no Combo emitted on
inactive tabs
dotnet build green (0 warnings); dotnet test 1,246 / 1,246 green
(243 Core.Net + 330 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase L.0 — foundation for the complete retail-style Settings interface
agreed in the 2026-04-26 brainstorm. Splits Phase K's keybind-only F11
panel into a tabbed shell whose first tab wraps the existing keybinds
content unchanged; the other five tabs (Display / Audio / Gameplay /
Chat / Character) render "Coming soon" placeholders so the shape the
user approved is visible immediately and gets filled in over the L.x
sub-phases (Display first per Easy-wins build order).
Why a tab API extension: retail had distinct Options UIs
(gmGameplayOptionsUI / gmChatOptionsUI / gmCharacterSettingsUI per the
PDB at acclient_2013_pseudo_c.txt:170739+) and the existing
IPanelRenderer only exposed CollapsingHeader. ImGui maps
BeginTabBar / BeginTabItem / EndTabItem / EndTabBar 1:1, so the new
primitives stay backend-friendly — the future D.2b custom retail-look
backend implements them via the retail tab UIs without panel changes.
Save / Cancel / Reset-all stay above the tab bar so they remain global
across all tabs (Phase K's UX preserved). FakePanelRenderer grows
matching tab calls + an ActiveTabLabel knob so tests can target a
specific tab's content; default behavior treats the first tab item
seen as active so existing tests keep passing without changes.
5 new SettingsPanelTests assertions: tab bar opens once, six expected
tab labels emitted in order, Keybinds-tab section headers only render
when active, placeholders show "Coming soon" text on inactive-content
tabs, and Save/Cancel buttons render BEFORE the tab bar (regression
guard against accidentally moving them inside a tab item).
dotnet build green (0 warnings); dotnet test 1,227 / 1,227 green
(243 Core.Net + 311 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four issues from the K-fix2 launch (2026-04-26 user report):
1. Can't return from free-fly to player view.
CameraController.ToggleFly only swaps Fly↔Orbit, so a user who
flew out of player mode landed in orbit (Holtburg) on
toggle-back instead of the chase camera. Added
ToggleFlyOrChase() helper that prefers Fly→Chase /
Chase→Fly when _playerMode is true and a chase camera is
available; falls back to the original Fly↔Orbit toggle for
offline / pre-login flows. Wired into all three free-fly
entry points: keyboard shortcut (Ctrl+Shift+F), Camera menu
item, and DebugPanel button.
2. Shift while moving STOPS instead of dropping to walk.
Root cause: InputDispatcher.IsChordHeld required
_keyboard.CurrentModifiers to match chord.Modifiers EXACTLY.
So with W bound as (W, None), holding W and then pressing
Shift made CurrentModifiers=Shift mismatch chord (None) →
IsActionHeld(MovementForward) returned false → Forward flag
dropped → player stopped. Fixed by relaxing IsChordHeld:
when chord.Modifiers is None, Shift is allowed to coexist
(it's the retail walk-modifier). Other modifiers
(Ctrl, Alt, Win) still mismatch strictly so Ctrl+W stays a
distinct chord from W.
+2 tests pinning the new permissive-Shift / strict-Ctrl
semantics.
3. Backwards too slow when running.
forwardCmdSpeed for the WalkBackward branch was hardcoded
to 1.0; localY was hardcoded to -(WalkAnimSpeed * 0.65).
Neither honored input.Run. With Run=true (default),
backward now scales by runRate (~2.4×) so X = "run
backwards" matches the forward run pace × the 0.65
backward animation cycle ratio.
4. Strafe too slow when running.
localX for SideStepLeft / SideStepRight was hardcoded to
±SidestepAnimSpeed regardless of Run. Same fix: when Run
is held, scale by runRate so strafe at default speed
matches the run-forward pace.
Tests: 1220 → 1222 (the two new IsChordHeld tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase K final commit. Settings panel with click-to-rebind UX on top of
the K.1+K.2 input architecture, plus the roadmap / ISSUES / memory
updates that retire Phase K.
InputDispatcher gains BeginCapture / CancelCapture / IsCapturing /
SetBindings — modal capture suppresses normal action firing for the
next chord. Esc cancels (returns sentinel default chord); modifier-only
keys don't complete capture; non-modifier key down with current
modifier mask completes.
IPanelRenderer + ImGuiPanelRenderer + FakePanelRenderer gain
BeginMainMenuBar / EndMainMenuBar / BeginMenu / EndMenu / MenuItem
primitives.
SettingsVM owns a draft copy of KeyBindings with explicit Save /
Cancel / Reset semantics. Click-to-rebind enters dispatcher capture
mode; on chord captured, conflict-detect against draft (excluding the
action being rebound itself); surface a ConflictPrompt when the chord
collides; ResolveConflict(replace=true|false) commits or reverts.
ResetActionToDefault restores a single action to RetailDefaults();
ResetAllToDefaults rebuilds the entire draft. Save invokes the
onSave callback (which writes JSON + swaps the live dispatcher's
bindings).
SettingsPanel renders 8 retail-keymap-categorized CollapsingHeader
sections (Movement, Postures, Camera, Combat, UI panels, Chat,
Hotbar, Emotes). Per action: name + current binding(s) summary +
"Rebind"/"Reset" buttons. Conflict prompt at the top when pending.
Save / Cancel / "Reset all to retail defaults" at the top.
GameWindow registers SettingsPanel + wires F11 →
ToggleOptionsPanel → IsVisible toggle, plus a top-of-frame ImGui
MainMenuBar with View → Settings/Vitals/Chat/Debug entries (calls
ImGui directly — the abstraction methods exist for backend
portability but the host doesn't own a menu-bar surface).
Tests: +37 across InputDispatcherCaptureTests (7),
IPanelRendererMainMenuBarTests (9), SettingsVMTests (13),
SettingsPanelTests (8). Solution total 1220 green.
Roadmap (docs/plans/2026-04-11-roadmap.md) appends Phase K shipped
section after Phase J with K.1a–K.3 commit SHAs. ISSUES.md files
Phase L deferred work as #L.1–#L.8 (hotbar UI, spellbook favorites,
combat-mode dispatch, F-key panels, floating chat windows, UI layout
save/load, joystick bindings, plugin input subscription) and adds
#21–#25 to Recently closed. project_input_pipeline.md updated to
shipped state. CLAUDE.md gets an input-pipeline reference.
Closes Phase K.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five changes:
1. PlayerModeAutoEntry — testable guard class that fires once after
EnterWorld + WorldSession.State.InWorld + player entity present +
PlayerController.State == InWorld. GameWindow arms the entry
after EnterWorld; per-frame Tick checks all four guards and
invokes the same fly-to-player transition the Tab handler runs.
User-initiated fly toggle (DebugPanel button) Cancel()s pending
entry. Skip in offline mode (no ACDREAM_LIVE) — Holtburg orbit
stays default for testing.
2. MouseLookState + KeyBindings.RetailDefaults() binds MMB Hold to
InputAction.CameraInstantMouseLook. GameWindow subscribes:
- Press: hide cursor, capture position, _mouseLookActive = true.
- Release: restore cursor, deactivate.
- WantCaptureMouse=true while held → suspend (release cursor).
- MouseMove while active: combined drive — chase camera yaw +
character heading move together (retail's signature mouse-look
behavior). Camera Y still pitches camera-only.
3. DebugPanel "Toggle Free-Fly Mode" button via DebugVM.ToggleFlyMode
action delegate — replaces the F-key as the primary discovery
path for free-fly. Gated on DevToolsEnabled.
4. ChatPanel.FocusInput() one-shot + IPanelRenderer.SetKeyboardFocusHere
primitive. GameWindow's ToggleChatEntry (Tab) subscriber calls
_chatPanel.FocusInput() so Tab moves focus to the chat input
field. Replaces the K.1c TODO stub.
5. WantCaptureMouse gating reinforcement on surviving mouse handlers
(no new code; verified intact from K.1b).
21 new tests (8 PlayerModeAutoEntry, 10 MouseLookState, 3 ChatPanel
focus). 1183 total green. 0 warnings, 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>