Commit graph

206 commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
Erik
d1c7d4a8b6 net: resolve ACDREAM_TEST_HOST via DNS, not just IPAddress.Parse
Allows hostnames like `play.coldeve.ac` in ACDREAM_TEST_HOST. Previously
the env var fed `IPAddress.Parse` directly and threw "An invalid IP
address was specified" on anything that wasn't a literal dotted IP.

Now: `IPAddress.TryParse` first (fast path, unchanged for 127.0.0.1 /
literal IPs); on failure, fall back to `Dns.GetHostAddresses` and prefer
the first IPv4 address (ACE + retail both use IPv4 UDP exclusively).

Tested against `play.coldeve.ac:9000` — resolves to 51.79.80.150,
handshake succeeds, login to character Barris works end-to-end.
2026-04-24 23:58:42 +02:00
Erik
593b76fda1 sky(phase-8.1): CLAMP_TO_EDGE on static sky meshes
The dome is 5 flat quads meeting at edges. Under GL_REPEAT + LINEAR
filtering, the edge pixels of each wall wrap-sample to the OPPOSITE
edge of the SAME texture (repeat wraps 1.0→0.0). Those opposite-edge
pixels never match the adjacent wall's border, so every dome seam
showed up as a visible line — four vertical wall lines plus the top
cap formed a huge cube-outline in the sky.

Switch to GL_CLAMP_TO_EDGE for any sky submesh whose SkyObject has
TexVelocityX == TexVelocityY == 0 (i.e. not a scrolling cloud layer).
Scrolling clouds keep GL_REPEAT because their UV-offset accumulates
and needs seamless wrap. One-line heuristic on TexVelocity picks the
mode per-object per draw.

User confirmed both the cube-edges artifact and the "huge square in
the sky" artifact are gone after this change. Both were the same bug
— the square was the four wall seams forming a closed rectangle
across the view.
2026-04-24 20:42:59 +02:00
Erik
1d54880213 sky(phase-8): retail-faithful night sky + README refresh
Iteration on the sky rendering pipeline to restore stars/moon visibility
at night and fix washed-out grey daytime clouds. Key fixes:

* sky.frag: disable fog-mix on sky meshes. Retail's keyframe FogEnd
  (0..400m at midnight, up to 2400m during day) is calibrated for
  terrain; sky meshes are authored at radii 1050-14271m which sits
  past FogEnd universally, causing every sky pixel to saturate to
  fogColor (dark navy). Stars, moon, dome texture all got
  obliterated. The horizon-glow trade-off is noted in the shader
  comment; research item to find retail's sky-specific fog range
  later.

* SkyRenderer + sky.frag: promote rep.Luminosity into uEmissive so the
  vertex lighting saturates properly for bright keyframes. Retail's
  FUN_0059da60 non-luminous path writes rep.Luminosity into
  material.Emissive via the cache +0x3c slot; we were instead using
  it as a post-fragment multiply which could only dim, never brighten.
  Net effect: daytime clouds now render saturated white, dome dims
  correctly at night (rep.Luminosity=0.11 → Emissive=0.11), stars
  and moon unchanged.

* terrain.vert: MIN_FACTOR 0.08 -> 0.0 per retail FUN_00532440 decompile
  (DAT_00796344 ambient-floor = 0.0). Back-lit terrain now falls to
  pure ambient rather than getting an 8% sun floor.

New research / tooling (no runtime impact):

* docs/research/2026-04-24-lambert-brightness-split.md — retail's
  ambient-brightness formula pinned from PE .rdata read + live
  RetailTimeProbe capture: effAmbBright = AmbBright + |sunDir| * 0.2
  where scale constant 0x0079a1e8 = 0.2f exactly.

* docs/research/2026-04-23-lightning-real.md — research note on the
  dat-baked PhysicsScript-driven lightning path (Rainy DayGroup has
  explicit PES-triggered flash SkyObjects with 5ms time windows).

* Corrections stapled to sky-decompile-hunt-{B,C}.md: DAT_00842778 is
  DirColor, DAT_0084277c is AmbColor (the hunt docs had the swap
  backwards).

* tools/RetailTimeProbe/Program.cs: extended with pid=NNNN selector,
  sky global probe (DirColor/AmbColor/AmbBright/sunDir/cache.amb),
  and the 0x0079a1e8 scale-factor readout.

* tools/SkyObjectInspect/: throwaway dat-inspector built by the Opus
  deep-dive agent. Identified GfxObj 0x010015EF as the stars layer
  (A8R8G8B8 128x128 texture, 4% bright-pixel ratio).

* src/AcDream.App/Rendering/TextureCache.cs: per-texture alpha
  histogram dump under ACDREAM_DUMP_SKY=1 for diagnosing "are the
  clouds decoded with proper alpha" type questions.

README: rewrite to reflect current state (playable pre-alpha rendering
Dereth with animated characters, day-night cycle, weather, etc.)
instead of the stale "Phase 0 dat inventory only" description.

All 742 tests green.
2026-04-24 20:34:36 +02:00
Erik
e4cf3a9b6b weather(phase-5d): AdminEnvirons packet handler + thunder sound dispatch
Ports retail's AdminEnvirons (opcode 0xEA60) — the client-visible
weather-event channel distinct from the PlayScript path. Wire format
(chunk_006A0000.c: `[u32 opcode][u32 environChangeType]`).

EnvironChangeType range:
  0x00..0x06 — fog presets (Clear/Red/Blue/White/Green/Black/Black2)
  0x65..0x75 — one-shot ambient sounds (Roar, Bell, Chant, etc)
  0x76..0x7B — Thunder1..6 sounds (paired with a lightning PlayScript)

Dispatch:
  - WorldSession decodes the packet, fires EnvironChanged event.
  - GameWindow.OnEnvironChanged:
    * Fog values (0x00..0x06) → WeatherSystem.Override. The enum
      values line up byte-for-byte with our EnvironOverride enum
      (deliberately mirrored from retail), so a direct cast works.
    * Sound values (0x65..0x7B) → console log with retail name for
      now. Actual OpenAL playback needs a EnvironChangeType →
      WaveData lookup (indexed via SoundTable dat), which is a
      separate follow-up. The event still fires so any future
      audio subscriber can plug in.

Combined with Phase 6a-6c PhysicsScript/PlayScript wiring, the
complete retail lightning pipeline is now:

  server sends PlayScript(0xF754, lightningGuid, scriptId=0x33xxxxxx)
    → runs the flash script via PhysicsScriptRunner
    → CreateParticleHook spawns the flash particles
  server sends AdminEnvirons(0xEA60, Thunder3Sound=0x78)
    → OnEnvironChanged logs; audio binding TBD

Whether the user's ACE sends these packets depends on the server
(ACE 2.x vanilla does NOT — Agent #5 verified no lightning opcodes in
the default emit path). With the client port complete, any ACE mod
or extension that emits the right packets will Just Work in acdream.

Build + 742 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:27:13 +02:00
Erik
2e9a836f08 weather(phase-6bc): wire PlayScript packet + script runner into frame loop
Phase 6b — WorldSession now dispatches the PlayScript opcode (0xF754)
that retail uses for all server-triggered client-side visual effects.
Wire format per Agent #5 decompile (chunk_006A0000.c:12320-12336):

    [u32 opcode=0xF754][u32 targetGuid][u32 scriptId]

New event `PlayScriptReceived(uint guid, uint scriptId)` fires on
every matching fragment. Unknown payloads (body < 12 bytes) are
silently ignored.

Phase 6c — GameWindow instantiates a PhysicsScriptRunner at startup,
subscribes to PlayScriptReceived, and ticks the runner every frame
BEFORE the ParticleSystem tick so a CreateParticleHook fired this
frame gets its emitter integrated in the same frame.

Anchor policy: use the camera's world position for the script anchor.
For Dereth-wide storm effects (lightning flashes) the camera is the
right reference frame — the flash is "around the player." Per-entity
effects (spell casts, emotes) dedupe by (scriptId, entityId) so
multiple simultaneous plays on different guids work; a follow-up will
look up the guid's last-known world pos from _worldState for accurate
per-entity anchoring.

The full pipeline now for a lightning flash:
  1. ACE (or other retail-emulating server) sends
     GameMessage(0xF754, lightningGuid, scriptId=0x33xxxxxx).
  2. WorldSession parses: PlayScriptReceived event fires.
  3. GameWindow.OnPlayScriptReceived routes to _scriptRunner.Play.
  4. Runner loads the PhysicsScript from the dat, schedules every
     (StartTime, AnimationHook) entry.
  5. Per-frame Tick fires each hook at its scheduled time via
     ParticleHookSink — CreateParticleHook spawns a particle emitter
     (the flash), SoundHook plays thunder audio (Phase 5d), etc.

Set ACDREAM_DUMP_PLAYSCRIPT=1 to see each inbound PlayScript and each
hook fire as `[pes]` log lines — useful for identifying which script
IDs your ACE server actually sends.

Build + 742 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:24:30 +02:00
Erik
8a42750459 sky(phase-5b): port retail vertex fog onto sky meshes
Retail applies linear vertex fog with 3D range distance
(D3DRS_FOGVERTEXMODE=3=LINEAR, D3DRS_RANGEFOGENABLE=1,
D3DRS_FOGTABLEMODE=0=NONE) to ALL mesh draws including sky. Only
FOGCOLOR / FOGSTART / FOGEND are lerped per keyframe; the mode flags
are init-only.

Verified in `docs/research/2026-04-23-sky-fog.md`:
  - chunk_005A0000.c:3361-3389 device-init sets the modes.
  - Sky meshes render at world origin (translation zeroed, rotation-
    only) with intrinsic mesh radii in the thousands of meters
    (WorldBuilder's SkyboxRenderManager.cs:247 comment confirms).
  - With keyframe MaxWorldFog = 2400m, the dome saturates to
    WorldFogColor at its horizon band. THAT is retail's dusk/dawn
    horizon-glow mechanism.

Port:

`sky.vert` now computes the vertex fog factor:
    worldPos = uModel × aPos         (camera-centered since view translation=0)
    dist = length(worldPos.xyz)
    fogFactor = clamp((fogEnd - dist) / (fogEnd - fogStart), 0, 1)
  — outputs as varying vFogFactor. 1.0 means no fog contribution,
  0.0 means full fog color.

`sky.frag` applies the mix BEFORE the lightning-flash bump:
    rgb = mix(uFogColor.rgb, rgb, vFogFactor)

Uses the existing SceneLighting UBO's uFogParams (x=start, y=end,
z=flash, w=mode) and uFogColor — no new uniforms, no C# change.

Expected visual:
  - Dome at dawn/dusk: horizon band blends toward keyframe fogColor
    (warm orange at sunset, cool blue at dawn), matching retail's
    sky/fog coupling.
  - Close sky objects (sun disk at typical mesh radius): unaffected
    since dist < fogStart.
  - Clouds at intermediate distance: partial fog blend, subtly
    muting their saturation with distance.

Note on lightning: the flash channel (uFogParams.z) stays wired but
is currently always 0 because no code drives it. Agent #5 is
researching retail's real lightning mechanism (PlayScript / SetLight
PhysicsScript / other). This commit does not attempt to port it.

Build + 733 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:06:57 +02:00
Erik
2802fb2151 sky(phase-4b): clamp sky vTint at vertex + 1.0 fragment cap for retail parity
After Phase 4 landed the per-vertex lighting formula, user observed
acdream was still "a bit too bright" vs retail. Root cause:

- My Phase 4 shader deliberately left vTint unclamped so D3D-style
  overbright contributions to emissive meshes (dome has Emissive=1 → lit
  could reach 2.0 with ambient + sun) would clamp naturally at the
  framebuffer.
- But the frag cap was 1.2 (leaving "headroom for lightning flash"),
  letting dome vertices run 20% hotter than retail's per-channel 1.0.

Retail's D3D fixed-function pipeline clamps vertex lit colour at
D3DRS_COLORCLAMP=1 (default) BEFORE texture modulation. We now match:
- Clamp `vTint = clamp(lit, 0, 1)` in sky.vert so the saturate happens
  at the vertex stage, exactly like D3D.
- Drop normal-frame frag cap from 1.2 → 1.0 (the 3.0 flash relaxation
  stays so lightning strobes still visibly blow out).

Expected visual:
- Dome: identical appearance (was clamping to framebuffer 1.0 anyway),
  but pure retail-spec rendering so no sneaky 20% headroom.
- Clouds: unchanged (already < 1.0 at morning Rainy keyframe).
- Fragment flash during storm: unchanged — cap relaxes to 3.0 on flash.

Build + 733 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:41:58 +02:00
Erik
3a117bd91a sky(phase-4): retail-verbatim per-vertex lighting on sky meshes
Re-enables the Phase 2 lighting formula that was reverted in Phase 3b
due to a "blue-green-yellow sweep" across clouds. Root cause of that
earlier regression was NOT the formula — it was that we rolled the
wrong DayGroup (Sunny when retail was Cloudy), producing a sharp warm
sun against a sky that should have been rendered with diffuse
overcast light. After Phase 3g pinned the LCG multiplier to 360
(DaysPerYear) so retail + acdream agree on DayGroup, the same
per-vertex formula now faithfully reproduces retail's visuals.

The formula is verified in decompile agent Q2+Q4+Q6 results,
`docs/research/2026-04-23-sky-material-state.md`:

  D3DRS_LIGHTING = ON         (FUN_0059da60:10648)
  D3DRS_AMBIENT  = 0          (never written after init)
  Material.Emissive = (Luminosity, Luminosity, Luminosity, 1)
  Material.Ambient/Diffuse = defaults (≈1,1,1,1) for non-luminous
  light.Ambient = keyframe AmbColor × AmbBright (via SetDirectionalLight)
  light.Diffuse = keyframe DirColor × DirBright

Fixed-function lighting per vertex:
  lit = Emissive + Ambient × lightAmbient + Diffuse × lightDiffuse × max(N·L, 0)
      = Surface.Luminosity + AmbColor×AmbBright + DirColor×DirBright × max(N·L, 0)

Fragment: texture × lit × SkyObjectReplace.Luminosity.

Expected visual:
- Dome (Surface.Luminosity=1): `lit = 1 + amb + diff·N·L` saturates to 1
  → texture passthrough, baked gradient preserved.
- Clouds (Surface.Luminosity=0): `lit = 0 + amb + diff·N·L`
  → purple haze at night (ambient dominates, sun below horizon);
  → warm tan at dusk (ambient + warm sun on west-facing vertices);
  → pale cool gray at noon (ambient + white sun from above).
- Sun/moon (SurfaceType.Additive, Luminosity=1): same as dome +
  additive blend — stays bright regardless.

The shader uniforms (uAmbientColor, uSunColor, uSunDir, uEmissive)
were already wired in the C# renderer from Phase 2; Phase 3b just
stopped using them in the shader. This commit re-activates them.

No clamp at the vertex — retail's D3D lighting allows Emissive+sum
to exceed 1, relies on the framebuffer per-channel saturation. We
keep the 1.2 ceiling in the frag (for lightning flash overbright
headroom) consistent with that convention.

No fog yet (Q1 confirmed retail leaves fog enabled for sky; will add
in a follow-up if horizon looks too bright).

Build + 733 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:37:40 +02:00
Erik
1e1d3875f7 sky(phase-3g): fix LCG multiplier — 360 (DaysPerYear), not 7620
Ran a live memory probe against retail acclient.exe (new tool:
tools/RetailTimeProbe/) to read the TimeOfDay struct at
DAT_008ee9c8 and compare against our computed values. The decompile
agent's identification of TimeOfDay+0x10 as "SecondsPerDay (int
copy)" turned out to be WRONG — the live value is **360**, which is
GameTime.DaysPerYear.

The retail FUN_00501990 LCG seed is:
  seed = Year × (*+0x10) + DayOfYear
       = Year × DaysPerYear + DayOfYear
       = flat "total days since epoch" day-index

Our previous Phase 3c port passed 7620 (DayLength in ticks) as the
multiplier, producing seed=883,967 against retail's seed=41,807 —
completely different LCG outputs, completely different DayGroup
picks. That's why the user's retail kept showing stormy/rainy while
acdream showed sunny/clear (or vice versa) even after Phases 3c.1
and 3f aligned Year and DayOfYear.

Also confirmed by the probe:
  - EpochBase / ZeroTimeOfYear = 3600   ✓ Phase 3f already correct
  - BaseYear / ZeroYear = 10            ✓ DerethDateTime.ZeroYear
  - Year=116, DayOfYear=47              ✓ our AbsoluteYear / DayOfYear
  - SecondsPerDay float (+0x0C) = 7620  ✓ DayTicks
  - SecondsPerYear = 2,743,200          ✓ YearTicks

One "finding that's not a fix": retail's +0x48 DayFraction is a
sub-period fraction (fraction through current day/night window)
NOT a full-day fraction. CurDayEnd - CurDayStart = 2857.5 = 0.375
of a day = 6 Dereth hours = night duration. Not relevant for our
keyframe bracket interpolation, which correctly uses a full-day
0..1 scale matching the SkyTime.Begin values. Documented in the
probe research doc so future work doesn't trip on it.

Changes:
- tools/RetailTimeProbe/ — new P/Invoke tool. Forced x86 target to
  match retail's bitness so hardcoded DAT_xxxxxxxx addresses are
  pointer-width-correct. Handles ASLR relocation via
  Process.MainModule.BaseAddress.
- src/AcDream.App/Rendering/GameWindow.cs: RefreshSkyForCurrentDay
  passes 360 (DaysInAMonth × MonthsInAYear) not 7620.
- src/AcDream.Core/World/SkyDescLoader.cs: ActiveDayGroup(ticks)
  and DefaultDayGroup same.
- docs/research/2026-04-23-retail-memory-probe.md — full probe
  results + decompile-agent correction.
- AcDream.slnx — add tools/ folder.

Build + 733 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:17:38 +02:00
Erik
cd8a37a9c8 sky(phase-3f): anchor calendar to dat's GameTime.ZeroTimeOfYear
Final piece of the retail-sync puzzle. Live Dereth dat has
GameTime.ZeroTimeOfYear = 3600 (verified 2026-04-23 diagnostic dump).
Our DerethDateTime hardcoded +7/16 × DayTicks = 3333.75, copied from
ACE's DerethDateTime.cs comment "tick 0 = Morntide-and-Half". The dat
is authoritative; ACE's comment is wrong by 266.25 ticks (~33 Dereth
minutes).

User-observed regression (2026-04-23):
  acdream: middle-of-night (Darktide), clear, DayGroup "Sunny"
  retail:  near-pre-dawn (Foredawn), thunderstorm, stormy DayGroup
  (both connected to the same ACE at PortalYearTicks=291134079)

Same server tick → different calendar extraction → the offset skewed
dayFraction AND pushed DayOfYear across a boundary at certain ticks,
feeding a different LCG seed into the DayGroup picker (FUN_00501990).
A single 266.25-tick offset error explains both the time mismatch and
the weather mismatch.

Code changes:
- DerethDateTime.OriginOffsetTicks — runtime-settable static, default
  = DayFractionOriginOffsetTicks (3333.75, the legacy fallback).
  Applied in DayFraction, Year, DayOfYear, ToCalendar.
- DerethDateTime.SetOriginOffsetFromDat(double) — called at Region
  load.
- SkyDescLoader.DumpRegionSkyDesc dumps GameTime fields (and all 16
  TimesOfDay entries) when ACDREAM_DUMP_SKY=1.
- GameWindow.LoadRegion adopts the dat's ZeroTimeOfYear after
  LoadFromRegion, logs the before/after values.

Also dumps every Dereth TimeOfDay hour-boundary (0..15) so any future
calendar weirdness has authoritative ground truth in the log.

Build + 733 tests green (no test depended on the hardcoded offset).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:00:54 +02:00
Erik
5f9df4d620 sky(phase-3e): drive WeatherSystem from DayGroup name — no more rogue rain
User reported rain in acdream while retail showed a clear sunny sky
after Phase 3d landed. Root cause: two independent weather systems
running in parallel.

  1. Retail DayGroup picker (FUN_00501990 port, Phase 3c/3c.1) —
     selected DayGroup[6] "Sunny" correctly.
  2. WeatherSystem.Tick (legacy stub from pre-decompile era) —
     kept rolling its own hardcoded PDF every day (60% Clear, 20%
     Overcast, 12% Rain, 5% Snow, 3% Storm), independent of the
     DayGroup picker. Its output drove the rain/snow particle
     emitters via UpdateWeatherParticles. If its hash happened to
     land on Rain for today's dayIndex, rain rendered even on a
     Sunny DayGroup day.

Retail has ONE source of truth for weather: the DayGroup roll. There
is no separate weather state machine — rain/snow/storm are implied by
the DayGroup name and its per-keyframe SkyObjectReplace settings.

Fix (Phase 3e):
- WeatherSystem.SetKindFromDayGroupName(string?) — loose substring
  match on the retail DayGroup name: "storm" → Storm, "snow" → Snow,
  "rain" → Rain, "cloud"/"overcast"/"dark"/"fog" → Overcast, else
  Clear. Case-insensitive. Covers the names observed in the live
  Dereth dat dump (Sunny, Clear, Cloudy, Rainy + inferred variants).
- WeatherSystem._externallyDriven flag disables the internal
  RollKind auto-roll once SetKindFromDayGroupName has been called at
  least once. Tests that drive Tick() directly keep the legacy
  hash-roll behavior (offline fallback). ForceWeather still works
  for debug overrides.
- GameWindow.RefreshSkyForCurrentDay calls
  Weather.SetKindFromDayGroupName(grp.Name) right after it installs
  the new SkyStateProvider. Logs the resulting WeatherKind on the
  same line as the DayGroup pick for correlation.
- New WeatherSystemTests.SetKindFromDayGroupName_MapsRetailNames
  (theory, 14 cases) + SetKindFromDayGroupName_DisablesInternalRoll.

Expected effect: Sunny/Clear DayGroups → no rain emitter. Rainy/Stormy
DayGroups → rain emitter active. The user's specific scenario
(DayGroup[6] "Sunny") now correctly maps to WeatherKind.Clear and no
particles spawn.

Build + 733 tests green (+16 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:32:27 +02:00
Erik
75ad74e0b3 sky(phase-3d): fix time drift — WorldTime.TickSize is always 1.0
User reported the in-game sky time in acdream consistently trails
retail's, even after Phases 3a-3c.1 aligned the DayGroup selection.
Diagnosed it as a rate mismatch between our client-side extrapolation
and the ACE server's tick advancement.

ACE advances PortalYearTicks at 1.0 ticks per real-second:
  Timers.cs:  PortalYearTicks += worldTickTimer.Elapsed.TotalSeconds

Our WorldTime was using SkyDesc.TickSize (0.8 in the live Dereth dat)
as the extrapolation rate:
  NowTicks = lastSync + elapsed * TickSize   // with TickSize=0.8

Between the server's ~20s TimeSync gap, we fell 4 ticks behind. Every
sync yanked us back, but in the window between syncs the sky interp
was rendering at a stale (earlier) dayFraction — visible as "acdream
is behind retail" when the user had retail running alongside.

Root cause: we misread r12 §1.2's definition of SkyDesc.TickSize.
Agent C's decompile (`docs/research/2026-04-23-sky-decompile-hunt-C.md`
§5 and the 2026-04-23 sky-retail-verbatim synthesis §5) showed
SkyDesc.TickSize is consulted at `FUN_005062e0:6241` as:

  _DAT_00842798 = SkyDesc.TickSize + _DAT_008379a8   // next deadline

i.e. the per-frame sky-subsystem update PERIOD. It's a throttle, not a
clock-rate multiplier. SkyDesc.LightTickSize=15 plays the same role for
lighting interpolation (re-run every 15 real-seconds).

Fix: remove the SkyDesc.TickSize → WorldTime.TickSize assignment. Keep
WorldTime.TickSize at its default 1.0, matching the server's rate.
SkyDesc.TickSize stays on the LoadedSkyDesc for a future Phase 4 port
of the actual retail throttle logic.

Build + 717 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:25:03 +02:00
Erik
f466c337ce sky(phase-3c.1): feed AbsoluteYear (Year+ZeroYear) to retail LCG picker
Live verification (2026-04-23, Phase 3c launch): acdream picked
DayGroup[17] "Rainy" for PY106 day46 while retail at the same server
tick showed clear blue sky with white clouds (Sunny-ish). Root cause:
our port passed the RELATIVE year (106, i.e. years since tick-0) into
the LCG seed, while retail's TimeOfDay+0x64 is ABSOLUTE Year =
floor(...) + ZeroYear (baseYear=10 for Dereth GameTime). The offset
seeds the LCG with `seed = 106×7620+46` vs retail's `seed =
116×7620+46` — `10 × SecondsPerDay = 76200` apart, guaranteed to
land on a different DayGroup index.

Fix:
- DerethDateTime.ZeroYear constant (= 10) + AbsoluteYear(ticks) helper.
- GameWindow.RefreshSkyForCurrentDay feeds AbsoluteYear into the picker.
- LoadedSkyDesc.ActiveDayGroup(ticks) same.
- Calendar display and generic Year(ticks) stay relative; only the
  LCG-seed path uses the offset. Matches retail FUN_005a7510:5300 which
  explicitly adds baseYear to the relative year before stashing in
  TimeOfDay+0x64.

Build + 717 tests green. Next visual check should show matching
weather with retail client side-by-side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 08:51:42 +02:00
Erik
6ea87b7ea8 sky(phase-3c): port retail FUN_00501990 DayGroup picker (uniform LCG)
Decompile agent located the retail DayGroup selection function at
FUN_00501990 (chunk_00500000.c:1276). It is a straight-line 32-bit
signed LCG — NOT a ChanceOfOccur-weighted CDF. Replaces the SplitMix64
approximation from Phase 3a.

Algorithm (verbatim from the decompile):

  seed  = year * secondsPerDay + dayOfYear    // TimeOfDay+0x64/+0x10/+0x68
  hash  = seed * 0x6A42FDB2 + 0x8ABE1652      // signed 32-bit LCG
  index = floor(dayGroupCount * (uint)hash / 2^32)
  if (index >= dayGroupCount) index = 0       // float-rounding safety

Uniform over all DayGroups. Dereth's 20 groups all carry ChanceOfOccur=5.0
so uniform matches the statistical intent; the weighted walk Phase 3a
attempted is NOT what retail does. The SecondsPerDay multiplier is
load-bearing — without it, adjacent years would share adjacent LCG
seeds and divergence from retail would recur annually.

Result (this session's local ACE):
  server: PY106 ColdMeet 17 MorntideAndHalf, ticks=291130073
  → year=106, dayOfYear=(106×0 + 17 across ColdMeet) via DerethDateTime
  → retail picker returns a deterministic uniform index from LCG.
  Acdream and retail now agree on the pick for any (Year, DayOfYear)
  since both drive from the same server PortalYearTicks.

Changes:
- src/AcDream.Core/World/DerethDateTime.cs: add Year(ticks) and
  DayOfYear(ticks) helpers (match retail TimeOfDay+0x64 / +0x68).
- src/AcDream.Core/World/SkyDescLoader.cs:
  - SelectDayGroupIndex signature: (year, secondsPerDay, dayOfYear)
    instead of the flat dayIndex used by the SplitMix64 approximation.
  - Body: retail LCG line-by-line port with decompile citations.
  - ACDREAM_DAY_GROUP env var still overrides (for A/B verification).
- src/AcDream.App/Rendering/GameWindow.cs: RefreshSkyForCurrentDay now
  feeds Year / DayOfYear / SecondsPerDay=7620 to the picker instead
  of a flat dayIndex. Composite `year*360+dayOfYear` still tracked
  internally as the day-change key for provider-rebuild idempotence.
- docs/research/2026-04-23-daygroup-selection.md committed with the
  full decompile trail (new agent-produced research).

Build + 717 tests green. User visual verification (retail side-by-side)
next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 08:45:34 +02:00
Erik
027ccb46b9 sky(phase-3b): revert Phase 2 per-vertex lighting — sky meshes are UNLIT
Phase 2 added a per-vertex lighting path to the sky shader based on the
Phase 1 dump showing dome surfaces with Luminosity=1.0 and cloud
surfaces with Luminosity=0.0. Live visual verification vs retail at
MorntideAndHalf (dayFraction=0.48, user-observed 2026-04-23) disproved
the hypothesis:

  retail: clean blue sky + white clouds
  acdream: blue-green-yellow sky sweep + greyish clouds

The "sweep" is exactly the signature of per-vertex `diffuse × sunColor`
where sunColor=(250,215,151) warm gold at ~63° east: the west-facing
cloud faces get the gold tint, east-facing stay cool, and interpolation
across the mesh produces the color sweep. Retail's clean white clouds
at the same time of day means retail is NOT applying per-vertex lighting
to sky meshes.

Revised model (unlit + SkyObjectReplace modulation):
  fragment.rgb = texture.rgb * uLuminosity
  fragment.a   = texture.a   * (1 - uTransparency)

The "purple haze night / warm dusk" effect users describe from retail
comes from SkyObjectReplace per-keyframe Luminosity dimming + Transparent
fading, NOT from a shader ambient multiply. At midnight, for example,
Replace[0] dims the dome to 11% (Luminosity_raw=11) and Replace[2]
fully hides the drifting cloud (Transparent_raw=100) — so the camera
sees the dome texture at 11% × baked gradient colors, and any purple
the user perceives is baked into the dome texture's night gradient.

The retail-authoritative Surface.Luminosity flag probably feeds a
separate render path (material system? D3D emissive vs diffuse
coefficients?) that is NOT per-vertex GL lighting. A future phase can
revive it if the decompile hunt for the DayGroup selection algorithm
surfaces it.

Code change: sky.vert + sky.frag only. The C# renderer still pushes
uAmbientColor/uSunColor/uSunDir/uEmissive uniforms — they are declared
in the shaders but unused in Phase 3b. No renderer change needed; these
uniforms cost nothing and keep the port-forward path open.

Build + 717 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 08:42:11 +02:00
Erik
62e9c6b9ac sky(phase-3a): per-Dereth-day weather roll + ACDREAM_DAY_GROUP override
Diagnosed the "retail shows early night, acdream shows early day" time
mismatch: server time sync was working correctly (ticks=291079558 →
dayFraction=0.8546 → EvensongAndHalf, hour 14 of 16). The mismatch was
the hardcoded DayGroup index.

Dereth's SkyDesc carries 20 DayGroups (Sunny / Clear / Cloudy / Storm /
etc), each weighted at ChanceOfOccur=5.0. Retail rolls one per server
day by `ChanceOfOccur` as a PDF (r12 §11). We were always rendering
DayGroup 0 = "Sunny" regardless of day, so at EvensongAndHalf we showed
SkyTime[7]@0.84 — sun still 20° above the western horizon, warm golden
— i.e. pre-sunset rather than the dimmer pre-night appearance retail
shows after rolling a cloudier group.

Fix (Phase 3a):
- LoadedSkyDesc.SelectDayGroupIndex(dayIndex) — deterministic roller:
  SplitMix64 hash of dayIndex → normalize to [0, sumChances) → walk the
  cumulative distribution. Same dayIndex on every client = same weather
  on every client, zero network sync needed.
- LoadedSkyDesc.ActiveDayGroup(ticks) / BuildProviderForDay(ticks) —
  convenience wrappers that compute dayIndex from raw server ticks.
- ACDREAM_DAY_GROUP=<N> env var override. Set to 10 "Clear", 12 "Cloudy",
  etc. for A/B visual verification against retail.
- SyncFromServer gains a [sky-dump] log: `ticks=X dayFraction=Y
  calendar=PY{year} {month} {day} {hour}` so the time-sync state is
  auditable from a single grep.
- GameWindow: tracks _loadedSkyDayIndex + _activeDayGroup. Calls
  RefreshSkyForCurrentDay on every server sync — swaps WorldTime's
  provider + caches the group only when the day index crosses a
  boundary (idempotent within a single day). SkyRenderer.Render now
  consumes _activeDayGroup instead of the legacy DefaultDayGroup.

Observed (this session, local ACE):
  server sent ticks=291079558 → PY106 ColdMeet 10 EvensongAndHalf
  SplitMix64(day 38197) will deterministically pick one of 20 groups.

Build + 717 tests green. Ready for user visual verification with
retail side-by-side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 08:27:37 +02:00
Erik
aa2e20a42e sky(phase-2): retail-verbatim per-vertex lighting via Surface.Luminosity
Phase 2 of the sky port. Empirically confirmed from the Phase 1 dump
(ACDREAM_DUMP_SKY=1 on the live Dereth region): retail distinguishes
self-illuminated sky meshes from lit ones by the `Surface.Luminosity`
FLOAT field (0..1), NOT by the `SurfaceType.Luminous` flag bit (none of
Dereth's sky meshes have the flag set).

Observed values on the 4 currently-visible sky GfxObjs:

  GfxObj 0x010015EE (dome, 4 surfaces)    Luminosity = 1.0
  GfxObj 0x010015EF (upper cloud)         Luminosity = 0.0
  GfxObj 0x01004C36 (lower drift cloud)   Luminosity = 0.0
  GfxObj 0x01001348 (sun/moon additive)   Luminosity = 1.0

Retail uses this as an emissive coefficient in the per-vertex lighting
formula (decompiled chunk_00500000.c:7535 FUN_00508010 + chunk_00530000.c
AdjustPlanes per-vertex math):

  tint = clamp(vec3(Luminosity) + AmbColor*AmbBright
               + max(dot(N, -sunDir), 0) * DirColor*DirBright,
               0.0, 1.0)
  fragment = texture * tint

When Luminosity=1.0 the clamp saturates → full texture brightness
regardless of time of day (dome gradient preserved; sun/moon always
bright). When Luminosity=0.0 only the ambient + diffuse term drives the
tint, so clouds pick up the time-of-day ambient (purple at midnight
per AmbColor=(200,100,255)×AmbBright=0.4 ≈ (0.31,0.16,0.40); warm tan
at dusk; pale-cool at noon).

Also empirically confirmed: raw SkyObjectReplace Transparent/Luminosity
/MaxBright are in 0..100 percent range (observed 11, 15, 22, 66, 100,
and -1 sentinel). The `/100` divide in SkyDescLoader (eeae83a) is
retail-correct; `_DAT_007a1870` in the decompile must be 0.01f.

Code changes:
- src/AcDream.Core/Meshing/GfxObjSubMesh.cs: new `Luminosity` field on
  the per-submesh record (0..1, defaults to 0 for non-sky meshes).
- src/AcDream.Core/Meshing/GfxObjMesh.cs: pull Surface.Luminosity when
  building submeshes (alongside existing Translucency capture).
- src/AcDream.App/Rendering/Sky/SkyRenderer.cs:
  - SubMeshGpu gains SurfLuminosity, propagated from GfxObjSubMesh.
  - Render() pushes uAmbientColor/uSunColor/uSunDir once per frame from
    the interpolated keyframe; uEmissive once per submesh.
  - uTint uniform removed (replaced by the vTint varying computed in
    the vertex shader).
- src/AcDream.App/Rendering/Shaders/sky.vert: computes vTint per-vertex
  using the retail AdjustPlanes formula.
- src/AcDream.App/Rendering/Shaders/sky.frag: consumes vTint, drops
  uTint uniform. uLuminosity (the per-keyframe SkyObjectReplace
  override) still applied as a final scalar multiply.

Expected visual difference from Phase 1 baseline:
  - Dome gradient: IDENTICAL (Luminosity=1 saturates).
  - Sun / moon: IDENTICAL (Luminosity=1 saturates, additive blend).
  - Clouds: now tinted by time of day. Midnight → purple haze. Noon →
    pale cool. Dusk → warm tan.

Open questions (unchanged from Phase 1 doc):
  - Does the 15s LightTickSize throttling need porting? Phase 3.
  - Does FUN_00532440 (AdjustPlanes per-cell terrain relight) need
    porting for non-sky geometry to follow the sky? Phase 3.

Build + 717 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:19:22 +02:00
Erik
58afd4850f sky(phase-1): revert speculative tint, add ACDREAM_DUMP_SKY diagnostic
The uncommitted uTint=AmbientColor-for-alpha-submeshes experiment (from
the 2026-04-22 inference) dimmed the sky dome's baked gradient — a
user-verified visual regression. Reverting to the eeae83a baseline
(uTint=Vector4.One for every submesh) while we execute the proper
retail-verbatim port.

Research: three parallel decompile-hunt agents landed verifying
retail's ground-truth sky pipeline for the first time (prior audits
searched for stripped symbol names; the trail opened via the Region
dat-type-index 0x1c registration at chunk_00410000.c:12952). Key
retail functions now mapped in chunk_00500000.c:1097-7535:
  - FUN_00501530: keyframe bracket-picker (with 1.0f wrap denominator)
  - FUN_00501600: sun+ambient interpolator (sunVec = DirBright ×
                  (sin yaw·cos pit, cos yaw·cos pit, sin pit))
  - FUN_00501860: fog interpolator
  - FUN_00502820: SkyDesc::Unpack (2 doubles + DayGroup list)
  - FUN_00502a10: build per-frame sky-object table
  - FUN_00505f30: apply light state + per-cell AdjustPlanes relight
  - FUN_005062e0: per-frame sky tick (throttled by LightTickSize)
  - FUN_00508010: sky-object render loop (enqueues through the NORMAL
                  mesh pipeline via FUN_00514b90 — not a bespoke path)

Surprise findings:
  - D3DRS_AMBIENT is set to 0 once at init and NEVER changes per-frame
    (chunk_005A0000.c). The r12-inferred "clouds = texture × D3DRS_
    AMBIENT" formula is falsified. Retail instead routes keyframe
    AmbColor through per-vertex lighting on non-Luminous sky meshes
    via _DAT_008682bc/c0/c4.
  - Retail does NOT anchor the sky to the camera or use a separate
    sky projection. Sky meshes live in world space and follow the
    camera via scene-graph parent.
  - FUN_00532440 (AdjustPlanes) re-lights every terrain cell on every
    keyframe tick — the "terrain follows the sky" effect we don't yet
    reproduce.

Phase 1 code change (this commit):
  - src/AcDream.App/Rendering/Sky/SkyRenderer.cs: revert uTint to white
    for all submeshes (the per-submesh blend split stays — sun gets
    additive, clouds get alpha). Keep the `keyframe` parameter in the
    signature for Phase 2 readiness. Comments now cite the retail
    functions and reference docs instead of the (disproven) r12 formula.
  - src/AcDream.Core/World/SkyDescLoader.cs: ACDREAM_DUMP_SKY=1 logs
    the entire Region SkyDesc on load — DayGroups, SkyObjects, every
    SkyTimeOfDay keyframe, and every SkyObjectReplace with RAW pre-/100
    Transparent/Luminosity/MaxBright values so we can settle the unit
    question empirically.
  - src/AcDream.App/Rendering/Sky/SkyRenderer.cs: ACDREAM_DUMP_SKY=1
    additionally logs each sky GfxObj's Surfaces and their SurfaceType
    flags on first load, so we can identify which meshes carry the
    Luminous bit (dome? sun? moon? stars?) vs which are lit.
  - src/AcDream.App/Rendering/GameWindow.cs: passes the interpolated
    keyframe to the sky renderer (kept — needed for Phase 2).

Research docs (pushed as part of this commit):
  - docs/research/2026-04-23-sky-retail-verbatim.md: full synthesis
    with retail function map, struct layouts, globals, pseudocode, and
    a 4-phase port plan.
  - docs/research/2026-04-23-sky-decompile-hunt-{A,B,C}.md: raw hunt
    outputs.
  - docs/research/2026-04-23-sky-references-crossref.md: WorldBuilder/
    ACE/ACViewer/holtburger/Chorizite coverage.
  - docs/research/2026-04-23-sky-dat-schema.md: full dat schema + unit
    analysis.
  - docs/research/2026-04-22-sky-lighting-decompile.md: prior agent's
    (superseded) inference — kept for provenance.

Phase 2 will port Surface.Luminous-flag-aware per-vertex lighting for
sky submeshes once the dump resolves the open questions (Luminous-flag
distribution per Dereth sky mesh; _DAT_007a1870 scale constant value).

Build + 717 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:06:52 +02:00
Erik
eeae83a14e fix(sky): scale keyframe Luminosity/Transparent/MaxBright from percent → fraction
Retail's Region dat stores SkyObjectReplace.Luminosity / Transparent /
MaxBright as percentages in the 0..100 range. Our shader expects
fractions in 0..1. We were passing raw values (luminosity up to 100)
straight into the sky fragment shader's rgb-multiplier:

    rgb = sampled.rgb * uTint.rgb * uLuminosity;

At the "Sunny" DayGroup's noon keyframes (verified via live diag),
Luminosity = 100 → shader multiplied the cloud texture RGB by 100 →
min(rgb, vec3(1.2)) clamped all channels to 1.2 → pure white sky.

Also gave the dawn/dusk purple sky effect on top of the pale texture.

Fix: SkyDescLoader.ConvertTimeOfDay divides Luminosity, Transparent
and MaxBright by 100 when loading each SkyObjectReplace. The Rotate
field stays as degrees (values like 270° are genuine headings, not
percentages).

Transparent was accidentally surviving via a 0..1 clamp downstream,
but we fix it for consistency and so brightness-attenuating values
in the 0..99 range (partial fade during dawn/dusk) work correctly
instead of rounding to full-transparent.

WorldBuilder's SkyboxRenderManager does NOT apply these fields at
all — that's why they never hit this bug. Our port applies them for
per-keyframe day-night fades, so we needed the unit conversion.

Also picked up in this commit (incidental, already running):
 - Sky render: per-submesh blend mode from TranslucencyKind.Additive
   for sun/moon-style self-bright objects (Additive bit 0x10000).
   Luminous flag 0x40 intentionally NOT mapped to additive — that
   flag is on the sky dome + cloud sheets and making them additive
   produced the previous "fully white" iteration of this bug.
 - ToD default seed: DayTicks/16 (Midsong = hour 9 = true noon)
   instead of DayTicks*0.5 which landed on Gloaming-and-Half (sunset)
   due to DerethDateTime's +7/16 day-fraction offset. Pre-TimeSync
   view now correctly starts at noon.
 - Lightning flash: brighter white-blue (vec3(1.5,1.5,1.8)) instead
   of dim grey; ceiling relaxed during flash so the strobe actually
   blows out. Cadence (strike intervals, decay) unchanged.
 - Saved docs/research/2026-04-21-sky-deep-audit.md with the
   decompile+ACE+ACME+WorldBuilder research done to corner this bug.

Open follow-up (not fixed here): sky clouds are white at noon /
don't get the dusk/night purple tint. Our sky shader is fully unlit
— doesn't apply sun/ambient directional light like the terrain
shader does. AmbientColor in the keyframe data carries the right
tint (purple at midnight, magenta at dusk) but we pass
uTint = Vector4.One instead of the keyframe value. Next commit will
wire directional-sun + ambient into sky.frag so cloud meshes pick
up the time-of-day color.

All 717 tests green. User-confirmed: sky colors are now "much
better" after this change (previously fully white).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:38:44 +02:00
Erik
f562215e6c fix(physics): water depth submersion + sphere-safety-push steep-slope correction
Two coupled physics fixes that together resolve "+Acdream walks on top of
water instead of submerged" and "brief Falling animation when running up
steep hills".

## 1. Water depth = physics adjustment, not rendering

Retail has NO separate water surface mesh. Characters visually submerge
in water because ValidateWalkable adds `waterDepth` to its signed-distance
check (ACE ObjectInfo.cs:124), letting the character's feet sit below the
terrain plane by that amount before the push-up fires. Rendered character
below rendered terrain = looks submerged.

Our ValidateWalkable didn't carry a waterDepth, so feet were always
snapped exactly to the plane. Water cells looked like walking on water.

Added:
 - TerrainSurface now carries per-vertex water flags (bits 2-6 of
   TerrainInfo → SurfChar lookup) and per-cell classification.
 - TerrainSurface.SampleWaterDepth(localX, localY) returns 0.0 (dry),
   0.45 (partial-water near water corner), 0.9 (entirely water). Deviates
   from retail's 0.1 fallback for "dry corner of partial-water cell" —
   that 0.1 destabilizes the "feet exactly on plane" contact-touch check
   in ValidateWalkable (dist > EPSILON, SetContactPlane skipped,
   ValidateTransition clears OnWalkable, gravity applies, character
   micro-falls each frame).
 - PhysicsEngine.SampleWaterDepth is the world-space wrapper.
 - FindEnvCollisions samples the per-point depth and forwards it.
 - ValidateWalkable adds +waterDepth to the signed-distance check (this
   is the ACE-line-124 port).

GameWindow.ApplyLoadedTerrain extracts the low byte of each TerrainInfo
ushort and passes it to the TerrainSurface ctor so classification works.

## 2. AdjustOffset safety-push threshold on sloped planes

The LocalSphere is positioned at `(0, 0, radius)` — center along world
+Z from the character root. On a tilted plane the sphere center's
perpendicular distance to that plane is `radius * Normal.Z`, NOT
`radius`. The original threshold `dist < radius - EPS` therefore fires
spuriously on every slope and the follow-up push-up lifts feet by
`radius * (sec θ - 1)` — 7 cm at 30°, 20 cm at 45°, 48 cm at 60°.

The steep-slope lift is large enough to break ValidateWalkable's
contact-touch check, ValidateTransition then clears OnWalkable,
calc_acceleration applies gravity, and the character flickers into the
Falling animation for ~0.3s while running uphill. User-observed on steep
hills after today's water-depth work made the artifact visible (before
that, general hover masked it).

Fix: the threshold is `radius * Normal.Z` (the natural resting distance
of a Z-axis sphere on the plane). The push fires only when feet are
actually penetrating below natural resting, not on any sloped plane.
ACE's Transition.cs AdjustOffset has the original threshold but the bug
is invisible server-side.

All 717 tests green. Water submersion + steep-slope running both
user-visually verified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:56:46 +02:00
Erik
93cbabbc87 fix(physics): full retail per-frame chain for remote motion + persist ContactPlane across frames
Two linked issues both rooted in skipping parts of the retail physics chain.

## 1. Remote staircase on slopes — Euler never integrated between UPs

TickAnimations called rm.Body.update_object(now) for remote integration, but
PhysicsBody.update_object gates on MinQuantum = 1/30s (retail FUN_00515020
early-return). At our 60fps render tick (~16 ms), deltaTime < MinQuantum on
almost every frame → early return AND LastUpdateTime never advances → position
effectively never integrates. Remote Position changed only on UP hard-snap,
producing visible teleport strides uphill (the "staircase" the user reported).

Fix: call UpdatePhysicsInternal(dt) directly for the remote tick — the same
pattern PlayerMovementController.cs:358 uses for the local player. Wire
ResolveWithTransition in afterwards so the remote's Euler-advanced position
gets swept through the same retail collision chain (find_env_collisions +
find_obj_collisions + step_down + 6-path BSP dispatcher) that the local
player already goes through.

New field RemoteMotion.CellId tracks the remote's cell across frames; set
from UpdatePosition.p.LandblockId and updated from transition output.

## 2. Local player floating on downhill slopes — ContactPlane not persisted

Running a character down a slope faster than ~0.5 m/s vertical: per-frame
Euler moves feet horizontally (no Z component since velocity is world-XY).
After Euler, feet are above the new-XY terrain. ValidateWalkable takes the
"above surface" branch without setting a contact plane, DoStepDown probes
~4 cm down (the retail StepDownHeight default), fails to find the surface
8-10 cm below, and the character stays at the old Z. Over a sustained
descent this accumulates into a visible hover.

Retail's PhysicsObj carries ContactPlane + ContactPlaneCellID as persistent
fields (ACE PhysicsObj.cs:2598-2604 get_object_info → InitContactPlane).
Each transition call seeds CollisionInfo.ContactPlane from the previous
frame's plane. That seed is what lets AdjustOffset project horizontal
velocity onto the slope surface — so the Euler offset acquires a Z
component matching the slope and the sphere tracks terrain without needing
step-down to do the catch-up every frame.

Fix: add PhysicsBody.ContactPlane* fields mirroring PhysicsObj's. Extend
ResolveWithTransition with an optional `body` parameter; when provided, seed
the transition's CollisionInfo from body.ContactPlane at the start, copy
back (preferring current, falling back to LastKnown) at the end. Both local
(PlayerMovementController) and remote (TickAnimations) pass their body.

Verified live: DIAG samples showed pre/post/resolved Z all exactly equal
before the MinQuantum bypass (Euler frozen). After bypass, deltas dropped
to floating-point noise on slopes for remotes. Local hover on downhill
resolved in separate visual pass.

All 717 tests green. No API breaks (ResolveWithTransition's body param is
optional, backwards-compatible).

Cross-refs:
 - decompile: FUN_00515020 update_object, FUN_005111D0 UpdatePhysicsInternal,
   FUN_005148A0 transition init
 - ACE: PhysicsObj.cs:2586-2621 get_object_info, Transition.cs:613-620 InitContactPlane

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:55:59 +02:00
Erik
56975f8919 fix(terrain): align per-cell triangle geometry with ACE's ConstructPolygons convention
Our LandblockMesh, terrain.vert corner tables, and TerrainSurface.SampleZ
used the OPPOSITE diagonal for each CellSplitDirection enum value from
what ACE (and the decompiled retail client at FUN_00532a50) picks for the
same sign bit. Same formula, same sign-bit mapping, inverted geometry.

Symptom: remote players rendered at server-broadcast Z hovered or clipped
by up to ~1m on sloped cells. Flat cells masked the bug because all four
corner heights were equal so any triangle pair returned the same Z. Live
diagnostic confirmed +0.79m hover on cell (7,5) at lb(AA,B4) — a ~20°
slope — while flat neighbors agreed to floating-point noise.

Three coordinated edits so CPU mesh + GPU corner lookup + CPU sampler all
agree on the retail geometry:
 - LandblockMesh: SWtoNE branch now emits {BL,BR,TR}+{BL,TR,TL} (y=x cut),
   SEtoNW emits {BL,BR,TL}+{BR,TR,TL} (x+y=1 cut).
 - terrain.vert: corner-index tables updated to match.
 - TerrainSurface.SampleZ: swapped the two branches' interpolation.

After the fix, 19 live DIAG samples across flat + two slope transitions
all land within 0.01m of server Z. Staircase pattern during remote motion
on slopes is a separate bug (no per-frame collision resolution) and will
be addressed via the transition/FindValidPosition port.

Cross-verified against: ACE LandblockStruct.ConstructPolygons lines 221-
244, decompiled retail FUN_00532a50 (chunk_00530000.c:2235), ClientReference
IsSWtoNECut (tests/AcDream.Core.Tests/Terrain/ClientReference.cs).

Updated test SplitDirection_TerrainSurface_AgreesWith_TerrainBlending
with corrected expectations (Z values swap between the two branches).
All 717 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:20:59 +02:00
Erik
340dabbc72 feat(anim): full retail remote-entity motion port — walk/run/strafe/turn/stop
Ports the retail client's client-side remote-entity motion pipeline
verbatim per the decompile research. Every remote now runs its own
PhysicsBody + MotionInterpreter + AnimationSequencer stack — retail has
no special "interpolator" for remotes, it runs the full motion state
machine on every entity. Now we do too.

## What changed

### Parser fixes (CreateObject, UpdateMotion)
Wire flag bits for InterpretedMotionState (per ACE MovementStateFlag enum):
  CurrentStyle=0x01, ForwardCommand=0x02, ForwardSpeed=0x04,
  SideStepCommand=0x08, SideStepSpeed=0x10, TurnCommand=0x20, TurnSpeed=0x40

Previously we only extracted CurrentStyle + ForwardCommand + ForwardSpeed
and SKIPPED the side/turn fields entirely. Result: we had zero rotation-
or strafe-intent data from the server — impossible to render turn or
sidestep animations. Now ServerMotionState carries all 7 fields and the
parser reads the bytes in ACE's write order (style, fwd, side, turn, then
fwdSpd, sideSpd, turnSpd).

### RemoteMotion (new per-remote struct in GameWindow)
Each remote gets its own PhysicsBody + MotionInterpreter + observed
angular velocity. Replaces the earlier shortcut RemoteInterpolator
(deleted — retail has no such thing).

On UpdateMotion:
  - ForwardCommand flag absent → stop signal (reset to Ready) per
    retail FUN_0051F260 bulk-copy semantics (absent = Invalid = default).
  - Forward + sidestep + turn each route through DoInterpretedMotion,
    exactly as retail FUN_00528F70 does.
  - Animation cycle selection: forward wins if active, else sidestep,
    else turn, else Ready. Matches the user's observation that retail
    plays turn animation when only turning.
  - Turn command seeds ObservedOmega = π/2 × turnSpeed (from Humanoid
    MotionData.Omega.Z ≈ π/2 per decompile).
  - Turn absent → ObservedOmega = 0 (stops rotation immediately).

On UpdatePosition:
  - Hard-snap Body.Position + Body.Orientation per retail FUN_00514b90
    set_frame (direct assignment, no slerp — retail does not soft-snap).
  - HasVelocity + |v| < 0.2 → StopCompletely + SetCycle(Ready).
  - ForwardSpeed=0 on wire is a VALID stop signal (ACE sends this when
    alt releases W); previously we defaulted to 1.0, causing the "slow
    walk that never stops" symptom.

Per-tick:
  - apply_current_movement → Body.Velocity via get_state_velocity
    (retail FUN_00528960: RunAnimSpeed × ForwardSpeed in body-local,
    rotated by orientation).
  - Manual omega integration: Orientation *= quat(ObservedOmega × dt).
    Bypasses PhysicsBody.update_object's MinQuantum=1/30s gate that
    was eating every-other-tick rotation updates at our 60fps render
    rate — the cause of the persistent "rotation snaps every UP" bug.
  - update_object still called for position integration and the motion
    subsystem it drives.

### AnimationSequencer synthesis extension
Added omega synthesis for TurnRight/TurnLeft cycles (same pattern as
the earlier velocity synthesis): when the Humanoid dat leaves HasOmega
clear, SetCycle synthesizes CurrentOmega = ±π/2 × speedMod on Z so
dead-reckoning and stop detection can read a non-zero omega for turn
cycles.

### Stop-detection heuristic removed
No more 300ms/2000ms/5000ms idle timers. Retail's stop signal is
explicit (UpdateMotion with ForwardCommand flag absent → Ready); we
handle it directly. Client-side timers were a source of flicker during
normal running.

## Confirmed working
- Walking (matches retail speed + leg cadence)
- Running (matches retail speed + leg cadence)
- Strafing (body moves sideways + strafe animation plays)
- Turning while stationary (body rotates smoothly + turn animation plays)
- Turning while running (body rotates + leg anim continues)
- Stopping (instant stop, no slow-walk tail)

All 717 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:26:23 +02:00
Erik
00c8a4feb5 fix(anim): stop ACE echo from clobbering player's speedMod; synthesize sequencer velocity
Two related bugs in the motion/animation pipeline:

1. Player's local animation was getting reset to speedMod=1.0 every ~100ms.
   ACE's BroadcastMovement echoes the player's own motion back via
   UpdateMotion. When ACE's ForwardSpeed == 1.0, the ForwardSpeed flag is
   omitted (InterpretedMotionState.BuildMovementFlags), so our wire parser
   returns null and we default to speedMod=1.0 — clobbering the
   locally-authoritative 2.375 × runRate that UpdatePlayerAnimation just
   set. Legs would crank up to full cadence for one frame then get slammed
   back to walking rate.

   Fix: for the player's own guid, skip the wire-echo SetCycle entirely.
   UpdatePlayerAnimation is the authoritative driver for the local
   player's animation; the server echo is only useful for observers of
   other characters. User-confirmed: legs now hold their full cadence.

2. Remote entities teleported between UpdatePositions because the
   sequencer's CurrentVelocity was always zero (Humanoid dat ships every
   locomotion MotionData with Flags=0x00, so EnqueueMotionData leaves
   CurrentVelocity at Vector3.Zero). Dead-reckoning's Priority 1
   (sequencer velocity) never triggered, falling through to EMA which
   has bootstrap lag + gets polluted by teleport-class server snaps.

   Fix: synthesize CurrentVelocity in SetCycle from the retail locomotion
   constants (WalkAnimSpeed=3.12, RunAnimSpeed=4.0, SidestepAnimSpeed=1.25)
   × speedMod, matching the decompiled get_state_velocity (FUN_00528960)
   which uses these same constants directly instead of MotionData.Velocity.
   The dat's HasVelocity field is reserved for non-locomotion motions
   (kick-off velocities, flying creatures, etc).

   Diag confirmed synthesis fires and DR picks it up with src=seq and
   correct magnitude. More visual polish may still be needed for the
   "lagging remote" symptom — see follow-up.

Also adds `PlayerMovementController.BodyVelocity` utility getter for HUD/
debug use, and `ACDREAM_ANIM_SPEED_SCALE` env var as a tunable knob for
visual pacing overrides.

All 717 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:22:35 +02:00
Erik
795d9c8a88 fix(anim): physics velocity now sourced from MotionData — option B / r03 §1.3
The decompiled get_state_velocity (FUN_00528960) literally computes
`RunAnimSpeed * ForwardSpeed` — a 4.0 × runRate world velocity. That
matches retail only when the character's MotionTable happens to bake
MotionData.Velocity.Y = 4.0 on RunForward (true for Humanoid, not
necessarily for other creatures or swapped weapon-style cycles).

When MotionData.Velocity ≠ RunAnimSpeed, the body's world velocity
drifts away from the animation's baked-in root-motion velocity, and
you see the classic "legs cycle too slowly for how fast the body is
sliding" visual bug. User reports ~30% discrepancy ("running animation
is too slow"), consistent with Humanoid RunForward's actual dat
Velocity being ~3.0 rather than the 4.0 constant.

The fix per r03 §1.3: physics body velocity = MotionData.Velocity ×
speedMod. That's exactly what AnimationSequencer.CurrentVelocity
already exposes. Route it into MotionInterpreter via an opt-in
Func<Vector3> accessor. When wired, get_state_velocity uses the
sequencer's cycle velocity as the primary forward-axis drive; when
unwired (tests, physics bodies without a sequencer), falls back to
the decompiled constant path — byte-compatible with retail on the
shapes where it actually matters.

The RunAnimSpeed × rate max-speed clamp at the bottom of
FUN_00528960 stays intact — Option B only replaces the *drive*, not
the clamp. 20 m/s phantom MotionData can't teleport the player.

Wiring: GameWindow attaches `playerAE.Sequencer.CurrentVelocity` to
`_playerController` on Tab-player-mode entry. The sequencer is always
built before the player enters chase mode, so timing is safe.

Sidestep continues to use SidestepAnimSpeed — the sequencer only
tracks the current forward cycle, so strafe is a separate axis.

6 new MotionInterpreterTests verify: accessor overrides constant path,
zero Y falls back to constant (link transitions), clamp still applies,
Ready state doesn't leak accessor value, sidestep axis is untouched.

All 717 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:06:08 +02:00
Erik
5b84b0785d fix(anim): 3 motion bugs — remote anim dropped, walk->run not resent, wrong class byte
Bug 1: remote chars never animate, just teleport.
Root cause: when OnLiveMotionUpdated transitions a remote entity from
Ready to a locomotion cycle (cmd=0x0007 RunForward), the
_remoteLastMove timestamp is still pegged to the last position update
from BEFORE the motion change (often >300ms old). On the very next
TickAnimations, stop-detection signal 1 immediately fires
(now - last.Time > 300ms), and the sequencer is flipped straight back
to Ready. Result: the run cycle flashes for one frame and is gone.
Fix: when we enter a locomotion cycle from a non-locomotion one, stamp
_remoteLastMove[guid].Time = now and drState.LastServerPosTime = now
so the stop-timer starts a fresh 300ms window from the transition.

Bug 2 + 3: Our own player's walk/run toggle not broadcast when only
Shift toggles mid-move.
Root cause: PlayerMovementController's motion-state-change detection
compared only (ForwardCommand, SidestepCommand, TurnCommand). When
the user walks (W) then adds Shift mid-stride, ForwardCommand stays
WalkForward but outForwardSpeed jumps 1.0 -> runRate and localAnimCmd
swaps Walk -> Run. 'changed' stayed false, no MoveToState broadcast,
server still thought we were walking. Retail observers saw walking.
Fix: extend the diff to include outForwardSpeed, input.Run (hold-key),
and localAnimCmd. Any of them flipping forces a new MoveToState.

Bug 4: Wrong MotionCommand class byte reconstruction.
Root cause: OnLiveMotionUpdated's heuristic OR'd the sequencer's
current-motion class byte with the wire-received low 16 bits, producing
values like 0x41000007 for RunForward (actual retail value is
0x44000007). Cycle key lookup uses only low 24 bits so the animation
mostly-worked, but the wrong class byte broke stance-aware code paths
and any downstream consumer that keys off the class.
Fix: route ForwardCommand through MotionCommandResolver.ReconstructFullCommand
(same path already used for Commands[] items) — retail-exact class
byte recovery via a reflection-built enum lookup table.

Build + 711 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:40:26 +02:00
Erik
187226f504 fix(render): shader reserved-word + defensive SkyRenderer dat reads
Two runtime blockers discovered after merging the sky/weather/lighting
branch:

1. GLSL reserved word: mesh.frag + mesh_instanced.frag used \`int active\`
   as a local. On GLSL ES / some drivers \`active\` is a reserved identifier
   and compile fails hard (\"ERROR: 0:38: 'active' : Reserved word\").
   Renamed to \`activeLights\`.

2. SkyRenderer.EnsureMeshUploaded called DatCollection.Get<GfxObj>
   without the _datLock that wraps the streaming pipeline's dat reads.
   DatBinReader has shared buffer state; concurrent reads race and
   throw ArgumentOutOfRangeException from Vec2Duv.Unpack deep in the
   mesh parse. Wrapped both Get<GfxObj> and GfxObjMesh.Build in
   try/catch and cache a null entry on failure so we don't retry every
   frame and crash the render loop. Full fix would plumb _datLock into
   the sky renderer, left as a TODO.

Client now stable end-to-end — in-world, spawn stream flowing,
animation + audio + sky + light UBO all live.

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