Commit graph

398 commits

Author SHA1 Message Date
Erik
8db7a9ec28 docs(research): sky/weather investigation handoff + diagnostic tools
Captures everything learned from a long worktree iteration on the
foreground-rain bug (ISSUES.md #1 / #26) plus a new star-rendering
bug observed in the same area. The code work from that worktree
(WeatherDispatcher, EmitterDescLoader.LoadFromDat, WeatherCellRenderer,
GameWindow integration) was reverted because it didn't visibly fix
the rain bug — but the research findings + diagnostic tools are
durable and should not have to be rediscovered.

What's added:
- docs/research/2026-04-26-sky-investigation-handoff.md
  Comprehensive seed prompt for the next session. Covers:
  * Bug A: foreground rain (#26) — what's open, what's confirmed,
    what's been tried
  * Bug B: stars rendering as square in corner (NEW, user-observed)
  * 40-agent decomp scan findings — retail rain is NOT camera-
    particles, NOT server-driven, NOT screen-space; the mesh IS
    a hollow octagonal tube; only 5 weather GfxObjs in Dereth
  * Things ruled out by trial (envelope, scaling, unlit, depth-
    always alone, Setup loading)
  * Things to try next (depth+zfar combined, full render-state
    audit, frame ordering, star UV bug as easier first target)
  * Acceptance criteria for "done"

- docs/research/2026-04-26-chorizite-pr-draft.md
  Upstream PR draft for Chorizite/DatReaderWriter. Five generated
  DBObj source files reference nonexistent enum values and are
  silently excluded from the NuGet build:
  ParticleEmitterInfo, Clothing, PaletteSet, DataIdMapper,
  DualDataIdMapper. Fix: delete the duplicates. Independent of
  the rain work — benefits the AC modding ecosystem broadly.

- docs/research/2026-04-26-datreaderwriter-reference.md
  Developer reference for our DatReaderWriter usage. Version,
  types we consume, known broken types, thread-safety caveats,
  upgrade procedure, NuGet-vs-vendored decision matrix.

- tools/PesChainAudit/
  Recursive PES walker — given a 0x33xxxxxx script id, walks all
  CallPES references and dumps every hook + every referenced
  ParticleEmitter's parameters. Used to prove no weather PES
  emits rain particles.

- tools/TextureDump/
  Dumps texture pixel statistics (alpha histogram, brightness,
  max) and saves as PNG for visual inspection.

- tools/WeatherEnumerator/
  Enumerates every DayGroup in a Region, lists weather SkyObjects
  (Properties & 0x04), dumps GfxObj bounding boxes.

- tools/WeatherSetupProbe/
  Loads a Setup id, dumps each part's GfxObj + frame + scale +
  surface. Used to prove weather Setups are 5cm dummy carriers.

Worktree feature/sky-fixes is being deleted in a follow-up step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:40:34 +02:00
Erik
a060f4fc98 fix(player): apply AttributeFormula to wire-derived Run/Jump skill — root cause of short jumps
Found the underlying cause of the user's persistent
"jumps don't reach retail height" complaint. The wire's SkillEntry
`init` field is ONLY the InitLevel (training/specialized
chargen bonus, per ACE GameEventPlayerDescription.cs:317
"init_level, for training/specialized bonus from character
creation"). It does NOT include the AttributeFormula
contribution.

ACE's CreatureSkill.Current is computed as:
  AttributeFormula(skill, attrs) + InitLevel + Ranks
  + augs + multipliers - vitae

Pre-fix13 we used `init + ranks` only — dropping the
AttributeFormula term, which is the DOMINANT component for
movement skills (50-100 points typical). For our character
that meant Jump skill 208 instead of the actual ~280-310,
giving a 3.11 m peak instead of the retail ~4 m peak. Hence
"feels like the upward acceleration is too slow and we don't
reach the same height".

Fix:
- GameWindow caches portal.dat's SkillTable (0x0E000004u) at
  WireAll time. Each entry has a SkillFormula with attr1/
  attr2/multipliers/divisor/additive constants
  (formula:  bonus = (attr1*M1 + attr2*M2)/Div + Additive).
- GameEventWiring.WireAll gains a
  `resolveSkillFormulaBonus(skillId, attrCurrents)` callback.
  GameWindow plugs in a resolver that looks up
  SkillTable.Skills[skillId].Formula, applies the formula
  using the player's current attribute values from PD.
- The PD handler builds attrId→current map (ranks+start) from
  the parsed attributes before iterating skills, then passes
  it to the resolver for Run (24) and Jump (22).
- Total skill = formulaBonus + InitLevel + Ranks. Matches ACE
  Current minus augs/multipliers/vitae (close enough — those
  add maybe ±10 % at most).

ACDREAM_DUMP_VITALS=1 logs add a per-skill line:
  "vitals: PD-skill id=22 init=N ranks=N formulaBonus=N total=N"
so live testing can confirm the formula is applied.

Tests stay 1222 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:56:38 +02:00
Erik
4b6fcffa01 fix(camera): pin chase-camera Z to last-grounded while airborne
K-fix11 (150 ms exponential lag) wasn't aggressive enough — at
0.15 s time constant the camera catches up to ~96 % of the
player's Z before peak, so the visible "rise on screen" was
maybe ~0.5 m of the 3.11 m arc. User reported the jump still
looked short.

K-fix12: replace the lag with an explicit airborne-pin. The
camera's tracked Z follows player Z directly while grounded,
but stays PINNED while airborne and rising. Falling / dropping
catches up immediately so we don't end up below ground when
landing in a hole.

Effect: during a jump the player visibly rises 3 m above the
camera on screen, matching retail's "you can see yourself jump"
feel. After landing the camera's tracked Z snaps back to the
player Z so there's no lingering vertical offset.

ChaseCamera.Update gains an isOnGround parameter; GameWindow
passes result.IsOnGround from the per-frame movement controller.
The look-at point still uses raw player Z so the camera tilts up
to keep the airborne character framed.

Tests stay 1222 green.
2026-04-26 18:23:02 +02:00
Erik
05ce090346 fix(camera): smooth chase-camera Z follow so the jump arc is visible on screen
Diagnostic from K-fix10 confirmed our local jump physics is
mathematically perfect — every full-charge jump produces
formulaPeak = actualPeakDz = vz²/19.6 to four-digit precision
(3.11 m for Jump skill 208). Yet the user observed retail
clients seeing the SAME character jump much higher than ACdream
sees of itself.

Root cause: ChaseCamera tracked player.Z 1:1. When the player
rises 3 m the camera rises 3 m too — the player's screen
position never changes during the arc, so the jump is visually
invisible. Retail's chase camera lags the Z follow, so an
observer sees the player visibly rise on screen.

Fix: low-pass filter the camera's Z target.
ChaseCamera.Update gains a dt parameter and an exponential
smoother:
    alpha = 1 - exp(-dt / ZFollowTimeConstant)
    smoothedZ += (player.Z - smoothedZ) * alpha
ZFollowTimeConstant defaults to 0.15 s — slow enough that a
~1 s jump arc shows up clearly on screen, fast enough that
slope walking still feels glued. The look-at point still uses
the raw player Z so the camera tilts up to keep the airborne
character in frame.

Drive-by: stripped K-fix10 jump diagnostic logging now that the
math has been confirmed correct.

Tests stay 1222 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:00:58 +02:00
Erik
13cc08e506 feat(net): remote retail jumps now show Falling animation + diag for height-mismatch investigation
User report:
1. ACdream watching retail-client jump shows no animation at all
   (legs don't fold during the arc).
2. Local jump arc in ACdream is shorter than what retail observes
   for the same character — formula mismatch somewhere.

Item 1 (animation): K-fix9 wired the body velocity but didn't
swap the sequencer cycle. The remote kept playing whatever
locomotion cycle was active (Ready/RunForward/etc.) through the
arc, so the legs stayed running while the body went up.
OnLiveVectorUpdated now also calls
  ae.Sequencer.SetCycle(currentStyle, MotionCommand.Falling, 1.0f)
when the velocity has +Z > 0.5 m/s. Mirrors the local-player
UpdatePlayerAnimation path that forces animCommand=Falling
whenever !IsOnGround. Style defaults to NonCombat (0x8000003D)
when the sequencer hasn't established one yet (rare on remotes).

Landing transitions back to the locomotion cycle naturally via
the next UpdateMotion the server sends after HitGround.

Item 2 (height): added per-jump diagnostic so we can compare
the formula-predicted peak (sentVz²/(2g) = sentVz²/19.6) with
the actually-rendered peak Δz. Logs:
  [jump.send] extent=... sentVz=... formulaPeak=...m startZ=...
  [jump.peak] sentVz=... formulaPeak=...m actualPeakDz=...m
              startZ=... peakZ=... landZ=...
Strip after the height-mismatch root cause is found.

Drive-by: previous diagnostic left an if/else hijack in the
resolve branch that broke 3 PlayerMovementControllerTests. Fixed.

Tests stay 1222 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:51:37 +02:00
Erik
b609b5ea6e feat(net): handle 0xF74E VectorUpdate so remote players' jumps render
Remote-player jumps were silently dropped — we never parsed the
VectorUpdate broadcast that carries the jump launch velocity, so
the remote body's Z velocity stayed at 0 and the jump animation
showed without any vertical motion.

ACE Player.cs:954 enqueues GameMessageVectorUpdate (opcode 0xF74E)
on every jump in addition to the bracketing UpdateMotion. Wire
layout (GameMessageVectorUpdate.cs):

  u32 opcode (= 0xF74E)
  u32 objectGuid
  3xf32 velocity (world-space, post-rotation)
  3xf32 omega
  u16 instanceSequence
  u16 vectorSequence

This commit:

1. Adds VectorUpdate.TryParse + VectorUpdated session event.
2. WorldSession.ProcessDatagram dispatches 0xF74E.
3. GameWindow subscribes via OnLiveVectorUpdated:
   - Sets remote PhysicsBody.Velocity from the wire vector.
   - When velocity.Z > 0.5 m/s, marks the remote as Airborne,
     clears Contact + OnWalkable bits, and enables the Gravity
     state flag — so calc_acceleration returns (0, 0, -9.8) and
     UpdatePhysicsInternal produces a parabolic arc.
4. The per-tick remote update (TickAnimations remote-physics
   block) now SKIPS the "force OnWalkable + apply_current_movement"
   step when Airborne. Otherwise that path stomps the +Z velocity
   each frame — same shape as the bug the local jump hit before
   K-fix7.
5. ResolveWithTransition for remotes now passes
   isOnGround: !rm.Airborne. Mirrors K-fix7's local-player gate —
   airborne resolves must NOT pre-seed the ContactPlane,
   otherwise AdjustOffset's snap-to-plane branch zeroes the
   upward offset.
6. UpdatePosition handler clears the airborne flag and restores
   ground-contact bits, so the server's authoritative re-grounding
   ends the arc cleanly at the new ground location.

ACDREAM_DUMP_MOTION=1 logs each VectorUpdate as
"VU guid=0x... vel=(...) airborne=...".

Tests stay 1222 green. Live verification pending — watch a remote
character jump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:38:32 +02:00
Erik
1fce21034a feat(player): use server-authoritative Run + Jump skill values from PlayerDescription
Before this commit, PlayerWeenie used hardcoded ACDREAM_RUN_SKILL
(default 200) and ACDREAM_JUMP_SKILL (default 300) regardless of
the actual character skill. PlayerDescription's skill table HAS
been parsed since Phase H, but the values weren't plumbed into
PlayerMovementController, so a high-Jump character still got the
3-4m default arc instead of their real 5m+ arc, and a low-Jump
character got too much.

GameEventWiring.WireAll gains an optional `onSkillsUpdated`
callback. The PlayerDescription handler scans the parsed skill
table for SkillId 24 (Run) and SkillId 22 (Jump) — ACE Skill enum
ordinals from references/ACE/.../Enum/Skill.cs:11-37 — and fires
the callback with `init + ranks` for each (the holtburger-named
"init" field is the attribute-derived initial component, ranks
is XP-bought additions; closest sane approximation of ACE's
CreatureSkill.Current short of porting Aug + Multiplier + Vitae
chains).

GameWindow stores the most recent values in _lastSeenRunSkill /
_lastSeenJumpSkill and pushes them into the controller at two
points:
  * Immediately if _playerController already exists (PD arriving
    mid-session, e.g. after a relog).
  * Inside EnterPlayerModeNow when constructing a fresh
    controller (the auto-entry path: PD always arrives at login
    before auto-entry fires, so this is the normal path).

Both sites also log "applied server skills run=X jump=Y" so live
testing can confirm the right values reached the formula.

Console output (ACDREAM_DUMP_VITALS=1) gains a "vitals: PD-skills
run=X jump=Y" line on every PlayerDescription with skill data.

Tests stay 1222 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:22:59 +02:00
Erik
5145938d06 fix(physics): jump arc was zero — stop pre-seeding ContactPlane while airborne
Live diagnostic (extent=1.000, vz=9.09 — formula peak 4.21m) showed
the body's Velocity.Z stayed at ~9 m/s but Position.Z never
advanced past 66.000 even after 575 frames airborne. The collision
resolver was snapping the player back to ground every step.

Root cause: PhysicsEngine.ResolveWithTransition unconditionally
pre-seeded the Transition's CollisionInfo from body.ContactPlane
before each resolve (a slope-walking continuity hack). Once
airborne, that pre-seed makes Transition.CollisionInfo's
ContactPlaneValid stay true. Then in AdjustOffset's "Have a contact
plane" path, when collisionAngle > 0 (offset moving AWAY from the
plane = jumping up), the code calls Plane::snap_to_plane on the
offset which ZEROES the Z component for flat ground (Normal.Z=1,
plane.D=0 → snap_to_plane sets vec.z = 0). The horizontal X/Y
parts of the offset survived; vertical Z was destroyed every step.
Position.Z only ever got the gravity drift back down, so the
"jump" was literally a sub-frame upward blip followed by 575
frames of stuck-at-ground while gravity ate vz.

Retail's CTransition::init at retail address 0x509dd0
(named-retail line 271954) explicitly sets
contact_plane_valid = 0 at the start of every transition resolve.
ValidateWalkable then re-establishes it during the sweep when
the foot sphere bottom is within EPSILON of the terrain plane —
so for grounded motion the plane is set fresh per frame, and for
airborne motion no plane interferes.

Fix: only seed the contact plane when isOnGround is true.
Airborne resolves now start with no plane, so AdjustOffset
preserves the upward Z and the integrator's positional update
actually lands. Slope-walking continuity is preserved because
the seed still fires whenever the body is grounded.

Diagnostic logging stripped after the fix.

Tests stay 1222 green. Live verification pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:17:13 +02:00
Erik
32583cdfe4 fix(anim): walk-backward leg-twitch + jump-too-low — port ACE negative-speed link path + faster charge
Two animation/movement issues from live verification:

1. Walk-backward leg twitches forward two times before the cycle
   reverses (X key glitch).

   Root cause: AnimationSequencer.GetLink only implemented the
   forward-direction lookup path. ACE's MotionTable.get_link
   (MotionTable.cs:395-426) takes BOTH the substate and the new
   motion's speeds, and switches lookup branches when EITHER speed
   is negative:
     * Forward path: Links[(style<<16) | substate][motion]
     * Reversed path (any negative speed): Links[(style<<16) |
                                            motion][substate]
   For Ready → WalkBackward we adjust_motion to WalkForward at
   speed -0.65 (negative). Our previous code looked up
   Links[Ready][WalkForward] — the "start walking forward"
   transition. Played in reverse, the cursor stranded at the
   wrong cycle frame and produced the user-visible "left leg
   twitches forward two times" before the cycle stabilized.
   With the reversed key Links[WalkForward][Ready] (the "stop
   walking → ready" anim) played at the cycle's negative speed,
   the link smoothly transitions Ready → start-of-cycle, then
   the cycle reverses cleanly.

   GetLink signature changed from (style, fromMotion, toMotion)
   to (style, substate, substateSpeed, motion, speed). Both
   call sites updated: SetCycle passes CurrentSpeedMod +
   adjustedSpeed; the Action-overlay path passes 1f, 1f
   (action overlays are always forward).

2. Jump too low.

   Two changes after deep investigation in named-retail decomp:

   a) Charge rate sped up from 1.0/s → 2.0/s. Retail's PowerBar
      charge constant is illegible in the named decomp (the
      divisor was clobbered in GetPowerBarLevel's FPU stack
      reordering at 0x0056ade0). 2.0/s (full charge in 0.5s)
      matches retail muscle memory better — a tap gives a
      noticeable hop, half-hold a meaningful jump, full-hold
      the maximum.

   b) Default jumpSkill bumped 200 → 300. Retail formula:
        height = (skill / (skill + 1300)) × 22.2 + 0.05
      At extent=1.0:
        skill=200 → 3.01m max (felt too low)
        skill=300 → 4.21m max (closer to retail mid-tier "I
                               can clear that fence" hop)
      Override via ACDREAM_JUMP_SKILL env var.

      Long-term fix is issue #7 — parsing PlayerDescription's
      skill block to apply the server's authoritative skill
      values. Until then, this default is the right baseline.

      (Velocity formula sqrt(height × 19.6) is unchanged and
      matches retail byte-for-byte; we only changed how much
      extent-feeding skill we default to.)

Tests stay 1222 green. The walk-backward fix has no new test
because GetLink is private; the cycle-transition behavior
will be exercised live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:46:10 +02:00
Erik
0ecd4f34ae fix(anim): Phase K live-test fixes pt5 — backward + strafe animation cycle scales with Run
User report: when running backward (X) or strafing (Z/C) at run
speed, the visual moves faster but the animation cycle continues
playing at walk pace, looking disjointed.

Root cause: GameWindow's player-anim driver fed the sequencer's
SetCycle speed from result.ForwardSpeed, but PlayerMovementController
intentionally pins ForwardSpeed = 1.0 for WalkBackward (ACE expects
this for the auto-upgrade) and SidestepSpeed isn't used by the anim
path at all. So Forward+Run played the RunForward cycle at runRate ×
(correct), but Backward+Run + Strafe+Run used speedMod = 1.0 even
though the body was moving at runRate × velocity.

Fix: split the visual-pacing field from the wire-correctness field.
Added MovementResult.LocalAnimationSpeed — runRate when any
directional input is held with Run, else 1.0. GameWindow's
SetCycle path now uses this instead of ForwardSpeed. The wire
output stays unchanged; only the local animation cycle pace
shifts.

Effect:
  - Forward+Run:  runRate × cycle pace (unchanged behavior).
  - Backward+Run: runRate × cycle pace (was 1×; now matches
                  velocity).
  - Strafe+Run:   runRate × cycle pace (was 1×; now matches
                  velocity).
  - Anything not in Run: 1× (unchanged).

Tests stay 1222 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:59:35 +02:00
Erik
7d2bc8cb15 chore: ignore .worktrees/ for isolated feature work
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:58:59 +02:00
Erik
94e9cbcf76 fix(ui): Phase K live-test fixes pt4 — collision wires default OFF, refresh DebugPanel cheat-sheet
User reported "I can't turn off collision wires" — root cause was
two-fold: the wires defaulted to ON at startup so the user saw
them every launch, and the DebugPanel's keybind cheat-sheet
still listed the pre-K.1c retail-default-conflicting bindings
(F1/F2/F3/F7/F8/F9/F10 etc.) instead of the Ctrl+F* aliases the
retail-faithful keymap moved them to.

Changes:

- _debugCollisionVisible defaults to FALSE. Ctrl+F2 toggles it
  on (toast: "Collision wireframes ON"); the DebugPanel →
  Diagnostics → "Toggle collision wires" button toggles too.

- DebugPanel "Help" cheat-sheet rebuilt to reflect the actual
  retail-default + Phase K bindings: Ctrl+F1 (debug), Ctrl+F2
  (collision wires), Ctrl+Shift+F (free-fly), F11 (Settings),
  W/X = forward/back, A/D = turn, Z/C = strafe, Q = autorun
  toggle, Shift = walk modifier, Y/G/H/B = postures,
  Hold MMB = instant mouse-look, Hold RMB = orbit, Tab =
  focus chat input. The user no longer has to read the source
  to find a working binding.

Tests stay 1222 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:53:16 +02:00
Erik
785dd92378 fix(input): Phase K live-test fixes pt3 — fly→chase round-trip, Shift coexists, run-speed for backward + strafe
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>
2026-04-26 14:48:45 +02:00
Erik
6481169cb9 fix(input): Phase K live-test fixes pt2 — visible cursor in chase, free-fly discoverable
Two issues from the K-fix1 launch (2026-04-26 user report):

1. Mouse pointer invisible after login.
   Root cause: CameraController.EnterChaseMode invokes
   ModeChanged?.Invoke(IsChaseMode) — passing TRUE when chase
   becomes active. The OnCameraModeChanged handler interpreted
   that bool as `isFlyMode`, so chase entry wrongly triggered
   the Raw cursor branch (raw = invisible pointer). The bool is
   unreliable: ToggleFly passes IsFlyMode, ExitChaseMode passes
   IsFlyMode, but EnterChaseMode passes IsChaseMode. Read the
   controller state directly inside the handler instead — fly
   mode IS the only state that needs Raw, everything else stays
   Normal so the user can click panels / future selectables.

2. No way to enter free-fly mode.
   The DebugPanel already had a "Toggle Free-Fly Mode" button
   wired in K.2, but the user didn't know to look there. Added
   two more discovery paths:

     - Keyboard shortcut: Ctrl+Shift+F → AcdreamToggleFlyMode
       in RetailDefaults() (retail leaves Ctrl+Shift+F unbound;
       Ctrl+F is unused too, so this is conflict-free).

     - View → Camera submenu in the ImGui MainMenuBar with a
       "Enter / Exit Free-Fly Mode" entry whose label flips with
       the active state. Shortcut hint shows "Ctrl+Shift+F".

   The keyboard handler now also cancels _playerModeAutoEntry on
   manual fly toggle (matches the DebugPanel button + new menu
   entry — user's choice wins, the chase camera doesn't snap on
   top of the fly camera mid-inspection).

   Also corrected the View → Debug menu shortcut hint (was "F1",
   actual binding is Ctrl+F1 since K.1c).

Tests still 1220 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:30:28 +02:00
Erik
bc9ee9fdfa fix(input): Phase K live-test fixes — default-run, Q-autorun toggle, free cursor, no Holtburg flash
Four issues from K.3 live verification (2026-04-26 user report):

1. Default movement speed should be RUN, not walk.
   PlayerMovementController.MovementInput.Run was sourced from
   IsActionHeld(MovementRunLock) (Q held). Inverted to
   !IsActionHeld(MovementWalkMode) (Shift held = walk; default = run).

   Also fixed RetailDefaults() — MovementWalkMode was bound to
   (ShiftLeft, ModifierMask.None), but when LShift IS the primary
   key the OS keyboard reports CurrentModifiers=Shift and the
   chord lookup mismatches. Bind both LShift+Shift and RShift+Shift
   to match (the same fix AcdreamCurrentDefaults already had).

2. Q is autorun TOGGLE, not hold-to-run. Added _autoRunActive
   field; OnInputAction toggles it on MovementRunLock Press;
   MovementInput.Forward now ORs in _autoRunActive so autorun
   stays latched until canceled. Pressing Backup / Stop /
   StrafeLeft / StrafeRight clears the latch (deliberate movement
   wins, retail-faithful). Pressing Forward AGAIN does NOT cancel —
   matches retail's stack semantics.

3. Mouse cursor visible by default in chase mode + no Y-axis
   steering without an explicit hold input. OnCameraModeChanged
   now uses CursorMode.Normal for chase (was Raw — invisible
   pointer). MouseMove handler's "neither RMB nor MMB held"
   branch dropped its AdjustPitch call — pitch is gated to
   deliberate hold inputs only. Fly mode keeps Raw (continuous
   look-and-fly affordance).

   Restored AcdreamRmbOrbitHold binding in RetailDefaults() —
   K.1c silently dropped it when SelectRight took the RMB Press
   slot; the Hold-type binding coexists with Press so RMB orbit
   still works in addition to (future) SelectRight click.

4. Holtburg flashes briefly at live login. Added
   IsLiveModeWaitingForLogin gate (true iff ACDREAM_LIVE=1 AND
   chase camera has not yet been entered) that:
     * suppresses StreamingController.Tick in OnUpdate so no
       landblocks load around the hardcoded startup center
       0xA9B4 (Holtburg);
     * skips terrain + entity rendering in OnRender via a
       SkipWorldGeometry label after the sky pass.
   Sky still draws so the user sees a live, time-of-day-correct
   sky during the connection / character-list / EnterWorld
   handshake. Latches off once chase mode has been entered, so
   later fly-mode toggles render the world normally.

Tests still 1220 green.

Also commits .gitignore tmp/ rule (left over from K.3
session) — gitignored per-session scratch (commit message
drafts, ad-hoc temp files).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:11:01 +02:00
Erik
f42c164b90 feat(ui): #25 Phase K.3 — Settings panel + click-to-rebind + Phase K shipped
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>
2026-04-26 09:44:56 +02:00
Erik
af74eac0c2 feat(input): #24 Phase K.2 - auto-enter player mode at login + MMB mouse-look + DebugPanel free-fly + Tab to chat-input focus
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>
2026-04-26 09:20:17 +02:00
Erik
da189103b8 feat(input): #23 Phase K.1c - retail-faithful keymap cutover + JSON persistence (muscle memory change)
Single bisectable commit where the user-visible keyboard layout
flips from acdream-current (W/S/A/D/Z/X) to canonical AC retail
(W/X/A/D/Z/C). The InputDispatcher abstraction landed in K.1a,
existing handlers cut over in K.1b, and now KeyBindings.RetailDefaults()
returns the byte-precise retail preset matching
docs/research/named-retail/retail-default.keymap.txt.

Movement (matches AC1 muscle memory):
- W/Up = MovementForward (run by default)
- X/Down = MovementBackup
- A/Left = MovementTurnLeft
- D/Right = MovementTurnRight
- Z = MovementStrafeLeft
- C = MovementStrafeRight
- Alt+A / Alt+Left = MovementStrafeLeft (Alt-flips-turn)
- Alt+D / Alt+Right = MovementStrafeRight
- LShift (Hold) = MovementWalkMode (default = run; held = walk)
- Q = MovementRunLock (autorun toggle)
- S = MovementStop (sets Ready stance / idle)
- Space = MovementJump (hold to charge)
- Y = Ready, G = Sitting, H = Crouch, B = Sleeping (postures)

Selection / targeting (18 bindings on punctuation cluster):
- F = SelectionPickUp, T = SelectionSplitStack, P = PreviousSelection
- Backspace/Minus/Equals = closest/prev/next CompassItem
- Backslash/[/] = closest/prev/next Item
- Apostrophe/L/Semicolon = closest/prev/next Monster
- Home = LastAttacker
- Slash/Comma/Period = closest/prev/next Player
- N/M = prev/next Fellow
- E = SelectionExamine
- R = UseSelected

UI:
- F1 = ToggleHelp; Shift+Ctrl+F1 = TogglePluginManager
- F3 = Allegiance, F4 = Fellowship, F5 = Spellbook, F6 = SpellComponents
- F8 = Attributes, F9 = Skills, F10 = World, F11 = Options (lights up
  the Settings panel in K.3), F12 = Inventory
- Alt+1/2/3/4 = ToggleFloatingChatWindow1/2/3/4
- Esc = EscapeKey, Shift+Esc = LOGOUT
- Numpad * = CaptureScreenshot

Hotbar / spellbook:
- 1-9 = UseQuickSlot_1..9 (hotbar) AND UseSpellSlot_1..9 (in MagicCombat
  scope - dormant until Phase L)
- Ctrl+1-9 = UseQuickSlot_1..9 (duplicate)
- Alt+5-9 = UseQuickSlot_14..18 (second bar)
- 0 / Ctrl+0 = CreateShortcut

Chat:
- Tab = ToggleChatEntry (focus chat input; subscriber stub-TODO in K.2)
- Return = EnterChatMode (send)

Combat (mode-dependent, dormant - Phase L lights up):
- Grave (`) = CombatToggleCombat
- Insert/PgUp/Delete/End/PgDn = melee power+attack-level OR missile
  accuracy+aim-level OR magic spell-tab nav + cast (resolved by
  scope at runtime once CombatState.CurrentMode lands).
- Ctrl+Insert/PgUp/Delete/PgDn = first/last spell tab + first/last spell

Emotes: U = Cry, I = Laugh, J = Wave, O = Cheer, K = PointState

Camera (numpad cluster + F2):
- F2 / Numpad/ = CameraActivateAlternateMode
- Numpad 4/6/8/2 = rotate left/right/up/down
- Numpad - / + = move toward / away
- Numpad 0 = ViewDefault, Numpad . = FirstPerson
- Numpad 5 = LookDown, Numpad Enter = MapMode

Scroll:
- Mouse wheel handled by dispatcher OnScroll path
- Ctrl+Up / Ctrl+Down = ScrollUp / ScrollDown

Acdream debug actions relocated from F-keys to Ctrl+F-keys to avoid
retail conflicts:
- Ctrl+F1 = AcdreamToggleDebugPanel
- Ctrl+F2 = AcdreamToggleCollisionWires
- Ctrl+F3 = AcdreamDumpNearby
- Ctrl+F7 = AcdreamCycleTimeOfDay
- Ctrl+F8 / Ctrl+F9 = AcdreamSensitivityDown / Up
- Ctrl+F10 = AcdreamCycleWeather
AcdreamToggleFlyMode + AcdreamTogglePlayerMode have NO keyboard
binding in retail-default. K.2 adds a DebugPanel button for fly
toggle and auto-enter player mode at login.

Total: 149 bindings.

JSON load/save:
- KeyBindings.LoadOrDefault(path): merge-over-defaults migration.
  Missing actions get default bindings; unknown actions in user
  file are skipped (preserves user customizations across action
  enum additions). Corrupt file warns + returns RetailDefaults
  without overwriting (don't blow away user's file silently).
- KeyBindings.SaveToFile(path): writes with schema version=1, alpha-
  sorted action names, alpha-sorted modifier keys for stable diffs.
- KeyBindings.DefaultPath() = %LOCALAPPDATA%/acdream/keybinds.json.

GameWindow startup:
- Replaces KeyBindings.AcdreamCurrentDefaults() call with
  KeyBindings.LoadOrDefault(KeyBindings.DefaultPath()) via a small
  LoadStartupKeyBindings() helper.
- Logs "keybinds: loaded N bindings from <path>" so launch.log
  shows the source of truth at session start.

Three deviations from plan:
1. LoadStartupKeyBindings() helper instead of inline initializer
   (field initializer can't call methods directly).
2. ToggleChatEntry subscriber is a no-op stub with TODO K.2 comment
   (ChatPanel doesn't expose FocusInput() yet; will add in K.2).
3. AcdreamRmbOrbitHold removed from RetailDefaults() to avoid
   double-binding RMB (SelectRight + RmbOrbitHold on the same chord
   would fire both subscribers). Chase-camera orbit will be replaced
   by MMB-hold mouse-look in K.2 - retail's CameraInstantMouseLook.

28 new tests:
- KeyBindingsRetailTests: 19 cases pinning every retail mapping
  (W/X movement, Z/C strafe, Tab=ToggleChatEntry, Shift+Esc=LOGOUT,
  Shift+Ctrl+F1=TogglePluginManager, MovementWalkMode=Hold,
  Acdream debug on Ctrl+F*, hotbar number-row variants, etc).
- KeyBindingsJsonTests: 9 cases (round-trip; missing file →
  defaults; corrupt → defaults + no-overwrite; merge-over-defaults;
  legacy version=0 parsing; Hold-activation preservation; unknown-
  action skip; DefaultPath shape).

Solution total: 1162 green (243 Core.Net + 254 UI + 665 Core),
0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:14:25 +02:00
Erik
256e9624bd feat(input): #22 Phase K.1b - cut handlers over to dispatcher (single input path)
Removes the parallel direct keyboard/mouse polling that K.1a left in
GameWindow alongside the new dispatcher. Now every input flows
through InputDispatcher; legacy IsKeyPressed/KeyDown/MouseDown/MouseUp/
Scroll handlers in GameWindow are deleted (~220-line refactor).

Bindings remain acdream-current (W/S/A/D/Z/X movement, Shift run,
F-key debug surface). K.1c flips them to retail.

Pieces:
- InputDispatcher.IsActionHeld(InputAction): per-frame held-state
  query for movement (W/X/A/D/Z/X/Shift/Space) so PlayerMovement-
  Controller can read action state without polling raw keys.
  Internally walks all bindings for the action; chord match
  requires modifier mask exactness.
- InputAction adds AcdreamRmbOrbitHold (Hold-activation, RMB held
  drives chase-camera orbit) and AcdreamFlyDown (Ctrl held in fly
  mode for descent).
- GameWindow OnInputAction subscriber replaces the entire KeyDown
  switch + per-mouse-button handlers. Single dispatcher event drives:
    - F1  AcdreamToggleDebugPanel
    - F2  AcdreamToggleCollisionWires
    - F3  AcdreamDumpNearby
    - F7  AcdreamCycleTimeOfDay
    - F8  AcdreamSensitivityDown
    - F9  AcdreamSensitivityUp
    - F10 AcdreamCycleWeather
    - F   AcdreamToggleFlyMode
    - Tab AcdreamTogglePlayerMode (player/fly toggle - K.1c will
          reassign this to ToggleChatEntry)
    - Esc EscapeKey (cancel fly mode etc.)
    - Mouse wheel ScrollUp/ScrollDown (camera zoom)
    - RMB held (Hold) drives orbit; LMB drag still drives orbit
      camera; mouse position handled by surviving MouseMove handler
      which is gated on ImGui WantCaptureMouse.
- MovementInput per-frame: reads from _inputDispatcher.IsActionHeld.
  MouseDeltaX hardcoded to 0f (mouse never drives character yaw).
  _playerMouseDeltaX field stays defined for chase-camera RMB-orbit
  but is never consumed by movement.
- WantCaptureMouse explicit gate at the top of every surviving mouse
  handler in GameWindow (defense in depth - dispatcher already gates
  via IMouseSource.WantCaptureMouse).

Movement-input boundary preserved: PlayerMovementController.Update
still takes the same MovementInput struct. Existing
PlayerMovementControllerTests continue green - no regression in
motion-command byte production.

Two deviations:
1. Scroll lost magnitude going through the dispatcher (fixed-step
   zoom). Acceptable - discrete wheel-tick matches retail feel
   anyway.
2. Movement chords are duplicated with both ModifierMask.None and
   ModifierMask.Shift (covering "shift held to run while walking
   forward" etc.) so the dispatcher's modifier-strict matching
   preserves the modifier-blind feel of the old IsKeyPressed
   polling. Will be reshaped cleanly in K.1c when retail's
   walk-modifier semantics flip (default = run, shift held = walk).

15 new tests:
- InputDispatcherIsActionHeldTests: 7 cases covering chord-held +
  release + modifier-mismatch + multi-binding-for-action.
- InputDispatcherTests: 3 scroll-action cases.
- DispatcherToMovementIntegrationTests (Core.Tests): 5 cases
  proving FakeKeyboardSource.Press(W) -> dispatcher.IsActionHeld ->
  MovementInput.Forward -> PlayerMovementController produces the
  expected motion-command bytes. Includes the regression-prevention
  test that mouse-X delta value (zero vs nonzero) doesn't affect
  the motion bytes.

Solution total: 1133 green (243 Core.Net + 225 UI + 665 Core),
0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:43:11 +02:00
Erik
84512d3c64 feat(input): #21 Phase K.1a - input architecture skeleton (parallel to existing handlers)
Introduces the abstraction without changing user-visible behavior.
Existing keyboard/mouse handlers in GameWindow continue working
unchanged. The new InputDispatcher runs alongside, fires
InputAction events, and a diagnostic Console.WriteLine subscriber
proves the path is observable. K.1b cuts the existing handlers
over; K.1c flips bindings to retail.

New types in src/AcDream.UI.Abstractions/Input/:
- InputAction enum (~110 actions, doc-grouped by retail keymap
  category: MovementCommands, ItemSelectionCommands, UICommands,
  QuickslotCommands, Chat, Combat, Emotes, Camera, Scroll, Mouse
  selection, plus Acdream-specific debug actions for the existing
  F-key behaviors)
- KeyChord record struct (Silk.NET.Input.Key + ModifierMask + Device)
- ModifierMask [Flags] enum matching retail keymap bit values
  (Shift=0x01, Ctrl=0x02, Alt=0x04, Win=0x08)
- ActivationType enum (Press, Release, Hold, DoubleClick, Analog)
- Binding record (chord -> action -> activation)
- InputScope enum with stack semantics (Always at bottom, Game on
  top during normal play; Chat / EditField / Dialog / MeleeCombat /
  MissileCombat / MagicCombat / Camera push as transient overlays)
- KeyBindings collection class with Find / ForAction / Add / Remove.
  AcdreamCurrentDefaults() factory matches today's hardcoded binds
  (W/S/A/D/Z/X movement, Shift run, F-key debug surface) so K.1a
  doesn't change behavior. RetailDefaults() is K.1c's job; for now
  it returns the same map.
- IKeyboardSource / IMouseSource - test-fakeable interfaces wrapping
  Silk.NET. Both surface WantCaptureMouse / WantCaptureKeyboard
  flags so the dispatcher can gate per ImGui state.
- InputDispatcher: multicast event Fired<InputAction, ActivationType>;
  scope stack with PushScope/PopScope/ActiveScope; per-frame Tick()
  fires Hold-type bindings for currently-held chords; mouse buttons
  encoded as KeyChord with Device=1.

New adapters in src/AcDream.App/Input/:
- SilkKeyboardSource - Silk.NET IKeyboard wrapper, tracks held state
- SilkMouseSource - Silk.NET IMouse wrapper, proxies ImGui WantCapture
  flags for both keyboard and mouse

GameWindow.cs:
- Constructs adapters + dispatcher in OnLoad
- Subscribes to dispatcher.Fired with diagnostic Console.WriteLine
  ("[input] {action} {activation}") so the path is observable in
  launch.log without touching any actual game state
- Calls _inputDispatcher.Tick() per frame in OnUpdate
- Existing IsKeyPressed and event handlers unchanged

Memory crib at memory/project_input_pipeline.md describes the five
layers (Silk events -> Source interfaces -> Dispatcher -> Action
events -> Subscribers) with file paths + scope semantics + the K.1c
retail-defaults plan. Indexed in MEMORY.md.

Two deviations from plan, both documented:
1. InputDispatcher placed in UI.Abstractions/Input/ rather than
   App/Input/ - it has no Silk dependencies (uses only the test-
   fakeable interfaces) and the test fakes live in
   UI.Abstractions.Tests. Mirrors LiveCommandBus precedent. Silk
   adapters + GameWindow wiring stay in App.
2. WantCaptureKeyboard moved to IMouseSource alongside WantCaptureMouse
   (the dispatcher needs both at the same point).

34 new tests covering KeyChord equality, ModifierMask flags,
KeyBindings lookup, dispatcher chord matching with modifier
mismatch rejection, Hold-type Press/Release transitions, Tick()
firing held bindings, scope stack push/pop with mismatched-pop
throwing, WantCapture* gating.

Solution total: 1118 green (243 Core.Net + 215 UI + 660 Core),
0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:17:41 +02:00
Erik
4717a5b6f7 docs(research): canonical retail keymap + dump-keymap tool
Pre-Phase K research artifact. Captures the AC retail default keymap
in two complementary forms so the upcoming InputAction enum + retail
preset (Phase K.1c) can be built byte-precise.

- docs/research/named-retail/retail-default.keymap.txt — verbatim
  copy of the user's test.keymap from
  ~/Documents/Asheron's Call/. Human-readable text format with
  every binding categorized: MovementCommands (W/X/A/D/Z/C/Q/Space/
  LShift/S + Y/G/H/B postures), ItemSelectionCommands (F/T/P + 18
  punctuation keys for compass/item/monster/player/fellow targeting),
  UICommands (F1-F12 panel toggles, R=USE, E=Examine, Esc=close,
  Shift+Esc=Logout), QuickslotCommands (1-9 + Ctrl/Alt variants for
  hotbar pages), Combat / MeleeCombat / MissileCombat / MagicCombat
  (mode-dependent Insert/PgUp/Delete/End/PgDn), Emotes
  (U=Cry, I=Laugh, J=Wave, O=Cheer, K=Point), CameraControls (numpad
  cluster), MouseCommands, ScrollableControls, EditControls,
  CopyAndPasteControls, DialogBoxes. 346 lines.

- docs/research/named-retail/keymap-default.txt — binary dump of
  the gmDefaultMap MasterInputMap from client_portal.dat at file id
  0x14000000. Decoded via the new tools/dump-keymap utility:
  scancodes + modifier flags + action IDs + activation phase per
  context. Confirms the text file's bindings against the dat-shipped
  default. Cross-referenced against
  acclient_2013_pseudo_c.txt:405510 (ACCmdInterp::OnAction) for the
  movement dispatch logic and :365889 (CPlayerSystem::OnAction) for
  the targeting dispatch.

- tools/dump-keymap/ — dotnet console tool referencing
  references/DatReaderWriter. Reads MasterInputMap entries from a
  dat directory + emits human-readable per-context binding tables.
  Reusable for future custom keymap analysis. Run with:
    dotnet run --project tools/dump-keymap/dump-keymap.csproj -c Release
  Default dat dir is %USERPROFILE%/Documents/Asheron's Call.

Foundation for Phase K — control system overhaul. Plan documented at
~/.claude/plans/ticklish-conjuring-cake.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:01:58 +02:00
Erik
579cbfb48b fix(chat): block / unknown commands from broadcasting as speech
User reported typing /ls (a command-style request, not chat) gets
echoed by the server as "You say, \"/ls\"". Slash-prefix is a
COMMAND surface, never a chat surface. Filed after the same flow
that produced @help and the welcome-message work.

Behavior change at the ChatPanel submit layer:
- Any /-prefixed input whose verb isn't in our alias tables now
  renders a local "[System] Unknown command: /foo. Type /help for
  the list." line and is NEVER published to the bus. No SendChatCmd,
  no Talk packet. The server never sees /foo.
- Known /-verbs (/say /tell /reply /retell /general /allegiance
  /patron /vassals /monarch /covassals /fellowship /lookingforgroup
  /trade /roleplay /society /olthoi /help /clear /framerate /loc
  and friends) still flow through ChatInputParser.Parse → SendChatCmd
  exactly as before.
- @-prefix unchanged: ACE's CommandManager handles unknown @ verbs
  server-side and replies via SystemChat ("Unknown command: foo")
  per ACE GameActionTalk.cs:21. Our @ -> / normalization for known
  verbs (Phase J Tier 1) and the @-passthrough fallthrough for
  unknown verbs both still apply.

ChatInputParser now exposes:
- IsKnownVerb(string verb): query against the union of every alias
  table. Used by ChatPanel to discriminate "unknown verb" from
  "known verb with bad args".
- GetVerbToken(string command): public alias of the existing
  ExtractVerb so callers can pull the first whitespace token without
  reproducing the helper.

Parse itself is unchanged — its existing fall-through (Say with
literal text) still applies for unknown /-verbs called directly via
the parser, but ChatPanel intercepts before reaching that path so
the fall-through never fires through the live submit pipeline. Tests
that directly call Parse continue to pass; the new ChatPanel-level
tests pin the unknown-command rejection.

19 new tests:
- ChatInputParserTests: 10 IsKnownVerb Theory cases + 4 GetVerbToken
  Theory cases.
- ChatPanelInputTests: 5 Theory cases for Submit_UnknownSlashCommand
  covering /foo, /ls, /mp <path>, /genio, and bare /.

Solution total: 1086 green (243 Core.Net + 183 UI + 660 Core),
0 warnings.

Acceptance: type /ls, /mp /path, /anything-not-known — see local
"[System] Unknown command: /xxx. Type /help for the list of
supported commands." Nothing reaches the wire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:49:56 +02:00
Erik
a44488e277 fix(ui): chat input pinned to window bottom on resize via scrollable child
User reported the chat input field disappearing when the chat
window was resized smaller — older entries pushed it past the
visible area. Standard ImGui chat-window pattern fixes it: scrollable
nested region for the chat tail, fixed footer for the
separator + input field below it.

IPanelRenderer extensions (Phase J Tier 3):
- BeginChild(string id, Vector2 size, bool border = false) — opens
  a nested scrollable region. Size follows ImGui semantics:
  0 = fill available, negative = fill available minus this much.
- EndChild() — closes the nested region.
- FrameHeightWithSpacing() — single-line widget height incl. frame
  padding + item spacing. Lets panels compute footer reservations
  without hardcoding pixel constants.
- SetScrollHereY(float ratio) — forces scroll within current region;
  pass 1.0f to keep the latest line visible after new entries
  arrive.

ImGuiPanelRenderer impls. ImGui.NET's BeginChild signature changed
across versions (third arg moved from `bool border` to
`ImGuiChildFlags`); we cast a numeric literal (0x01 = Border bit)
to sidestep the rename. FrameHeightWithSpacing maps to
ImGui.GetFrameHeightWithSpacing(); SetScrollHereY to ImGui.SetScrollHereY.

ChatPanel restructured:
- Reserves footer height = FrameHeightWithSpacing() + 6f (small pad
  for the separator above the input).
- Wraps the chat tail in BeginChild("##chattail", (0, -footer))
  so the inner region scrolls independently of the window.
- Tracks _lastRenderedCount across frames and calls SetScrollHereY(1f)
  only when new entries appended — manual scroll-up isn't fought
  against; new messages jump the view back down only when they
  actually arrive.
- Header Separator removed (the BeginChild border is enough).

FakePanelRenderer extended with the four new methods + recording.
4 new tests in ChatPanelLayoutTests pin the layout invariants:
- Render order: Begin → BeginChild → ... → EndChild → Separator
  → InputTextSubmit → End.
- BeginChild size has X=0 + negative Y at least matching the
  injected FrameHeightWithSpacingValue.
- SetScrollHereY fires when entries grow.
- SetScrollHereY does NOT fire when entries don't grow.

Solution total: 1067 green (243 Core.Net + 164 UI + 660 Core),
0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:44:10 +02:00
Erik
a316d6359c feat(chat): Phase J Tier 1+2 - @ verb prefix, /retell, /framerate, /loc
Three-tier rollout per the 2026-04-25 retail @help dump showing the
full ACE command surface. Tier 1 + most of Tier 2 in one commit.

TIER 1 - @ as / equivalent

ACE accepts both / and @ as verb prefixes (per its own help text:
"Note: You may substitute a forward slash (/) for the at symbol
(@)."). ChatInputParser now normalises @ to / for the verb-match
phase and re-enters parsing. Critical: for verbs we don't recognise
(@acehelp, @tele, @die, @version, @loc-on-server, @nonsense, ...),
the original @ is kept in the message text so ACE's CommandManager
intercepts the message server-side. If we substituted / there too,
ACE would treat it as plain Talk and broadcast it.

Result: @a hi / @tell Bob hi / @help / @clear / @reply / @retell
all route exactly like their / counterparts. @acehelp / @tele /
@version / @die etc. pass through to the server intact.

TIER 2 - client-only commands

- /retell <msg> (also @retell): resend to the last person you
  tell'd. Mirrors retail @retell. ChatVM tracks
  LastOutgoingTellTarget on each OnSelfSent(Tell, ...) entry —
  SenderGuid==0 distinguishes outgoing echo from inbound whispers,
  same way LastIncomingTellSender already worked. ChatInputParser
  takes a new optional lastOutgoingTellTarget param.

- /framerate (also @framerate): prints "Framerate: 144.2 FPS"
  into chat. Wired via a new ChatVM.FpsProvider Func<float>
  callback set by GameWindow at construction (closes over
  _lastFps). Falls back to "(provider unavailable)" if no
  callback is wired (tests / pre-live).

- /loc (also @loc): prints "Location: (123.4, 567.8, 60.0)" into
  chat. Wired via ChatVM.PositionProvider Func<Vector3> closing
  over GetDebugPlayerPosition() in GameWindow. ACE has a server-
  side @loc too; client wins here (instantaneous + uses the local
  interpolated position).

ChatPanel.TryHandleClientCommand grew @ aliases for /help /clear
/framerate /loc and the new EqAny helper for case-insensitive
multi-string matching. Help text rewritten to reference the
/ <-> @ equivalence and point at @acehelp / @acecommands for ACE's
full command list.

TIER 3 - automatic (no code)

Most retail @-commands (@allegiance motd, @afk, @die, @lifestone,
@corpse, @marketplace, @pkarena, @emote/@emotes, @fillcomps,
@permit, @consent, @squelch, @unsquelch, @messagetypes, @age,
@birth, @day, @endurance, @pklite, @version, @filter, @unfilter,
@loadfile, @log, @marketplace, ...) are server-side ACE commands.
Tier 1's passthrough takes care of them automatically — they
arrive via Talk, ACE recognises the @ and intercepts, replies via
SystemChat (which our 0xF7E0 wiring renders as [System] lines).

DEFERRED

- @saveui / @loadui / @lockui: ImGui layout save/load, ~1 hr
  standalone task. Filed for follow-up.
- @title <text>: rename chat window. ImGui window-id complications.
- Toggle-style @framerate (FPS overlay on/off): print-once is
  simpler and matches retail's most-common usage.

30 new tests:
- ChatInputParserAtPrefixTests: 11 covering @-prefix recognition,
  unknown-@ passthrough, /retell and @retell.
- ChatVMRetellAndProvidersTests: 8 covering LastOutgoingTellTarget
  tracking, FpsProvider/PositionProvider callbacks, no-provider
  fallback.
- ChatPanelInputTests: +3 (/framerate, @loc, @acehelp passthrough).

Solution total: 1063 green (243 Core.Net + 160 UI + 660 Core),
0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:34:13 +02:00
Erik
3501194083 fix(chat): /help client-side handler + System dedup + ThatIsNotAValidCommand template
Phase J follow-up after a 2026-04-25 trace where typing /help
produced two identical "Unknown command: help" lines (ACE fires the
text via both GameMessageSystemChat 0xF7E0 and a paired
CommunicationTransientString 0x02EB), and the server's WeenieError
0x0026 trailer rendered cryptically as "WeenieError 0x0026".

Three small changes:

1. WeenieErrorMessages: add 0x0026 ThatIsNotAValidCommand ->
   "That is not a valid command." Plus 0x0414 / 0x050F that Phase J
   already added are now covered by tests too.

2. ChatLog.OnSystemMessage dedup. Track last system text + arrival
   time; if a second identical text shows up within 1 second,
   suppress. ACE's two-path send (gag warnings, command errors,
   etc.) collapses to a single chat line. Long bursts of repeated
   text still skip the duplicates without resetting the timer.

3. Client-side /help and /clear in ChatPanel. Intercepted BEFORE
   the parser passes to the server bus:
   - /help, /?, /h (case-insensitive) -> render local cheat-sheet
     listing acdream's slash prefixes via ChatLog.OnSystemMessage.
     Avoids the round-trip to ACE that produced the duplicate
     "Unknown command: help" lines AND gives users discoverability.
   - /clear, /cls -> drains the chat log so the panel starts empty.

   New ChatVM.ShowSystemMessage() + ChatVM.Clear() expose the
   minimum surface the panel needs to dispatch client-only feedback
   without coupling the panel to ChatLog directly.

12 new tests:
- 3 WeenieErrorMessages template adds (0x0026 / 0x0414 / 0x050F).
- 4 ChatLog dedup cases (immediate dup, different text, triplet,
  bookended-by-different-text).
- 5 ChatPanel client-command cases (/help, 3 alias variants,
  /clear).

Solution total: 1033 green (243 Core.Net + 130 UI + 660 Core),
0 warnings.

Acceptance: type /help in chat -> local help banner appears, no
server round-trip, no "Unknown command: help" duplicates. Type
/clear -> chat tail empty. Welcome banner + WeenieError-templated
"You are not in an allegiance!" / "You do not belong to a
Fellowship." continue rendering once each.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:22:07 +02:00
Erik
7726f62528 feat(chat): Phase J - welcome message + own-echo dedup + long-form slash aliases + WeenieError templates
Six fixes from the 2026-04-25 live verify session.

1. ServerMessage (0xF7E0) wired to ChatLog. ACE's
   GameMessageSystemChat - used for the login banner "Welcome to
   Asheron's Call ... powered by ACEmulator ... type @acehelp" plus
   any future server broadcast - rides opcode 0xF7E0. The parser
   shipped in I.5 but the WorldSession.ServerMessageReceived event
   was never subscribed by GameWindow, so the welcome line was
   silently dropped. Subscribed now; same wave wires the missing
   EmoteHeard / SoulEmoteHeard / PlayerKilledReceived events that
   I.5 also left orphan.

2. Drop optimistic /say echo + plumb local-player-guid into ChatLog.
   ACE's HandleActionTalk broadcasts a HearSpeech back to the sender
   too, so we were double-printing every /say (own optimistic +
   server echo). New ChatLog.SetLocalPlayerGuid() pushes the chosen
   character guid in (mirrors VitalsVM pattern); OnLocalSpeech
   detects own-guid match and substitutes Sender="" so the formatter
   's IsOwnSpeaker path renders "You say, ..." instead of
   "+Acdream says, ...". Single line per /say.

3. IsOwnSpeaker check now applies to ChatKind.Channel too. Empty/
   "You" sender -> "[Allegiance] You say, \"text\"" instead of the
   "[Allegiance]  says, \"text\"" double-space hole that Phase I.6's
   OnSelfSent left when echoing legacy ChatChannel sends.

4. Long-form slash aliases: /general /allegiance /patron /vassals
   /monarch /covassals /fellowship /fellow /lookingforgroup
   /roleplay /rp /tr /gen, plus /s as alias for /say. Retail muscle
   memory expected these; the prior parser only recognized /g /a /p
   /v /m /cv /lfg /role and friends, so "/patron hello" fell
   through as /say with the literal "/patron" prefix.

5. WeenieError templates filled in for the codes the user hit:
   - 0x0414 YouAreNotInAllegiance  -> "You are not in an allegiance!"
   - 0x050F YouDoNotBelongToAFellowship -> "You do not belong to a Fellowship."
   Replaces the cryptic "WeenieError 0x0414" / "0x050F" lines.

6. @ command pass-through: ACE handles @help / @acehelp / @tele etc.
   server-side by intercepting Talk text with @ prefix; the user's
   message isn't broadcast and ACE replies via SystemChat. Drop the
   optimistic /say echo so the chat shows only the server's response
   (the SystemChat wiring from #1 surfaces it as [System] {help}).

Tests:
- 11 long-form-alias Theory cases on ChatInputParser.
- 3 own-guid-substitution cases on ChatLog (own match, different
  guid, pre-login fallback).
- Existing PrefixSubstring test refactored to "/genio" since the
  previous "/general" stub is now a real verb.

Solution total: 1021 green (243 Core.Net + 125 UI + 653 Core),
0 warnings, 0 errors. +14 tests.

Acceptance: at login, [System] Welcome to Asheron's Call appears.
Single "You say, \"hi\"" per /say. /allegiance with no allegiance
shows [Allegiance] You say, ... + [System] You are not in an
allegiance!. /patron / /vassals / /monarch route correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:07:56 +02:00
Erik
3f7821c18d fix(chat): BuildTell wire field order + retail-style FormatEntry + suppress duplicate Channel echo
Three follow-up fixes from the 2026-04-25 live verify session.

1. CRITICAL: BuildTell wire field order. Our outbound layout was
   [target_name, message] but ACE's GameActionTell.Handle reads
   [message, target_name] (verified against
   references/ACE/.../GameActionTell.cs:17-18 verbatim). Result: every
   /tell since Phase I.3 has been failing with WeenieError 0x052B
   (CharacterNotAvailable) because ACE was looking up the message
   text as the recipient name. Swapped the field order in
   ChatRequests.BuildTell so message is written first; updated the
   pinned BuildTell test to expect the corrected layout. The
   WorldSessionChatTests round-trip continues to pass since SendTell
   delegates to BuildTell.

2. Retail-style FormatEntry. The user asked for the canonical retail
   strings:
     /say (own):       You say, "text"
     /say (incoming):  Name says, "text"
     /tell (own echo): You tell Caith, "text"
     /tell (incoming): Caith tells you, "text"
     channel:          [Trade] +Acdream says, "text"
     /shout (own):     You shout, "text"
     /shout (incoming):Name shouts, "text"

   Discriminators: SenderGuid == 0 distinguishes our own outbound
   echoes (set by OnSelfSent) from real incoming whispers (carry the
   sender's player guid). Sender == "" or "You" distinguishes our own
   /say echoes (OnLocalSpeech substitutes "You" when the wire sender
   is empty per holtburger client/messages.rs:476-487).

   ChatEntry gains a new ChannelName slot so Channel-kind entries
   render with the friendly room name ("Trade") instead of "ch 3".
   Falls back to "ch {ChannelId}" when ChannelName isn't populated
   (legacy ChatChannel inbound or older callers).

3. Suppress optimistic Channel echo. The user saw duplicates per
   /trade /lfg in the live trace:
     [ch 0] Trade: hello                     <-- our optimistic
     [ch 3] +Acdream: [Trade] hello          <-- ACE's TurbineChat broadcast
   ACE's TurbineChatHandler at Network/Handlers/TurbineChatHandler.cs
   broadcasts EventSendToRoom to ALL recipients in the room including
   the sender, so the canonical echo always arrives via 0xF7DE. Drop
   the optimistic OnSelfSent for Turbine kinds in GameWindow's
   SendChatCmd handler; trust the server. Legacy ChatChannel paths
   (Fellowship / Allegiance / Patron / Monarch / Vassals / CoVassals)
   keep the optimistic echo because the legacy 0x0147 broadcast may
   not always come back to the sender.

   Inbound TurbineChat also stops embedding "[Trade] " into the
   message text — passes the friendly name out-of-band via the new
   channelName parameter on ChatLog.OnChannelBroadcast.

11 tests updated for the new format strings (8 in ChatVMTests, 1 in
ChatVMCombatTests, 1 BuildTell, plus the format additions cover
incoming/outgoing variants per kind). Solution total: 1007 green
(243 + 114 + 650), 0 warnings.

Tells should now actually deliver. Channel echoes show as
[Trade] +Acdream says, "hello" without the duplicate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:49:02 +02:00
Erik
e17caa2942 fix(chat): translate WeenieError templates + strip Tell target punctuation + Turbine routing diagnostics
Three post-launch fixes from the 2026-04-25 live verify session.

1. WeenieError display bug. Many ACE WeenieError / WeenieErrorWithString
   codes are *informational*, not error-level — the user saw cryptic
   "WeenieError 0x051B: General" / "WeenieError 0x051D" at login, but
   those decode as "You have entered the General channel." and
   "Turbine Chat is enabled." per ACE WeenieError(WithString).cs
   templates. New static helper Core/Chat/WeenieErrorMessages.cs maps
   ~30 high-frequency codes to retail-faithful templates with `_`
   placeholder substitution. ChatLog.OnWeenieError now routes through
   Format(); unknown codes still fall back to "WeenieError 0xNNNN[: param]"
   so nothing is silently lost. New codes can be added in 30 seconds
   when the user reports one.

2. Tell target eats trailing punctuation. Retail muscle memory is
   "/t Name, message" — comma is the separator. Our split-on-whitespace
   pulled "Name," (with comma) as the target, server returned 0x052B
   "That person is not available now." because no such character.
   ChatInputParser.TryParseTargeted now strips a trailing ,;:.!? from
   the target token so "/t Caith, hi" and "/t Caith hi" both work.
   Added 7 Theory cases covering each separator + the long-form alias.

3. TurbineChat routing diagnostics. The user's ACE login showed the
   "TurbineChatIsEnabled" + "YouHaveEnteredThe_Channel" notifications
   for General/Trade/LFG, confirming TurbineChat IS active server-side.
   But outbound /g /trade /lfg might still fall back to legacy
   ChatChannel (which the server then rejects). Added diagnostic
   Console.WriteLines so the next launch shows:
     - "chat: SetTurbineChatChannels parsed enabled=true general=0x... ..."
       (when ACE sends the 0x0295 channel-id table)
     - "chat: outbound TurbineChat General room=0x... cookie=0x... len=N"
       (when SendChatCmd routes a Turbine kind through 0xF7DE)
     - "chat: outbound legacy ChatChannel Fellowship id=0x... len=N"
       (when SendChatCmd uses the legacy 0x0147 path)
     - "chat: SendChatCmd kind=General dropped (turbine.Enabled=false no legacy id)"
       (when neither path can dispatch — usually means ACE didn't send
       0x0295 yet and the kind is Turbine-only)

   Sets up Bug 3 (proper outbound TurbineChat for /g /trade /lfg) for
   a follow-up commit once the next live trace shows the actual flow.

18 new tests:
- WeenieErrorMessagesTests: 11 covering known templates + fallback.
- ChatInputParserTests: +7 Theory cases for trailing-punctuation strip.

Solution total: 1007 green (114 UI + 650 Core + 243 Core.Net), 0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:31:23 +02:00
Erik
762df152d1 docs: align roadmap + ISSUES + CLAUDE.md with Phase I (UI consolidation + chat completeness)
Wraps Phase I — UI consolidation + complete chat system. All 7 prior
commits (I.1 through I.7 + I.2) are now reflected in the canonical
sources of truth.

- docs/plans/2026-04-11-roadmap.md: new "Phase I — UI consolidation +
  complete chat system" section between H and J. 8 sub-pieces all
  marked SHIPPED 2026-04-25 with their actual commit SHAs:
    I.1 b131514, I.2 56037a4, I.3 8e6e5a0, I.4 f14296c, I.5 ff5ed9e,
    I.6 ca968fc, I.7 3d26c8e, I.8 (this commit).
  Plus Phase H.1 entry annotated to credit I.4 + I.7 for chat input
  + combat translation. D.5 / D.6 entries cross-link to the new I
  surface where relevant. Three Q&A rows added to "When will my
  specific complaint be fixed?".

- docs/ISSUES.md: 7 issues filed and closed in the same session
  (#14 IPanelRenderer widgets, #15 DebugPanel migration, #16
  LiveCommandBus, #17 ChatPanel input, #18 holtburger inbound
  parity, #19 TurbineChat, #20 CombatChatTranslator). All in
  Recently closed with real commit SHAs.

- CLAUDE.md: surgical update to the UI strategy paragraph (~line 35).
  ImGui now hosts ALL dev/debug UI (Vitals + Chat + Debug);
  StbTrueTypeSharp DebugOverlay deleted in I.2; TextRenderer +
  BitmapFont retained for the future HUD-in-world (D.6); custom
  retail-look toolkit (D.2b) remains the long-term retail-look
  path while ImGui is the pragmatic D.2a default.

- memory/project_chat_pipeline.md (auto-loaded; in user's claude
  project memory tree): new evergreen crib documenting the
  ChatLog -> ChatVM -> ChatPanel + LiveCommandBus -> WorldSession
  pipeline with the slash-command set + opcode coverage.
- memory/MEMORY.md: indexed line for project_chat_pipeline.

Solution state at end of Phase I:
  989 tests green (107 + 639 + 243), 0 warnings, 0 errors.
  +124 tests across the phase.

Closes Phase I in roadmap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:11:23 +02:00
Erik
56037a4471 feat(ui): #15 migrate DebugOverlay to ImGui DebugPanel - 7 collapsing sections + diagnostics toggles
Replaces the 473-LOC custom-StbTrueTypeSharp DebugOverlay with an
ImGui-rendered DebugPanel using the I.1 widget extensions. Single
window with 7 CollapsingHeader sections; checkboxes are the primary
toggle surface; F-keys retained where they invoke real gameplay
actions, dropped where they only toggled panels.

Pieces:
- DebugVM (UI.Abstractions): read-through ViewModel with combat-event
  ring (cap 25), toast ring (cap 25), 4 diagnostic-flag bools
  (DumpMotion / DumpVitals / DumpOpcodes / DumpSky), 3 Action hooks
  (CycleTimeOfDay / CycleWeather / ToggleCollisionWires). Self-
  subscribes to CombatState.DamageTaken/DealtAccepted/Evaded* /
  Missed*/AttackDone/KillLanded - replaces the old BindCombat path.
- DebugPanel (UI.Abstractions): one ImGui window with sections
  Player Info, Performance, Compass (text-only - draw-list strip
  deferred to D.6), Help (BeginTable cheat-sheet), Combat events
  (TextColored by kind: Info=yellow, Warning=red, Error=deep red),
  Recent toasts, Diagnostics (Checkboxes for the 4 flags + Buttons
  for the 3 cycle/toggle actions).
- All 28 Snapshot data points covered: Fps, FrameMs, PlayerPos,
  HeadingDeg, CellId, OnGround, InPlayerMode, InFlyMode,
  VerticalVelocity, EntityCount, AnimatedCount, LandblocksVisible,
  LandblocksTotal, ShadowObjectCount, NearestObjDist, NearestObjLabel,
  Colliding, DebugWireframes, StreamingRadius, MouseSensitivity,
  ChaseDistance, RmbOrbit, HourName, DayFraction, Weather,
  ActiveLights, RegisteredLights, ParticleCount.
- GameWindow surgery (+252/-165): removed _debugOverlay field +
  snapshot builder block + Update/Draw calls; added _debugVm /
  _debugPanel construction in the if (DevToolsEnabled) block;
  added per-frame nearest-object scan cached for VM closures
  (zero cost when devtools off); helper methods CycleTimeOfDay /
  CycleWeather / ToggleCollisionWires / GetDebug* / GetActiveSensitivity.

F-key disposition:
- F1: repurposed - now toggles whole DebugPanel visibility.
- F2: kept - ToggleCollisionWires (also a Button in panel).
- F4 / F5 / F6: REMOVED - per-section toggles replaced by
  CollapsingHeader inside one window.
- F7: kept - CycleTimeOfDay (also Button).
- F8 / F9: kept - mouse-sensitivity adjust; toasts route to
  _debugVm.AddToast.
- F10: kept - CycleWeather (also Button).

DebugOverlay.cs DELETED (473 LOC). TextRenderer + BitmapFont kept
alive: UiHost references _debugFont and the future HUD-in-world
(D.6) will reuse both.

11 new DebugVM tests covering combat-event-ring subscription, toast
ring cap, diagnostic-flag toggles. UI.Abstractions.Tests: 96 -> 107.
Solution total: 989 green (243 Core.Net + 639 Core + 107 UI).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:09:26 +02:00
Erik
3d26c8efde feat(chat): #20 CombatChatTranslator - retail-faithful combat -> ChatLog templates
Subscribes to CombatState's DamageDealtAccepted / DamageTaken /
MissedOutgoing / EvadedIncoming / AttackDone / KillLanded events
and emits chat-line text into ChatLog.OnCombatLine, mirroring
holtburger's templates verbatim from references/holtburger/apps/
holtburger-cli/src/pages/game/panels/chat.rs:221-308.

Pieces:
- ChatLog: new ChatKind.Combat value; new CombatLineKind enum
  (Info / Warning / Error) on ChatEntry; OnCombatLine(text, kind)
  adapter.
- CombatChatTranslator (Core, IDisposable). Static formatters:
  FormatDamageType (slashing/piercing/bludgeoning/fire/cold/acid/
  electric/nether), FormatDamageLocation (head/chest/abdomen/
  upper arm/lower arm/hand/upper leg/lower leg/foot), FormatPercent,
  FormatAttackConditionsSuffix.
- ChatVM.RecentLinesDetailed() returns FormattedLine records with
  kind metadata so panels can render combat lines colored.
- ChatPanel switches on Kind/CombatKind: combat-Info -> yellow,
  combat-Warning -> red incoming-damage, combat-Error -> deep red,
  all others -> existing renderer.Text path.
- GameWindow constructs translator after GameEventWiring.WireAll;
  disposes in OnClosing + live-session failure path.

Templates landed:
  Attacker:  "You hit {def} for {dmg} {dtype} damage ({hp%}). [Crit]{suffix}"
  Defender:  "{atk} hit you for {dmg} {dtype} damage to your {loc} ({hp%})..."
  Evade-out: "{def} evaded your attack."
  Evade-in:  "You evaded {atk}'s attack."
  AttackErr: "Attack sequence finished with {error}."
  Kill:      synthesized "You killed {name}." + server PlayerKilled
             death-message arrives separately via ChatLog.OnPlayerKilled.

Deviations from holtburger templates (documented in source):
- DamageDealt omits Critical-hit suffix until CombatState.DamageDealt
  carries the flag (defender-side has it; attacker-side doesn't yet).
- DamageTaken omits (health%) until CombatState.DamageIncoming
  parses the wire health-percent field.
- AttackConditions suffix is implemented but always empty until the
  bitflag is plumbed into CombatState records.

18 new tests (12 translator + 4 ChatVMCombat + 2 ChatLog).
Solution total: 978 green (243 Core.Net + 639 Core + 96 UI).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 19:55:15 +02:00
Erik
ca968fc766 feat(net+chat): #19 TurbineChat (0xF7DE) codec + ChatChannelInfo + SetTurbineChatChannels parser
Full port of holtburger's TurbineChat sidecar wire path:

- TurbineChat.cs: 0xF7DE codec with three payload variants
  (EventSendToRoom S->C, RequestSendToRoomById C->S, Response).
  10-field outer header (size_first/blob_type/dispatch_type/
  target_type/target_id/transport_type/transport_id/cookie/
  size_second + payload).
- UTF-16LE turbine string codec with 1-or-2 byte variable-length
  prefix (high bit on first byte signals 2-byte form). Mirrors
  holtburger's read_turbine_string / write_turbine_string at
  references/holtburger/.../messages/chat/turbine.rs:502-544.
- SetTurbineChatChannels.cs: 0x0295 GameEvent sub-opcode parser
  (10 x u32 channel ids). Wired through GameEventDispatcher in
  WorldSession ctor; routes to GameEventWiring + TurbineChatState.
- ChatChannelInfo.cs (Core): unified record union with Legacy
  (channel id + name) and Turbine (room id + chat type +
  dispatch type + name) variants, plus IsSelfEchoChannel
  predicate (Tells = false, channels = true so optimistic echo
  is suppressed where the server will echo).
- TurbineChatState.cs (Core): Enabled flag + 10 cached room ids
  + NextContextId() cookie counter starting at 1.
- WorldSession adds TurbineChatReceived + TurbineChannelsReceived
  events; SendTurbineChatTo outbound builds RequestSendToRoomById
  + sends through SendGameAction. ProcessDatagram dispatches
  0xF7DE at the top level.
- GameWindow constructs TurbineChatState, subscribes inbound
  EventSendToRoom -> ChatLog.OnChannelBroadcast; extends I.3's
  SendChatCmd handler to route Turbine kinds (General/Trade/Lfg/
  Roleplay/Society/Olthoi) through TurbineChat first, fall back
  to legacy ChatChannel send when state.Enabled == false.

Round-trip golden fixtures from holtburger source verified for
all three payload variants + UTF-16LE strings (short + long
prefix + non-ASCII Cafe + empty) + SetTurbineChatChannels.

26 new tests:
- TurbineChatTests, SetTurbineChatChannelsTests in Core.Net.Tests
- ChatChannelInfoTests, TurbineChatStateTests in Core.Tests

Solution total: 960 green (243 Core.Net + 625 Core + 92 UI).

ACE doesn't run a TurbineChat server, so codec is "ready when
needed" for retail-server-emulating setups. Legacy ChatChannel
fallback continues to work for current ACE-against-acdream play.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 19:44:56 +02:00
Erik
f14296c75f feat(ui): #17 ChatPanel input field + slash commands + reply-to-last-tell
ChatPanel gains an Enter-to-submit input field via the I.1
InputTextSubmit widget. Submitted text routes through ChatInputParser
to a SendChatCmd published on ctx.Commands; LiveCommandBus (I.3)
handles the wire send + ChatLog echo.

Recognised prefixes (ported from holtburger commands.rs):

  /say msg or no prefix  -> Say
  /t Name msg or /tell   -> Tell  (first whitespace token = target)
  /r msg                 -> Tell  (target = LastIncomingTellSender)
  /g msg                 -> General
  /f msg                 -> Fellowship
  /a msg                 -> Allegiance
  /m msg                 -> Monarch
  /p msg                 -> Patron
  /v msg                 -> Vassals
  /cv msg                -> CoVassals
  /lfg msg               -> Lfg
  /trade msg             -> Trade
  /role msg              -> Roleplay
  /society msg           -> Society
  /olthoi msg            -> Olthoi

Edge cases: empty / whitespace / cmd-without-message / /r without
prior tell -> null (no-op). Unknown /xyz prefix -> Say with literal
text (matches holtburger's Talk(command) default arm).

ChatVM.LastIncomingTellSender populated only on incoming Tell entries;
discriminated by SenderGuid != 0 (OnSelfSent echoes always carry 0).

32 new tests:
- ChatInputParserTests: 22 covering every prefix + edge case
- ChatVMLastTellSenderTests: 6 covering capture + skip rules
- ChatPanelInputTests: 6 using FakePanelRenderer + recording
  ICommandBus to assert publish behaviour

UI.Abstractions.Tests: 60 -> 92. Solution total: 934 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 19:44:04 +02:00
Erik
8e6e5a0b61 feat(ui+net): #16 LiveCommandBus + WorldSession.Send{Talk,Tell,Channel} + SendChatCmd wiring
Replaces NullCommandBus.Instance in PanelContext with a real
LiveCommandBus when a live session is active. Panels publish
SendChatCmd; the host routes it to the right wire opcode + emits
a ChatLog.OnSelfSent local echo (optimistic; retail-equivalent
for Talk).

Pieces:
- ChatChannelKind enum (UI.Abstractions) - mirrors holtburger's
  ChatChannelKind (references/holtburger/.../client/types.rs:35-49).
- SendChatCmd record (UI.Abstractions) - (Channel, TargetName?, Text).
- LiveCommandBus (UI.Abstractions) - single-handler-per-type;
  Register<T> throws on double-register; Publish<T> logs missing
  handler but does not throw.
- ChannelResolver (UI.Abstractions) - port of holtburger's
  resolve_legacy_channel (client/commands.rs:50-62) mapping
  ChatChannelKind to legacy ChatChannel ids verbatim from
  holtburger-protocol/.../chat/types.rs:8-24 (Fellow=0x0800,
  AllegianceBroadcast=0x02000000, Vassals=0x1000, Patron=0x2000,
  Monarch=0x4000, CoVassals=0x01000000).
- WorldSession.SendTalk / SendTell / SendChannel - 3-line wrappers
  around existing ChatRequests.Build* + SendGameAction. Internal
  GameActionCapture seam + InternalsVisibleTo for tests.
- GameWindow registers SendChatCmd handler: Say -> SendTalk +
  ChatLog echo, Tell -> SendTell + echo, channel kinds ->
  ChannelResolver.Resolve -> SendChannel + echo.

12 new tests across SendChatCmd + LiveCommandBus + ChannelResolver
+ WorldSessionChat. NullCommandBus.Instance retained for back-compat
when no live session.

Solution total: 893 green (51 + 229 + 613).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 19:27:22 +02:00
Erik
ff5ed9ec0b feat(net): #18 holtburger inbound chat parity - EmoteText, SoulEmote, ServerMessage, PlayerKilled, WeenieError + Windows-1252 codec
Five sub-changes:

1. Windows-1252 codec switch (global). Every Encoding.ASCII call site
   in src/AcDream.Core.Net/Messages/ -> Encoding.GetEncoding(1252).
   Touched HearSpeech, ChatRequests, GameEvents, AppraiseInfoParser,
   CharacterList, CreateObject, PlayerDescriptionParser, SocialActions.
   New Encodings.cs module-init registers CodePagesEncodingProvider
   (System.Text.Encoding.CodePages ships with .NET 10 SDK but isn't
   auto-registered). Matches retail + holtburger; accented names
   no longer round-trip-broken.

2. New parsers (opcodes confirmed against holtburger opcodes.rs):
   - EmoteText (0x01E0)     { u32 senderGuid, string16 senderName, string16 text }
   - SoulEmote (0x01E2)     same wire layout as EmoteText
   - ServerMessage (0xF7E0) { string16 message, u32 chatType }
   - PlayerKilled (0x019E)  { string16 deathMessage, u32 victimGuid, u32 killerGuid }
   Shared StringReader.cs has the CP1252 String16L primitive.

3. WorldSession dispatch. ProcessDatagram adds branches for the four
   new top-level opcodes + fires session-level events (EmoteHeard,
   SoulEmoteHeard, ServerMessageReceived, PlayerKilledReceived).
   0x0295 SetTurbineChatChannels stubbed with TODO for parallel I.6.

4. GameEventWiring routes WeenieError + WeenieErrorWithString
   (parsers existed but were unrouted) -> chat.OnWeenieError.

5. ChatLog adapters: Emote / SoulEmote ChatKind values, OnEmote,
   OnSoulEmote, OnPlayerKilled, OnWeenieError. OnLocalSpeech now
   substitutes empty sender -> "You" per holtburger client/messages.rs.
   ChatVM.FormatEntry handles new kinds (asterisk + sender + text).

22 new tests covering parser round-trips + reject-bad-opcode +
ChatLog adapter coverage + Win-1252 round-trip with non-ASCII chars.
Solution total: 881 green (210->225 in Core.Net.Tests, 606->613 in Core.Tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 19:06:01 +02:00
Erik
b131514d51 feat(ui): #14 IPanelRenderer widget extension - TextColored, Checkbox, Combo, InputTextSubmit, BeginTable, etc.
Adds 14 widget signatures to IPanelRenderer + ImGuiPanelRenderer impl:
TextColored, CollapsingHeader, TreeNode/TreePop, Checkbox, Button,
Combo, SliderFloat, PlotLines, BeginTable/TableNextColumn/EndTable,
InputTextSubmit (Enter-key submit), Spacing, Dummy, TextWrapped.

InputTextSubmit uses ImGuiInputTextFlags.EnterReturnsTrue and clears
the buffer + emits via `out submitted` on the frame Enter is pressed.
PlotLines passes `ref values[0]` with empty-array guard. CollapsingHeader
defaultOpen=true uses ImGuiTreeNodeFlags.DefaultOpen (= 0x20).

FakePanelRenderer test double records (Method, Args) tuples and
exposes knobs to drive ref/out values. 17 new tests dispatch through
IPanelRenderer (not the concrete fake) so tests fail to compile when
the interface itself lacks a method - real RED -> GREEN signal.

Tests: 26 -> 43 in UI.Abstractions.Tests. Total solution 881 green.
Foundation for Phase I.2 (DebugPanel) and I.4 (ChatPanel input field).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 19:03:28 +02:00
Erik
196f883c10 fix(player): EnchantmentMask bit fix + Vitae key=0 + absolute Vitals overlay
Three fixes to the Vitals HUD path:

1. EnchantmentMask Vitae/Cooldown bit values (parser regression).
   ACE's enum at references/ACE/Source/ACE.Entity/Enum/EnchantmentCategory.cs
   has Vitae=0x4 and Cooldown=0x8. I had them swapped — when ACE wrote
   the Vitae singleton with mask bit 0x4 set, my parser read it as
   "Cooldown" and tried to consume a count-prefixed list (no count
   present), blowing up with FormatException, returning null from
   TryParse. PlayerDescription consequently failed to parse on every
   live login. Fix: swap the bit values + bucket constants to match ACE.

2. Vitae applies regardless of StatModKey. Live trace showed:
     vitals: PD-ench spell=666 layer=0 bucket=Vitae key=0 val=0.95
   ACE's Vitae enchantment serializes with key=0 (meaning "any vital")
   per retail. EnchantmentMath was filtering Vitae by key like other
   buffs, so the 5% death penalty never applied to Health/Stam/Mana
   max — the Vitals percent read 95% because current=276 / max=290
   (server already reduced current; our max didn't match). Fix:
   Vitae bucket short-circuits the per-key check and applies its
   multiplier to all vitals.

3. Absolute current/max in HUD overlay. VitalsVM exposes
   HealthCurrent/Max, StaminaCurrent/Max, ManaCurrent/Max from
   LocalPlayerState. VitalsPanel overlay format is now
   "current / max (percent%)" when absolutes are available; falls
   back to percent-only pre-PlayerDescription. Matches the retail
   look the user requested ("HP 400/400" style).

Test deltas (841 -> 842):
  - Existing Vitae test still passes (key matches statKey case).
  - New Vitae key=0 test pins the "any vital" semantics.
  - Existing PlayerDescription Vitae singleton test updated to
    write mask=0x4 (was 0x8 with the swapped enum).

Live verification: with +Acdream's Vitae-666 active and Endurance.current=290:
  HP   : current=138, max=145×0.95≈138 → bar 100% (was 95%)
  Stam : current=276, max=290×0.95≈276 → bar 100%
  Mana : current=190, max=200×0.95≈190 → bar 100%
Overlay reads e.g. "276 / 276 (100%)".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:15:20 +02:00
Erik
bb5003a849 feat(net): #7 PlayerDescriptionParser - enchantment block walker + StatMod flow
Extends PlayerDescriptionParser past the spell block to parse the
Enchantment trailer per holtburger events.rs:462-501 +
magic/types.rs:40. New EnchantmentEntry record carries the full
60-64 byte wire payload:
  u16 spell_id, layer, spell_category, has_spell_set_id
  u32 power_level
  f64 start_time, duration
  u32 caster_guid
  f32 degrade_modifier, degrade_limit
  f64 last_time_degraded
  u32 stat_mod_type, stat_mod_key
  f32 stat_mod_value
  [u32 spell_set_id]?
  + EnchantmentBucket (Multiplicative / Additive / Cooldown / Vitae)

EnchantmentMask outer u32 selects which buckets follow; each bucket
(except Vitae) is u32 count + N records. Vitae is a singleton.

Parsed.Enchantments now exposed as IReadOnlyList<EnchantmentEntry>.
GameEventWiring routes each entry through Spellbook.OnEnchantmentAdded
with the full StatMod data + bucket. EnchantmentMath.GetMod consumes
StatMod records to produce real (Multiplier, Additive) per stat key:

  Bucket 1 (Multiplicative): multiplier *= val
  Bucket 2 (Additive):       additive += val
  Bucket 8 (Vitae):          multiplier *= val (applied last)
  Bucket 4 (Cooldown):       skipped (not a vital mod)

ActiveEnchantmentRecord extended with optional StatModType /
StatModKey / StatModValue / Bucket fields. Existing 4-arg callers
stay compatible (defaults to null / 0). New OnEnchantmentAdded
overload accepts the full record from PlayerDescription path.

Tests: 7 new (834 -> 841):
  - PlayerDescriptionParserTests (2): enchantment block schema with
    multiplicative + additive buckets, Vitae singleton.
  - EnchantmentMathTests (5): multiplicative buffs aggregate, additive
    buffs sum, stat-key mismatch filters out, Vitae applied
    multiplicatively, family-stacking picks higher spell-id.

Closes #7 (parser past spells, enchantment block parsed).
Closes #12 (StatMod flow architecture — data lights up #6's
aggregator). Files #13 (remaining trailer sections: options /
shortcuts / hotbars / desired_comps / spellbook_filters / options2 /
gameplay_options / inventory / equipped — needs the heuristic
gameplay_options walker per holtburger).

Note: ParseMagicUpdateEnchantment (live-update 0x02C2) NOT yet
extended — still uses 4-field summary. PlayerDescription is the
load-bearing path for #6; live updates can be folded in separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:01:22 +02:00
Erik
b153bbe5ad feat(player): #6 fold enchantment buffs into vital max via EnchantmentMath
Ports CEnchantmentRegistry::EnchantAttribute (PDB 0x00594570, see
docs/research/named-retail/acclient_2013_pseudo_c.txt line 416110).
The retail formula:

    real_max = (vital.(ranks+start) + attribute_contribution) * mult_buff + add_buff
    clamp >= 5 if base >= 5 else >= 1

is now applied in LocalPlayerState.GetMaxApprox.

EnchantmentMath.GetMod(activeEnchantments, table, statKey)
  - Family-stacking dedup via SpellTable.Family (only one buff per
    family-bucket wins, by highest spell-id as a generation proxy).
  - Family=0 means "no bucket" — each layer is its own bucket.
  - Returns (Multiplier, Additive) ready to apply.
  - StatKey constants: MaxHealth=1, MaxStamina=3, MaxMana=5
    (verified against named-retail/acclient.h line 37287-37301).

Spellbook.GetVitalMod(statKey) delegates to EnchantmentMath using
its constructor-injected SpellTable.

LocalPlayerState.GetMaxApprox now applies the full formula with
the min-vital floor (matches CreatureVital::GetMaxValue at PDB
0x0058F2DD). When Spellbook is null (back-compat), falls back to
Identity (no buff modification) — existing tests stay green.

GameWindow constructor wires SpellBook -> LocalPlayer so the chain
is complete in the live session.

Architecture in place; data still flat.

Until ISSUES.md #12 lands the wire-format extension that captures
StatMod (type/key/val) on ActiveEnchantmentRecord, the per-enchantment
modifier value isn't aggregated yet — GetMod returns Identity. Once
#12 wires the data, the existing aggregator + formula light up
automatically. Live +Acdream Stam/Mana will keep reading ~95% until
#12 lands.

6 new EnchantmentMathTests cover: empty list returns Identity,
no-table-entries returns Identity, stat-key constants match ACE,
Identity is (1, 0), family-stacking dedup, family=0 (no-bucket).

Total tests: 828 -> 834.

Closes #6 architecturally. Files #12 to track the wire-data follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:55:15 +02:00
Erik
4ceac5cb40 feat(spells): #11 SpellTable - hydrate metadata from spells.csv at startup
New SpellMetadata + SpellTable. Loads docs/research/data/spells.csv at
GameWindow construction (3,956 spells x 11 useful fields including
Family for buff stacking which issue #6 needs). The CSV is copied to
bin/<config>/net10.0/data/spells.csv via the csproj <None Include>
entry; SpellTable.LoadFromCsv resolves relative to AppContext.BaseDirectory.

Hand-rolled CSV parser handles RFC 4180 quoted fields with embedded
commas (the Description column) + escaped double-quotes ("" -> ").
No external CsvHelper dep. Falls back to SpellTable.Empty + console
warning if the file is missing (tooling contexts).

Spellbook now accepts an optional SpellTable in its constructor +
exposes TryGetMetadata(spellId, out SpellMetadata). When the table is
absent (legacy `new Spellbook()` calls), TryGetMetadata returns false
gracefully so existing tests keep passing.

GameWindow:
  - SpellTable field initialized via LoadSpellTable() helper that
    handles the missing-file case + emits the spells: loaded N entries
    log line.
  - SpellBook field constructor-initialized with the loaded SpellTable
    so TryGetMetadata works for the live session.

10 new tests (SpellTableTests):
  - Empty table behavior
  - Header-only loads to empty
  - Single row populates all metadata
  - Quoted Description with embedded commas
  - Blank lines skipped
  - Bad-spell-id rows silently skipped (third-party data is messy)
  - Unknown spell-id lookup returns false
  - ParseRow primitive: simple comma split, quoted-field with comma,
    escaped double-quote.

Total tests: 818 -> 828.

Closes #11. Phase G (issue #6 — fold enchantment buffs into vital max
via EnchantmentMath using SpellTable.Family for stacking) unblocked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:48:43 +02:00
Erik
83b020499b docs(research): #9 sweep acclient_function_map.md against PDB symbols
Pure-docs sweep. Cross-checked 63 hand-curated entries in
acclient_function_map.md against docs/research/named-retail/symbols.json
(the PDB-derived authoritative name table) using the new helper at
tools/pdb-extract/check_function_map.py.

Findings:
  - Zero entries matched address-and-name exactly. Confirms the
    PDB build is from a different revision than the binary that
    produced our Ghidra chunks (~0x800-0xC10 byte delta varies by
    function cluster). Match by NAME, not by raw address.
  - 38 entries corrected by PDB name lookup. The "Was" column
    preserves the old address for traceability against existing
    code comments. Old entries pointed mid-body of the actual
    function; new column heads point to function starts.
  - 25 entries have no PDB match. Either inlined / non-public
    (no S_PUB32 record) or our hand-derived names were synthesized
    from call-site analysis and don't match the MSVC mangled form
    in the PDB. Several had wrong class assignments (e.g. 0x5387C0
    claimed as CTransition::find_collisions, actually
    CPolygon::polygon_hits_sphere). Flagged for re-derivation in
    acclient_2013_pseudo_c.txt.

Pattern: kept the table format with two address columns (PDB +
legacy) so existing code references using the old addresses can
still be looked up. Added a sweep-summary section at the bottom of
the file documenting the methodology + findings.

Helper script at tools/pdb-extract/check_function_map.py is reusable
for future re-runs (re-run after every PDB regeneration / function
map edit).

Closes #9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:44:07 +02:00
Erik
567078803f docs(issues): #8/#9/#11 filed; #10 wired (KillerNotification)
Files four new issues created by the 2026-04-25 PDB-discovery sprint:
  #8  (DONE 2026-04-25) — pdb-extract tool, shipped 69d884a
  #9  (OPEN)            — function-map address-correction sweep
                          (Phase E will close)
  #10 (DONE 2026-04-25) — wire KillerNotification (0x01AD); orphan
                          parser at GameEvents.ParseKillerNotification
                          existed but was never registered. This commit
                          adds CombatState.OnKillerNotification +
                          KillLanded event, registers the dispatcher
                          handler, and adds a regression test.
  #11 (OPEN)            — spell metadata loader (spells.csv → SpellTable)
                          (Phase F will close)

Code change is minimal — three lines of dispatch + a 12-line
CombatState method with a typed event for future killfeed UI.

818 tests passing (+1 KillerNotification).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:39:47 +02:00
Erik
0a429a980c docs(workflow): align CLAUDE.md + memory + roadmap with named-retail foundation
CLAUDE.md edits (6 surgical ranges):
  - Goal section: introduce named-retail/ as primary; old chunks
    remain as fallback for chunk-by-chunk address-range navigation.
  - Workflow renamed to "grep named -> decompile -> verify -> port"
    with a new STEP 0 GREP NAMED FIRST. Decompile demoted to a
    fallback (Step 1) for the rare obfuscated/packed minority that
    pseudo-C lacks.
  - Function-map citation updated to point at symbols.json + the
    cross-port hand-curated table.
  - "Do not guess" rule strengthened: PDB has the answer for almost
    everything; guessing is now negligence.
  - Phase completion checklist accepts named symbols + addresses.
  - Reference hierarchy table gets a new top row pointing at
    docs/research/named-retail/ as the primary oracle for any
    AC-specific algorithm — beats every other reference.

memory/project_named_decompilation.md (new): evergreen crib-sheet
with file inventory, grep examples, hard rules. Pattern matches
project_ui_architecture.md.

memory/project_retail_research_index.md: updated preamble to point
named-retail/ as first stop; older slices remain useful for
pseudocode + C# port sketches.

memory/project_collision_port.md: rewrote the "Decompiled ground
truth" section to put named-retail/ first, chunks second. The
"DECOMPILE FIRST" mandate becomes "GREP NAMED FIRST, then DECOMPILE
FALLBACK".

docs/architecture/acdream-architecture.md: Guiding Principle text
updated to introduce named-retail as the primary decomp source.

docs/plans/2026-04-11-roadmap.md: new Phase R block — Retail
research infrastructure. R.1 (corpus, shipped a9a01d8), R.2
(pdb-extract, shipped 69d884a), R.3 (actestclient vendored,
shipped a9a01d8). All marked SHIPPED 2026-04-25.

Auto-loaded MEMORY.md index updated with a new entry pointing at
project_named_decompilation.md so post-compaction sessions inherit
the workflow change automatically.

Acceptance verified:
  - grep -c "named-retail" CLAUDE.md = 9 (>= 3 required)
  - grep -c "named-retail" MEMORY.md = 1
  - dotnet build green (docs-only commit, but verified)

Foundation phases A + B + C all landed. Next: Phase D files
ISSUES #8/#9/#11 + closes #10 (KillerNotification orphan parser).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:36:53 +02:00
Erik
69d884a3d6 tools(pdb-extract): #8 PDB -> symbols.json + types.json sidecar
Pure-Python MSF 7.00 PDB extractor (no deps, stdlib only). Reads
refs/acclient.pdb directly:
  - DBI stream (3) -> symbol record stream index + section header
    stream index
  - Section headers stream (9) -> per-segment image VA bases
  - Symbol record stream (8) -> S_PUB32 records with image VAs
  - TPI stream (2) -> LF_CLASS / LF_STRUCTURE named records (not
    forward-declared), with size leaf + name

Includes a best-effort MSVC C++ demangler so symbols.json is
grep-friendly:
  ?EnchantAttribute@CEnchantmentRegistry@@QBEHKAAK@Z
  -> CEnchantmentRegistry::EnchantAttribute

Both demangled `name` + raw `mangled` emitted per entry so callers
can choose. Operator overloads, vtables, and other special forms
where a partial demangle would be misleading are kept mangled.

Outputs committed to docs/research/named-retail/:
  - symbols.json (2.9 MB) — 18,366 named public function symbols
  - types.json (506 KB) — 5,371 unique named class/struct records

Spot check (matches discovery agent's earlier finding):
  CEnchantmentRegistry::EnchantAttribute -> 0x00594570 ✓

Updated docs/research/acclient_function_map.md header preamble to
direct readers at the new symbols.json as the authoritative name
source; the hand-curated table stays as the cross-port (ACE/ACME)
index. Several addresses there are wrong vs the PDB and will be
swept in the issue #9 close (Phase E).

Closes #8 (filed in Phase D's commit). Foundation for the address
sweep + name-driven workflows from here on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:31:52 +02:00
Erik
a9a01d8ba2 docs(research): commit named retail decomp + spells.csv (foundation)
Move the high-value retail RE artifacts from refs/ (per-developer
download cache, gitignored) into committed paths so subagents +
post-compaction sessions inherit them without round-tripping:

  - docs/research/named-retail/acclient_2013_pseudo_c.txt (62 MB,
    Binary Ninja named pseudo-C, 99.6% function-name recovery —
    18,366 named functions out of 18,598 public symbols)
  - docs/research/named-retail/acclient.h (1.7 MB / 70,719 lines,
    IDA-decompiled retail struct definitions verbatim — Attribute,
    SecondaryAttribute, AttributeCache, Attribute2ndTable, SkillFormula,
    Enchantment, CEnchantmentRegistry with _mult_list/_add_list/_vitae,
    CSpellBook, MotionState, RawMotionState, MoveToStatePack, CACQualities,
    CPhysicsObj — every retail object-model layout we'd otherwise have
    to guess at)
  - docs/research/named-retail/acclient.c (46 MB, secondary named
    decomp — IDA full-binary export with mixed FUN_/named functions
    plus named struct fields the chunked Ghidra output lacks)
  - docs/research/data/spells.csv (3,956 spells × 35 cols including
    Family for buff stacking — issue #6 unblocked)

actestclient-master vendored at references/actestclient/ (extracted
from refs/actestclient-master-2019-01-10.zip; contains the canonical
machine-readable wire-schema messages.xml). Covered by existing
references/ gitignore — per-developer reference, not committed.

Repo precedent for committing decompiled retail content was set at
commit 4d36756 (18 MB Ghidra chunks). This adds ~110 MB more of the
same qualitative content. Ripgrep handles it in <1s.

Foundation for the named-retail workflow change in CLAUDE.md (next
commit). Plan at C:/Users/erikn/.claude/plans/ticklish-conjuring-cake.md
Phase A.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:27:19 +02:00
Erik
7da2a027d4 feat(player): #5 PlayerDescription parser — Stam/Mana via attribute block
Visual-verified — Vitals window now shows three bars (HP/Stam/Mana)
with live values. Closes ISSUES.md #5; ~95% reading on Stam/Mana
traced to active buff multipliers, filed as #6.

Why the rewrite

The first attempt (commit d42bf57) routed PlayerDescription (0x0013)
through AppraiseInfoParser, trusting a misleading xmldoc claim.
Live diagnostics proved the format is wrong — ACE source
(GameEventPlayerDescription.WriteEventBody) hand-writes a body
distinct from IdentifyObjectResponse's AppraiseInfo: property
hashtables gated on DescriptionPropertyFlag, vector-flag-gated
attribute / skill / spell blocks, then a long options + inventory
trailer. Vitals only arrive via the attribute block at login.
Holtburger's events.rs:220-625 has the canonical client-side
unpacker; this commit ports the early-section walker through spells.

What landed

  PlayerDescriptionParser.cs (new — 350 LOC):
    Walks propertyFlags + weenieType, then property hashtables
    (Int32/Int64/Bool/Double/String/Did/Iid) + Position table —
    each gated on a property flag bit, header is `u16 count, u16
    buckets`. Then vectorFlags + has_health + the attribute block
    (primary attrs 1..6 = 12 B each, vitals 7..9 = 16 B with
    `current`), then optional Skill + Spell tables. Stops cleanly
    before the options/shortcuts/hotbars/inventory trailer (filed
    as #7 — heuristic alignment search needed for gameplay_options).

  PrivateUpdateVital.cs (new — 95 LOC):
    Wire parsers for the GameMessage opcodes 0x02E7 (full snapshot)
    and 0x02E9 (current-only delta), per holtburger UpdateVital +
    UpdateVitalCurrent. WorldSession dispatches each to a session-
    level event the GameWindow forwards into LocalPlayerState.

  LocalPlayerState (full redesign):
    VitalKind (Health/Stamina/Mana) + AttributeKind (six primary).
    VitalSnapshot stores ranks/start/xp/current; AttributeSnapshot
    stores ranks/start/xp with `Current = ranks+start` per
    holtburger. GetMaxApprox computes the retail formula
        vital.(ranks+start) + attribute_contribution
    where the contribution is hardcoded from retail's
    SecondaryAttributeTable: Endurance/2 for Health, Endurance for
    Stamina, Self for Mana. Enchantment buffs not yet folded in
    (filed as #6). VitalIdToKind now accepts both ID systems
    (1..6 wire, 7..9 PD attribute block); AttributeIdToKind covers
    primary attrs 1..6.

  GameEventWiring:
    PlayerDescription handler. Walks parsed.Attributes, routes
    primary attrs (id 1..6) to OnAttributeUpdate and vitals
    (id 7..9) to OnVitalUpdate. Player's full learned spellbook
    also lands here. ACDREAM_DUMP_VITALS=1 traces every PD attribute
    + every PrivateUpdateVital(Current) opcode for diagnostics.

  WorldSession:
    Dispatch chain re-ordered — the diagnostic else-if for
    ACDREAM_DUMP_OPCODES=1 was originally placed before
    GameEventEnvelope.Opcode, which silently intercepted 0xF7B0 and
    broke UpdateHealth dispatch when the env var was set. Moved to
    the very end of the chain so it only fires for genuinely
    unhandled opcodes. (Diagnostic-only regression; production
    launches without the env var were unaffected.)

Test deltas

  Added:
    - PlayerDescriptionParserTests (6 — empty header, full attribute
      block, partial flags, post-property-table walk, spell table)
    - PrivateUpdateVitalTests (7 — fixture round-trip, vital ID
      coverage, opcode rejection, truncation)
    - LocalPlayerStateTests rewritten (20 — VitalIdToKind +
      AttributeIdToKind theories, Endurance/Self formula coverage,
      delta semantics, change events)
    - GameEventWiringTests for PlayerDescription dispatch (2 —
      end-to-end populate + spellbook feed)
  Updated:
    - VitalsVMTests rephrased onto the new OnVitalUpdate API.
  Total: 765 → 817 tests passing.

Diagnostics

  ACDREAM_DUMP_VITALS=1 — log every PD attribute extracted,
    every 0x02E7/0x02E9 dispatch.
  ACDREAM_DUMP_OPCODES=1 — log first occurrence of any unhandled
    GameMessage opcode (now correctly placed at end of chain).

Visual verify

  $env:ACDREAM_DEVTOOLS = "1"
  dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug

  Vitals window shows three bars; HP at 100%, Stam/Mana at ~95%
  (the gap is buff enchantments — filed as #6 with the holtburger
  multiplier+additive aggregator pattern as the reference for the
  fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:42:24 +02:00
Erik
d42bf5735d feat(player): #5 LocalPlayerState — Stam/Mana wired through PlayerDescription
Closes ISSUES.md #5. The Vitals devtools window now draws three bars
(HP / Stamina / Mana) once the server sends the first PlayerDescription
(0x0013), instead of HP only. Built test-first per CLAUDE.md TDD rule —
16 new tests went red before the implementation went in.

New AcDream.Core.Player.LocalPlayerState (cache):
  - {CurrentStamina, MaxStamina, CurrentMana, MaxMana} as uint? — null
    until first received.
  - StaminaPercent / ManaPercent: 0..1 fraction or null when either
    field is missing or max is zero. Clamps to 1.0 if current > max
    (server can briefly report this during buff transitions).
  - OnPlayerDescription preserves any previously known good value when
    an incoming field is null — partial profiles don't wipe state.
  - Changed event for future subscribers.

GameEventWiring.WireAll:
  - New optional 6th parameter: LocalPlayerState? localPlayer = null.
    Existing 5-arg call sites still work; without the parameter the new
    PlayerDescription handler still parses + feeds the spellbook but
    skips the cache update.
  - PlayerDescription (0x0013) shares AppraiseInfo wire format with
    IdentifyObjectResponse (0x00C9) per AppraiseInfoParser docstring,
    so the new handler reuses the existing parser and pulls
    CreatureProfile.{Stamina, StaminaMax, Mana, ManaMax}.
  - Player's full learned spellbook also lands here (previously only
    item-scoped Identify responses fed the spellbook).

VitalsVM:
  - Constructor adds optional LocalPlayerState? parameter (default null
    keeps every existing caller compiling).
  - StaminaPercent / ManaPercent now read through to LocalPlayerState
    every access — no VM-side caching, so a server-side delta to the
    cache surfaces next frame without any explicit refresh.

GameWindow:
  - Public readonly LocalPlayer field alongside Combat / Chat / Items /
    SpellBook so plugins + future panels can bind directly.
  - WireAll call updated to pass LocalPlayer.
  - VitalsVM construction passes LocalPlayer so the existing
    VitalsPanel automatically picks up the two new bars.

Test counts:
  - AcDream.Core.Tests:           550 → 561  (+11 LocalPlayerStateTests)
  - AcDream.UI.Abstractions.Tests: 23 →  26  (+3 VitalsVM through-cache)
  - AcDream.Core.Net.Tests:       192 → 194  (+2 PlayerDescription wiring)
  - Total:                        765 → 781

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:02:00 +02:00
Erik
9faf9d7e3a feat(ui): ChatPanel — second devtools panel proves the abstraction
Adds a second real panel behind ACDREAM_DEVTOOLS=1. Shows the tail
of ChatLog (last 20 entries by default) formatted per ChatKind:

  "Caith: hello"                  — LocalSpeech
  "Regal says distantly: hi"      — RangedSpeech
  "[ch 7] Caith: g'day"           — Channel
  "[Tell] Regal: psst"            — Tell
  "[System] Your spell fizzled!"  — System
  "[Popup] A door stands..."      — Popup

Why now: proves the D.2a IPanelRenderer contract survives beyond a
single progress-bar panel. ChatPanel exercises Text() + Separator()
on a variable-length list where VitalsPanel was a fixed three-widget
layout. No renderer primitives needed to grow — the contract held,
which is the whole point of the abstraction layer.

Files:
  - src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs (new)
      Snapshots ChatLog tail every frame. Cheap at default 500-entry
      cap. Per-kind formatting lives here (not in the panel) so the
      D.2b retail-look swap inherits plain-text fallbacks.
  - src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs (new)
      IPanel implementation. Separator + N Text lines. "(no messages
      yet)" fallback when the log is empty.
  - src/AcDream.App/Rendering/GameWindow.cs
      Registers the ChatPanel alongside VitalsPanel in the devtools
      init block. Uses the existing GameWindow.Chat field already
      fed by H.1's wire layer + GameEventWiring.WireAll.
  - tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs (new)
      12 tests covering tail selection, display-limit bounds, every
      ChatKind's formatting, null-log + zero-limit guards, no stale
      caching across appends.

Also fixes one stale "Hexa.NET.ImGui" mention in VitalsPanel's xmldoc
(pivoted to ImGui.NET in 55aaca7; doc needed a trailing update).

Build: 0 warnings, 0 errors. Tests: 23 UI.Abstractions (up from 11,
all Core + Core.Net still green), 0 failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:48:07 +02:00
Erik
4d1b8b8aee docs(issues): #5 — VitalsPanel stam/mana null until LocalPlayerState lands
Filed as the one explicit post-D.2a follow-up. VitalsVM returns float?
null for Stamina/Mana because absolute values only arrive in
PlayerDescription (0x0013) today and we parse-then-discard. A small
LocalPlayerState Core class that retains the parsed fields unblocks
two more progress bars in the existing Vitals window — no new wire
work needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:44:17 +02:00
Erik
55aaca7a14 feat(ui): Phase D.2a — VitalsPanel wired into GameWindow + backend pivot
Closes Phase D.2a. Launch with ACDREAM_DEVTOOLS=1 now shows a live
ImGui "Vitals" window whose HP bar reads CombatState.GetHealthPercent
for the local player. Without the env var the branches are dead code,
no ImGui context is created, and behaviour is identical to before.

GameWindow hunks:
  - fields: _imguiBootstrap / _panelHost / _vitalsVm + DevToolsEnabled
  - init (OnLoad): construct bootstrap + host, register VitalsPanel
  - GUID push: _vitalsVm?.SetLocalPlayerGuid(chosen.Id) at live-connect
  - frame begin: _imguiBootstrap.BeginFrame(dt) after GL clear
  - frame end: _panelHost.RenderAll(ctx) + _imguiBootstrap.Render() after debug overlay
  - input gating: skip WASD when ImGui.GetIO().WantCaptureKeyboard

Backend pivot: Hexa.NET.ImGui → ImGui.NET + Silk.NET.OpenGL.Extensions.ImGui.

First-light integration with the Hexa backend crashed 0xC0000005 inside
Hexa.NET.ImGui.Backends.OpenGL3.ImGuiImplOpenGL3.InitNative. Root cause:
Hexa's native OpenGL3 backend resolves GL function pointers via GLFW or
SDL internally; with Silk.NET (which uses neither) the pointers are null
and the native code crashes on first use. The mitigation path was
already planned — the design doc's Risk section called a pivot to
ImGui.NET a "one-morning operation" — and that's exactly what happened.

  - Packages: Hexa.NET.ImGui 2.2.9 + Hexa.NET.ImGui.Backends 1.0.18
    → ImGui.NET 1.91.6.1 + Silk.NET.OpenGL.Extensions.ImGui 2.23.0
  - ImGuiBootstrapper: was static Initialize(gl)+Shutdown() wrapping
    Hexa's OpenGL3 init; now an IDisposable wrapping Silk.NET's
    ImGuiController instance which handles GL backend init + input
    subscription in one go.
  - SilkInputBridge.cs deleted (~190 LOC): ImGuiController subscribes
    IKeyboard / IMouse events itself, we don't need a bespoke bridge.
  - ImGuiPanelRenderer: ImGuiNET.ImGui.* calls instead of
    Hexa.NET.ImGui.ImGui.*. Widget surface unchanged.

Boundary discipline is preserved — no panel imports ImGuiNET; only
ImGuiPanelRenderer does. The D.2b custom toolkit will implement the
same IPanelRenderer contract without touching panel code.

Out of scope (tracked for follow-up):
  - Stam/Mana currently return float? null (VitalsVM). Absolute values
    need LocalPlayerState + PlayerDescription (0x0013) parsing to be
    stored rather than discarded — filed as a post-D.2a issue.
  - Mouse-capture gating (WorldMouseFallThrough-style click-through
    tests) — not needed until we add clickable inventory items.

Roadmap + memory + architecture doc + UI framework plan updated in the
same commit per CLAUDE.md roadmap-discipline rules. 753 tests pass
(550 Core + 192 Core.Net + 11 new UI.Abstractions), 0 build warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:43:46 +02:00