Two should-fix items from the pre-merge code review pass:
1. Persisted settings now apply on startup unconditionally
(previously gated on ACDREAM_DEVTOOLS=1).
2. Music + Ambient volume sliders are hidden because the
underlying engine paths don't exist yet (R5 MIDI playback).
== 1. Settings load + apply outside DevToolsEnabled gate ==
Previous structure put SettingsStore construction, LoadDisplay /
LoadAudio / etc, and ApplyDisplayWindowState inside the
`if (DevToolsEnabled)` block. A user running with the env var unset
silently got WindowOptions defaults (1280x720 / VSync=false /
60° FOV) instead of their saved settings.json values — even though
the settings file existed and was valid.
Refactored: extracted LoadAndApplyPersistedSettings() that runs
unconditionally in OnLoad after _audioEngine is constructed but
before the DevToolsEnabled block. Persisted values cached as
_persistedDisplay / _persistedAudio / _persistedGameplay /
_persistedChat / _persistedCharacter fields. The Settings PANEL
construction (devtools-gated, naturally — no UI without ImGui) now
reads those fields when wiring SettingsVM.
The Settings UI gating is correct (panel needs ImGui devtools);
the persisted-runtime-state gating was the bug.
== 2. Music + Ambient sliders hidden ==
OpenAlAudioEngine has Music/MusicVolume/Ambient/AmbientVolume
properties but they're never read — PlayMusic is a stub for R5 MIDI
playback that hasn't shipped, StartAmbient reserves a handle but
doesn't start a source. Dragging those sliders moved a number that
nothing observed.
Hid the Music + Ambient sliders from RenderAudioTab; left the
AudioSettings record fields intact so settings.json round-trips
the values across phases — when R5 lands and the sliders return,
saved values will already be in place. Updated the panel's footer
note to call out the limitation. Updated
Audio_tab_when_active_renders_implemented_volume_sliders to assert
Master + SFX are present AND Music + Ambient are absent.
dotnet build green; dotnet test 1,309 / 1,309 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The InvisibleButton drag-trap inside BeginChild only catches clicks
inside that specific child. Chat had widgets OUTSIDE the inner
##chattail child (the Copy-mode Checkbox + a Separator at top, the
footer Separator + InputTextSubmit at bottom) — empty space around
those widgets fell through directly to the parent window's
window-drag init.
Fix: wrap the entire chat panel body in a single outer ##chatbody
BeginChild before drawing any content. The renderer's drag-trap
fires inside this outer child too, absorbing every empty-space
click in the chat panel body. The inner ##chattail child is now
nested inside it, which doesn't change its scroll-tail semantics
but does mean it gets its own drag-trap as a bonus.
Test fixed: Render_BeginChild_ReservesNegativeFooterFromFrameHeight
was using Single(BeginChild) — there are now two BeginChild calls
(##chatbody outer + ##chattail inner). Switched to Single(... &&
Args[0] == "##chattail") so the test still pins the footer reserve
on the inner call where it lives.
dotnet build green; 1,309 / 1,309 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported wanting to mark text in-game and copy it out (item names,
coordinates, NPC dialogue, etc). ImGui doesn't natively let you select
across multiple TextColored widgets, but a read-only multi-line
InputText is fully click-drag selectable + Ctrl+C copyable. This
commit adds a "Copy mode" toggle to ChatPanel that swaps the chat
tail's render path between the colored-line view and a single
selectable text region.
New IPanelRenderer primitive:
void TextMultilineReadOnly(string id, string content, Vector2 size);
ImGui maps this to InputTextMultiline with the ReadOnly flag — same
selection + Ctrl+C UX a user expects from any text-input widget.
FakePanelRenderer records the call for tests. The future D.2b
custom retail-look backend implements its own equivalent (likely
the same widget pattern with retail font/skin).
ChatPanel rendering:
· A "Copy mode (select text to Ctrl+C)" Checkbox at the top of
the panel toggles _copyMode.
· Off (default) — current per-line render with colored combat
entries. Visually unchanged from before.
· On — the chat tail becomes a single TextMultilineReadOnly
widget holding every visible line joined with newlines. Loses
per-line color, gains arbitrary-span text selection.
· Footer (separator + input field) renders identically in both
modes so the user can still type while in copy mode.
Existing ChatPanelLayoutTests's footer-separator probe was using
IndexOf("Separator") — which now matches the new pre-tail separator
between the Checkbox and the chat tail. Switched to LastIndexOf
which still pins the footer separator (between EndChild and
InputTextSubmit). Behaviour and intent unchanged.
DisplaySettingsTests' With_expression test was still asserting the
old "1920x1080" Default.Resolution; updated to the new "1280x720"
that the previous wire-up commit introduced (the earlier commit
forgot this one).
dotnet build green (0 warnings); dotnet test 1,309 / 1,309 green
(243 Core.Net + 393 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase L.0 polish — the Display + Character tabs were persisting to disk
but didn't yet drive runtime behavior. This commit flips the live
switches.
DISPLAY ↔ GL window:
· FOV slider (degrees) → camera FovY (radians) on Orbit + Fly + Chase,
pushed every frame so dragging is visible immediately. Brainstorm
said FOV is a live-preview slider; this delivers it.
· VSync → _window.VSync, change-detected per-frame so flipping the
checkbox is instant. Applied at startup too so saved-VSync takes
effect before the first frame.
· Resolution → _window.Size on Save (TryParseResolution parses
"WIDTHxHEIGHT"). Live preview would be too jarring; resize is on
Save only.
· Fullscreen → _window.WindowState (Silk.NET borderless mode), also
on Save only.
· ShowFps → wraps the title-bar perf string. true → full perf line;
false → just "acdream" for a cleaner alt-tab. Default true matches
pre-L.0 behavior.
Defaults rebalanced — FieldOfView 75→60° (matches Orbit/Fly/Chase
FovY = π/3), VSync true→false (matches the previous WindowOptions),
ShowFps false→true (preserves the existing perf-in-title behavior).
Net effect: a user who never opens Display tab + later opens it +
Saves without touching anything sees ZERO visual change. Tests pinned
to the new defaults.
ApplyDisplayWindowState helper consolidates the window-side
mutations. Called from the SettingsVM construction site (apply
persisted at startup) and from the onSaveDisplay callback (apply
saved on demand). Malformed resolution strings are silently ignored
to avoid crashing mid-session if settings.json gets hand-edited.
CHARACTER ↔ active toon:
· _activeToonKey field replaces the hard-coded "default" — starts as
"default" (used for any pre-login Settings interaction), gets
swapped to the actual character.Name immediately after EnterWorld
in BeginLiveSessionAsync.
· onSaveCharacter callback closes over _activeToonKey by reference
(lambda captures `this`), so saves always write to the current
toon's slot without rebinding the lambda.
· After EnterWorld lands the chosen toon's name, the host loads
that toon's bag via SettingsStore.LoadCharacter and calls a new
SettingsVM.LoadCharacterContext to swap BOTH persisted snapshot
AND draft atomically — HasUnsavedChanges stays false on login so
the user doesn't see a "pending changes" indicator just because
they switched toons.
Per-toon storage already worked at the SettingsStore layer (commit
73749d1); this commit just plumbs the actual character name through
to the toonKey instead of always using "default".
2 new tests for LoadCharacterContext: atomic persisted+draft swap,
and pending edits getting wiped on swap (so pre-login bleed-through
can't write to the new toon's slot).
dotnet build green (0 warnings); dotnet test 1,309 / 1,309 green
(243 Core.Net + 393 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase L.0 (final) — last tab on the Settings shell. Per-toon
preferences keyed by toon name in settings.json under
character[<toonName>]. With this commit the L.0 build order
finishes and every approved tab is implemented.
CharacterSettings record (4 fields):
· DefaultChatChannel (string — Local / Allegiance / Fellowship / etc)
· AutoAttack (bool — continue swinging until target dies)
· ConfirmSalvage (bool — prompt before salvaging valuable items)
· ShowPickupMessages (bool — pickup lines in chat)
AvailableChannels static list exposes the 7 retail-routing targets
for the dropdown.
SettingsStore grows LoadCharacter(toonKey) / SaveCharacter(toonKey)
using JsonNode/JsonObject for the nested-toon write — the existing
SaveSection raw-text-preservation pattern handles top-level keys
but doesn't fit the nested per-toon mutation. The character map
preserves every other toon's settings on save, and other top-level
sections (display / audio / gameplay / chat) are preserved too.
SettingsVM grows the parallel character state machine. The host
owns the toonKey (currently hard-coded to "default" in GameWindow
because we don't have a current-character source plumbed yet) —
the VM just edits whatever bag the host loaded.
SettingsPanel.RenderCharacterTab replaces the L.0-shell placeholder
— a Combo for default chat channel + 3 Checkboxes for
AutoAttack / ConfirmSalvage / ShowPickupMessages. The
RenderPlaceholder helper is now removed (no callers); the old
"Placeholder_tabs_render_coming_soon_text_when_active" test is
replaced by an "all six tabs are implemented" guard test that
fails if any future commit adds a placeholder back.
GameWindow loads/saves character settings under toonKey "default"
with a TODO comment to swap in the real toon name once
CharacterList plumbing exposes a currentCharacter source.
18 new tests:
· CharacterSettings record (4) — defaults pinned, AvailableChannels
list shape, value equality, with-expressions
· SettingsStore character (6) — missing-file / toon-not-in-file →
defaults, round-trip, multi-toon preservation, preserves other
top-level sections, all five sections coexist
· SettingsVM character (5) — initial draft, SetCharacter marks
dirty, Save invokes callback, Cancel reverts, ResetAllToDefaults
covers
· SettingsPanel character tab (3 net, after removing the
placeholder test) — combo+checkboxes render only when active,
channel combo uses AvailableChannels, all six tabs are now
non-placeholder
Phase L.0 final tally:
· 5 commits on feature/settings-retail (shell + 5 tabs)
· 6 tabs: Keybinds (Phase K) + Display + Audio + Gameplay + Chat + Character
· 5 settings sections in settings.json (display/audio/gameplay/chat/character),
coexisting non-destructively + a sixth file (keybinds.json) on the side.
dotnet build green (0 warnings); dotnet test 1,307 / 1,307 green
(243 Core.Net + 391 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase L.0 (cont.) — fourth tab on the Settings shell. Mixes retail's
CharacterOptions2 chat-channel filter bits (Hear*Chat / TimeStamp /
FilterLanguage / AppearOffline) with a font-size slider that has no
retail bitfield equivalent.
ChatSettings record (9 fields):
· 5 channel filters: HearGeneralChat, HearTradeChat, HearLFGChat,
HearRoleplayChat, HearSocietyChat
· 3 display flags: ShowTimestamps, FilterProfanity, AppearOffline
· 1 visual: FontSize (10..20 pt)
Local-only this phase per the brainstorm — Hear*Chat flags affect
client-side display filtering only; the server still streams every
channel. Server-sync arrives later when the protocol round-trip is
in place.
SettingsStore grows LoadChat / SaveChat using the existing generic
SaveSection helper. All four non-keybind sections (display, audio,
gameplay, chat) now coexist non-destructively in settings.json.
SettingsVM grows the parallel chat state machine. HasUnsavedChanges,
Save, Cancel, ResetAllToDefaults all cover chat. Constructor signature
adds two more params; existing call sites updated.
SettingsPanel.RenderChatTab replaces the L.0-shell placeholder —
8 Checkbox calls grouped under "Channel filters" + "Display"
headers, plus a font-size SliderFloat. The "Coming soon" placeholder
test was retargeted from "Chat" to "Character" since Chat is no
longer a placeholder.
GameWindow wires SettingsStore.LoadChat / SaveChat + a TODO comment
for the future ChatPanel filter integration (read SettingsVM.ChatDraft
when filtering inbound chat lines).
13 new tests:
· ChatSettings record (3) — defaults pinned, value equality, with-
expressions
· SettingsStore chat (3) — missing-file → defaults, round-trip, all
four sections coexist
· SettingsVM chat (5) — initial draft, SetChat marks dirty, Save
invokes callback, Cancel reverts, ResetAllToDefaults covers
· SettingsPanel chat tab (2) — checkboxes + slider render only when
active
dotnet build green (0 warnings); dotnet test 1,289 / 1,289 green
(243 Core.Net + 373 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase L.0 (cont.) — third tab on the Settings shell, in the Easy-wins
build order. Subset of retail's CharacterOption + CharacterOptions2
bitfield flags ported as bools (see acclient.h:3404+ enum). Local-
only this phase per the brainstorm — server sync deferred to a
later phase that will marshal the draft into the retail
CharacterOption packet.
GameplaySettings record exposes 14 named flags grouped by usage:
· Combat: AutoTarget, AutoRepeatAttack, ToggleRun, AdvancedCombatUI,
VividTargetingIndicator
· Display: ShowTooltips, SideBySideVitals, CoordinatesOnRadar,
SpellDuration, ShowHelm, ShowCloak
· Interface: AllowGive, LockUI, UseMouseTurning
Retail names + bit values are documented in field-level comments so
the future server-sync phase has a 1:1 mapping. Defaults are
typical-user starting points (NOT bit-exact to retail's
0x50C4A54A / 0x948700 masks); class-level remarks call out that
defaults will be re-anchored to retail values once the wire-format
is the load-bearing source.
SettingsStore grows LoadGameplay / SaveGameplay using the existing
SaveSection generic helper (added in the audio commit). All three
non-keybind sections (display, audio, gameplay) now coexist in
settings.json with non-destructive cross-section saves — verified
by a new "all three sections coexist" round-trip test.
SettingsVM grows the parallel gameplay state machine
(gameplayPersisted / gameplayDraft / SetGameplay / onSaveGameplay).
HasUnsavedChanges, Save, Cancel, ResetAllToDefaults all cover
gameplay too. Constructor signature adds two more params; existing
call sites (App startup + tests) updated.
SettingsPanel.RenderGameplayTab replaces the L.0-shell placeholder —
14 Checkbox calls grouped under three Text+Separator headers, plus
a footer note explaining the local-only-this-phase scope. The
"Coming soon" placeholder test was retargeted from "Gameplay" to
"Chat" since Gameplay is no longer a placeholder.
GameWindow construction site loads gameplay on startup + writes via
the SettingsStore on Save. Server-sync packet wiring is left as a
TODO comment in the onSaveGameplay callback (next phase, after the
protocol round-trip is in place).
14 new tests:
· GameplaySettings record (3) — defaults pinned, value equality,
with-expressions
· SettingsStore gameplay (4) — missing-file → defaults, round-trip,
partial-file fallback, all-three-sections coexist
· SettingsVM gameplay (5) — initial draft, SetGameplay marks dirty,
Save invokes callback, Cancel reverts, ResetAllToDefaults covers
· SettingsPanel gameplay tab (2) — 8 spot-checked Checkboxes render
only when active
dotnet build green (0 warnings); dotnet test 1,276 / 1,276 green
(243 Core.Net + 360 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase L.0 (cont.) — second tab on the Settings shell, in the Easy-wins
build order. Audio is the live-preview poster child: dragging a slider
is audible immediately, Save persists, Cancel reverts and the engine
catches up on the next frame.
AudioSettings record: Master / Music / Sfx / Ambient (all 0..1 floats).
Defaults match the OpenAlAudioEngine constructor values exactly so a
user who never opens the tab gets identical behaviour to the
pre-Phase-L env-var-only world (Master=1.0, Music=0.7, Sfx=1.0,
Ambient=0.8).
SettingsStore grows LoadAudio / SaveAudio + a generic SaveSection
helper that consolidates the unknown-top-level-key preservation logic.
Display and Audio sections coexist in settings.json:
{ "version": 1, "display": { ... }, "audio": { ... } }
Saving one section preserves the other on disk; a future Gameplay /
Chat / Character section drops in the same way without touching
existing data.
SettingsVM gains a parallel audio state machine (audioPersisted /
audioDraft / SetAudio / onSaveAudio callback). HasUnsavedChanges
covers all three buckets now (keybinds + display + audio); Save /
Cancel / ResetAll are atomic across all of them.
GameWindow wiring is the live-preview mechanism — every render frame
pushes the VM's AudioDraft into _audioEngine.MasterVolume etc. Cheap
(four float assignments) and unconditional. SetListener still applies
MasterVolume each frame too via the existing Phase E.2 code path, so
listener gain stays in sync. Persisted audio is applied to the engine
ONCE at startup before the first frame so the user's saved values
take effect before any sound plays — startup-time apply happens during
the same SettingsVM construction site that does the LoadDisplay +
LoadAudio.
SettingsPanel.RenderAudioTab replaces the L.0-shell placeholder — four
SliderFloat calls clamped to [0, 1], plus a footer note explaining the
live-preview UX. The "Coming soon" placeholder test was retargeted
from "Audio" to "Gameplay" since Audio is no longer a placeholder.
16 new tests:
· AudioSettings record (3) — defaults pin engine constants, value
equality, with-expressions
· SettingsStore audio round-trip (5) — missing-file → defaults,
round-trip all fields, partial-file per-field fallback, save-audio-
preserves-display, save-display-preserves-audio
· SettingsVM audio state (5) — initial draft tracks persisted,
SetAudio marks dirty, Save invokes audio callback, Cancel reverts,
ResetAllToDefaults covers audio
· SettingsPanel audio tab (3) — four sliders render only when active,
no SliderFloat emitted on inactive tabs, slider range is [0, 1]
dotnet build green (0 warnings); dotnet test 1,262 / 1,262 green
(243 Core.Net + 346 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase L.0 (cont.) — first concrete tab on the new Settings shell, in
the Easy-wins build order agreed in the brainstorm
(Display → Audio → Gameplay → Chat → Character).
DisplaySettings (immutable record): Resolution / Fullscreen / VSync /
FieldOfView (30-120°) / Gamma (0.5-2.0) / ShowFps. Six common 16:9
resolutions in the dropdown. Defaults: 1920×1080, windowed, vsync on,
75° FOV, gamma 1.0, FPS off — matches the brainstorm UX agreement.
SettingsStore: JSON persistence at %LOCALAPPDATA%\acdream\settings.json
(coexists with keybinds.json — own load/save path stays put, no
migration needed). LoadDisplay falls back per-field when keys are
missing (partial-file tolerant) and falls back to defaults when the
file is corrupt or the JSON is unparseable. SaveDisplay round-trips
preserved — unknown top-level keys (e.g. an `audio` section written
by a future client) are kept on save so older builds don't silently
drop newer-tab data.
SettingsVM gains a parallel display-state machine: persistedDisplay +
draftDisplay, SetDisplay mutator, HasUnsavedChanges checks both
keybinds and display deltas, Save/Cancel/ResetAll cover both
atomically from the user's POV (one Save commits everything, one
Cancel reverts everything). Constructor signature extends with two
new params; existing keybinds-only callers updated.
SettingsPanel.RenderDisplayTab replaces the L.0-shell placeholder —
Combo for resolution, Checkboxes for fullscreen/vsync/show-fps,
SliderFloat for FOV + gamma. Live-preview note in the panel body
matches the agreed UX: FOV + gamma update visibly while the user
drags; resolution / fullscreen / vsync apply on Save (live preview
would be too jarring).
GameWindow wires SettingsStore into the existing SettingsVM construct
site — load on startup, save on each tab Save. Errors print to
console and don't crash the panel.
19 new tests:
· DisplaySettings record (4) — defaults pinned, value equality, with-
expressions, AvailableResolutions sorted ascending
· SettingsStore (6) — round trip, missing-file → defaults, corrupt-
file → defaults, partial-file → per-field fallback, unknown-key
preservation, DefaultPath shape
· SettingsVM display (6) — initial draft tracks persisted, SetDisplay
marks dirty, Save invokes display callback, Cancel reverts,
ResetAllToDefaults covers display, Save-then-Cancel is no-op
· SettingsPanel display tab (3) — widgets render only when active,
resolution combo uses AvailableResolutions, no Combo emitted on
inactive tabs
dotnet build green (0 warnings); dotnet test 1,246 / 1,246 green
(243 Core.Net + 330 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase L.0 — foundation for the complete retail-style Settings interface
agreed in the 2026-04-26 brainstorm. Splits Phase K's keybind-only F11
panel into a tabbed shell whose first tab wraps the existing keybinds
content unchanged; the other five tabs (Display / Audio / Gameplay /
Chat / Character) render "Coming soon" placeholders so the shape the
user approved is visible immediately and gets filled in over the L.x
sub-phases (Display first per Easy-wins build order).
Why a tab API extension: retail had distinct Options UIs
(gmGameplayOptionsUI / gmChatOptionsUI / gmCharacterSettingsUI per the
PDB at acclient_2013_pseudo_c.txt:170739+) and the existing
IPanelRenderer only exposed CollapsingHeader. ImGui maps
BeginTabBar / BeginTabItem / EndTabItem / EndTabBar 1:1, so the new
primitives stay backend-friendly — the future D.2b custom retail-look
backend implements them via the retail tab UIs without panel changes.
Save / Cancel / Reset-all stay above the tab bar so they remain global
across all tabs (Phase K's UX preserved). FakePanelRenderer grows
matching tab calls + an ActiveTabLabel knob so tests can target a
specific tab's content; default behavior treats the first tab item
seen as active so existing tests keep passing without changes.
5 new SettingsPanelTests assertions: tab bar opens once, six expected
tab labels emitted in order, Keybinds-tab section headers only render
when active, placeholders show "Coming soon" text on inactive-content
tabs, and Save/Cancel buttons render BEFORE the tab bar (regression
guard against accidentally moving them inside a tab item).
dotnet build green (0 warnings); dotnet test 1,227 / 1,227 green
(243 Core.Net + 311 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four issues from the K-fix2 launch (2026-04-26 user report):
1. Can't return from free-fly to player view.
CameraController.ToggleFly only swaps Fly↔Orbit, so a user who
flew out of player mode landed in orbit (Holtburg) on
toggle-back instead of the chase camera. Added
ToggleFlyOrChase() helper that prefers Fly→Chase /
Chase→Fly when _playerMode is true and a chase camera is
available; falls back to the original Fly↔Orbit toggle for
offline / pre-login flows. Wired into all three free-fly
entry points: keyboard shortcut (Ctrl+Shift+F), Camera menu
item, and DebugPanel button.
2. Shift while moving STOPS instead of dropping to walk.
Root cause: InputDispatcher.IsChordHeld required
_keyboard.CurrentModifiers to match chord.Modifiers EXACTLY.
So with W bound as (W, None), holding W and then pressing
Shift made CurrentModifiers=Shift mismatch chord (None) →
IsActionHeld(MovementForward) returned false → Forward flag
dropped → player stopped. Fixed by relaxing IsChordHeld:
when chord.Modifiers is None, Shift is allowed to coexist
(it's the retail walk-modifier). Other modifiers
(Ctrl, Alt, Win) still mismatch strictly so Ctrl+W stays a
distinct chord from W.
+2 tests pinning the new permissive-Shift / strict-Ctrl
semantics.
3. Backwards too slow when running.
forwardCmdSpeed for the WalkBackward branch was hardcoded
to 1.0; localY was hardcoded to -(WalkAnimSpeed * 0.65).
Neither honored input.Run. With Run=true (default),
backward now scales by runRate (~2.4×) so X = "run
backwards" matches the forward run pace × the 0.65
backward animation cycle ratio.
4. Strafe too slow when running.
localX for SideStepLeft / SideStepRight was hardcoded to
±SidestepAnimSpeed regardless of Run. Same fix: when Run
is held, scale by runRate so strafe at default speed
matches the run-forward pace.
Tests: 1220 → 1222 (the two new IsChordHeld tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase K final commit. Settings panel with click-to-rebind UX on top of
the K.1+K.2 input architecture, plus the roadmap / ISSUES / memory
updates that retire Phase K.
InputDispatcher gains BeginCapture / CancelCapture / IsCapturing /
SetBindings — modal capture suppresses normal action firing for the
next chord. Esc cancels (returns sentinel default chord); modifier-only
keys don't complete capture; non-modifier key down with current
modifier mask completes.
IPanelRenderer + ImGuiPanelRenderer + FakePanelRenderer gain
BeginMainMenuBar / EndMainMenuBar / BeginMenu / EndMenu / MenuItem
primitives.
SettingsVM owns a draft copy of KeyBindings with explicit Save /
Cancel / Reset semantics. Click-to-rebind enters dispatcher capture
mode; on chord captured, conflict-detect against draft (excluding the
action being rebound itself); surface a ConflictPrompt when the chord
collides; ResolveConflict(replace=true|false) commits or reverts.
ResetActionToDefault restores a single action to RetailDefaults();
ResetAllToDefaults rebuilds the entire draft. Save invokes the
onSave callback (which writes JSON + swaps the live dispatcher's
bindings).
SettingsPanel renders 8 retail-keymap-categorized CollapsingHeader
sections (Movement, Postures, Camera, Combat, UI panels, Chat,
Hotbar, Emotes). Per action: name + current binding(s) summary +
"Rebind"/"Reset" buttons. Conflict prompt at the top when pending.
Save / Cancel / "Reset all to retail defaults" at the top.
GameWindow registers SettingsPanel + wires F11 →
ToggleOptionsPanel → IsVisible toggle, plus a top-of-frame ImGui
MainMenuBar with View → Settings/Vitals/Chat/Debug entries (calls
ImGui directly — the abstraction methods exist for backend
portability but the host doesn't own a menu-bar surface).
Tests: +37 across InputDispatcherCaptureTests (7),
IPanelRendererMainMenuBarTests (9), SettingsVMTests (13),
SettingsPanelTests (8). Solution total 1220 green.
Roadmap (docs/plans/2026-04-11-roadmap.md) appends Phase K shipped
section after Phase J with K.1a–K.3 commit SHAs. ISSUES.md files
Phase L deferred work as #L.1–#L.8 (hotbar UI, spellbook favorites,
combat-mode dispatch, F-key panels, floating chat windows, UI layout
save/load, joystick bindings, plugin input subscription) and adds
#21–#25 to Recently closed. project_input_pipeline.md updated to
shipped state. CLAUDE.md gets an input-pipeline reference.
Closes Phase K.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five changes:
1. PlayerModeAutoEntry — testable guard class that fires once after
EnterWorld + WorldSession.State.InWorld + player entity present +
PlayerController.State == InWorld. GameWindow arms the entry
after EnterWorld; per-frame Tick checks all four guards and
invokes the same fly-to-player transition the Tab handler runs.
User-initiated fly toggle (DebugPanel button) Cancel()s pending
entry. Skip in offline mode (no ACDREAM_LIVE) — Holtburg orbit
stays default for testing.
2. MouseLookState + KeyBindings.RetailDefaults() binds MMB Hold to
InputAction.CameraInstantMouseLook. GameWindow subscribes:
- Press: hide cursor, capture position, _mouseLookActive = true.
- Release: restore cursor, deactivate.
- WantCaptureMouse=true while held → suspend (release cursor).
- MouseMove while active: combined drive — chase camera yaw +
character heading move together (retail's signature mouse-look
behavior). Camera Y still pitches camera-only.
3. DebugPanel "Toggle Free-Fly Mode" button via DebugVM.ToggleFlyMode
action delegate — replaces the F-key as the primary discovery
path for free-fly. Gated on DevToolsEnabled.
4. ChatPanel.FocusInput() one-shot + IPanelRenderer.SetKeyboardFocusHere
primitive. GameWindow's ToggleChatEntry (Tab) subscriber calls
_chatPanel.FocusInput() so Tab moves focus to the chat input
field. Replaces the K.1c TODO stub.
5. WantCaptureMouse gating reinforcement on surviving mouse handlers
(no new code; verified intact from K.1b).
21 new tests (8 PlayerModeAutoEntry, 10 MouseLookState, 3 ChatPanel
focus). 1183 total green. 0 warnings, 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
User reported typing /ls (a command-style request, not chat) gets
echoed by the server as "You say, \"/ls\"". Slash-prefix is a
COMMAND surface, never a chat surface. Filed after the same flow
that produced @help and the welcome-message work.
Behavior change at the ChatPanel submit layer:
- Any /-prefixed input whose verb isn't in our alias tables now
renders a local "[System] Unknown command: /foo. Type /help for
the list." line and is NEVER published to the bus. No SendChatCmd,
no Talk packet. The server never sees /foo.
- Known /-verbs (/say /tell /reply /retell /general /allegiance
/patron /vassals /monarch /covassals /fellowship /lookingforgroup
/trade /roleplay /society /olthoi /help /clear /framerate /loc
and friends) still flow through ChatInputParser.Parse → SendChatCmd
exactly as before.
- @-prefix unchanged: ACE's CommandManager handles unknown @ verbs
server-side and replies via SystemChat ("Unknown command: foo")
per ACE GameActionTalk.cs:21. Our @ -> / normalization for known
verbs (Phase J Tier 1) and the @-passthrough fallthrough for
unknown verbs both still apply.
ChatInputParser now exposes:
- IsKnownVerb(string verb): query against the union of every alias
table. Used by ChatPanel to discriminate "unknown verb" from
"known verb with bad args".
- GetVerbToken(string command): public alias of the existing
ExtractVerb so callers can pull the first whitespace token without
reproducing the helper.
Parse itself is unchanged — its existing fall-through (Say with
literal text) still applies for unknown /-verbs called directly via
the parser, but ChatPanel intercepts before reaching that path so
the fall-through never fires through the live submit pipeline. Tests
that directly call Parse continue to pass; the new ChatPanel-level
tests pin the unknown-command rejection.
19 new tests:
- ChatInputParserTests: 10 IsKnownVerb Theory cases + 4 GetVerbToken
Theory cases.
- ChatPanelInputTests: 5 Theory cases for Submit_UnknownSlashCommand
covering /foo, /ls, /mp <path>, /genio, and bare /.
Solution total: 1086 green (243 Core.Net + 183 UI + 660 Core),
0 warnings.
Acceptance: type /ls, /mp /path, /anything-not-known — see local
"[System] Unknown command: /xxx. Type /help for the list of
supported commands." Nothing reaches the wire.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported the chat input field disappearing when the chat
window was resized smaller — older entries pushed it past the
visible area. Standard ImGui chat-window pattern fixes it: scrollable
nested region for the chat tail, fixed footer for the
separator + input field below it.
IPanelRenderer extensions (Phase J Tier 3):
- BeginChild(string id, Vector2 size, bool border = false) — opens
a nested scrollable region. Size follows ImGui semantics:
0 = fill available, negative = fill available minus this much.
- EndChild() — closes the nested region.
- FrameHeightWithSpacing() — single-line widget height incl. frame
padding + item spacing. Lets panels compute footer reservations
without hardcoding pixel constants.
- SetScrollHereY(float ratio) — forces scroll within current region;
pass 1.0f to keep the latest line visible after new entries
arrive.
ImGuiPanelRenderer impls. ImGui.NET's BeginChild signature changed
across versions (third arg moved from `bool border` to
`ImGuiChildFlags`); we cast a numeric literal (0x01 = Border bit)
to sidestep the rename. FrameHeightWithSpacing maps to
ImGui.GetFrameHeightWithSpacing(); SetScrollHereY to ImGui.SetScrollHereY.
ChatPanel restructured:
- Reserves footer height = FrameHeightWithSpacing() + 6f (small pad
for the separator above the input).
- Wraps the chat tail in BeginChild("##chattail", (0, -footer))
so the inner region scrolls independently of the window.
- Tracks _lastRenderedCount across frames and calls SetScrollHereY(1f)
only when new entries appended — manual scroll-up isn't fought
against; new messages jump the view back down only when they
actually arrive.
- Header Separator removed (the BeginChild border is enough).
FakePanelRenderer extended with the four new methods + recording.
4 new tests in ChatPanelLayoutTests pin the layout invariants:
- Render order: Begin → BeginChild → ... → EndChild → Separator
→ InputTextSubmit → End.
- BeginChild size has X=0 + negative Y at least matching the
injected FrameHeightWithSpacingValue.
- SetScrollHereY fires when entries grow.
- SetScrollHereY does NOT fire when entries don't grow.
Solution total: 1067 green (243 Core.Net + 164 UI + 660 Core),
0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Phase J follow-up after a 2026-04-25 trace where typing /help
produced two identical "Unknown command: help" lines (ACE fires the
text via both GameMessageSystemChat 0xF7E0 and a paired
CommunicationTransientString 0x02EB), and the server's WeenieError
0x0026 trailer rendered cryptically as "WeenieError 0x0026".
Three small changes:
1. WeenieErrorMessages: add 0x0026 ThatIsNotAValidCommand ->
"That is not a valid command." Plus 0x0414 / 0x050F that Phase J
already added are now covered by tests too.
2. ChatLog.OnSystemMessage dedup. Track last system text + arrival
time; if a second identical text shows up within 1 second,
suppress. ACE's two-path send (gag warnings, command errors,
etc.) collapses to a single chat line. Long bursts of repeated
text still skip the duplicates without resetting the timer.
3. Client-side /help and /clear in ChatPanel. Intercepted BEFORE
the parser passes to the server bus:
- /help, /?, /h (case-insensitive) -> render local cheat-sheet
listing acdream's slash prefixes via ChatLog.OnSystemMessage.
Avoids the round-trip to ACE that produced the duplicate
"Unknown command: help" lines AND gives users discoverability.
- /clear, /cls -> drains the chat log so the panel starts empty.
New ChatVM.ShowSystemMessage() + ChatVM.Clear() expose the
minimum surface the panel needs to dispatch client-only feedback
without coupling the panel to ChatLog directly.
12 new tests:
- 3 WeenieErrorMessages template adds (0x0026 / 0x0414 / 0x050F).
- 4 ChatLog dedup cases (immediate dup, different text, triplet,
bookended-by-different-text).
- 5 ChatPanel client-command cases (/help, 3 alias variants,
/clear).
Solution total: 1033 green (243 Core.Net + 130 UI + 660 Core),
0 warnings.
Acceptance: type /help in chat -> local help banner appears, no
server round-trip, no "Unknown command: help" duplicates. Type
/clear -> chat tail empty. Welcome banner + WeenieError-templated
"You are not in an allegiance!" / "You do not belong to a
Fellowship." continue rendering once each.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
Adds 14 widget signatures to IPanelRenderer + ImGuiPanelRenderer impl:
TextColored, CollapsingHeader, TreeNode/TreePop, Checkbox, Button,
Combo, SliderFloat, PlotLines, BeginTable/TableNextColumn/EndTable,
InputTextSubmit (Enter-key submit), Spacing, Dummy, TextWrapped.
InputTextSubmit uses ImGuiInputTextFlags.EnterReturnsTrue and clears
the buffer + emits via `out submitted` on the frame Enter is pressed.
PlotLines passes `ref values[0]` with empty-array guard. CollapsingHeader
defaultOpen=true uses ImGuiTreeNodeFlags.DefaultOpen (= 0x20).
FakePanelRenderer test double records (Method, Args) tuples and
exposes knobs to drive ref/out values. 17 new tests dispatch through
IPanelRenderer (not the concrete fake) so tests fail to compile when
the interface itself lacks a method - real RED -> GREEN signal.
Tests: 26 -> 43 in UI.Abstractions.Tests. Total solution 881 green.
Foundation for Phase I.2 (DebugPanel) and I.4 (ChatPanel input field).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three fixes to the Vitals HUD path:
1. EnchantmentMask Vitae/Cooldown bit values (parser regression).
ACE's enum at references/ACE/Source/ACE.Entity/Enum/EnchantmentCategory.cs
has Vitae=0x4 and Cooldown=0x8. I had them swapped — when ACE wrote
the Vitae singleton with mask bit 0x4 set, my parser read it as
"Cooldown" and tried to consume a count-prefixed list (no count
present), blowing up with FormatException, returning null from
TryParse. PlayerDescription consequently failed to parse on every
live login. Fix: swap the bit values + bucket constants to match ACE.
2. Vitae applies regardless of StatModKey. Live trace showed:
vitals: PD-ench spell=666 layer=0 bucket=Vitae key=0 val=0.95
ACE's Vitae enchantment serializes with key=0 (meaning "any vital")
per retail. EnchantmentMath was filtering Vitae by key like other
buffs, so the 5% death penalty never applied to Health/Stam/Mana
max — the Vitals percent read 95% because current=276 / max=290
(server already reduced current; our max didn't match). Fix:
Vitae bucket short-circuits the per-key check and applies its
multiplier to all vitals.
3. Absolute current/max in HUD overlay. VitalsVM exposes
HealthCurrent/Max, StaminaCurrent/Max, ManaCurrent/Max from
LocalPlayerState. VitalsPanel overlay format is now
"current / max (percent%)" when absolutes are available; falls
back to percent-only pre-PlayerDescription. Matches the retail
look the user requested ("HP 400/400" style).
Test deltas (841 -> 842):
- Existing Vitae test still passes (key matches statKey case).
- New Vitae key=0 test pins the "any vital" semantics.
- Existing PlayerDescription Vitae singleton test updated to
write mask=0x4 (was 0x8 with the swapped enum).
Live verification: with +Acdream's Vitae-666 active and Endurance.current=290:
HP : current=138, max=145×0.95≈138 → bar 100% (was 95%)
Stam : current=276, max=290×0.95≈276 → bar 100%
Mana : current=190, max=200×0.95≈190 → bar 100%
Overlay reads e.g. "276 / 276 (100%)".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends PlayerDescriptionParser past the spell block to parse the
Enchantment trailer per holtburger events.rs:462-501 +
magic/types.rs:40. New EnchantmentEntry record carries the full
60-64 byte wire payload:
u16 spell_id, layer, spell_category, has_spell_set_id
u32 power_level
f64 start_time, duration
u32 caster_guid
f32 degrade_modifier, degrade_limit
f64 last_time_degraded
u32 stat_mod_type, stat_mod_key
f32 stat_mod_value
[u32 spell_set_id]?
+ EnchantmentBucket (Multiplicative / Additive / Cooldown / Vitae)
EnchantmentMask outer u32 selects which buckets follow; each bucket
(except Vitae) is u32 count + N records. Vitae is a singleton.
Parsed.Enchantments now exposed as IReadOnlyList<EnchantmentEntry>.
GameEventWiring routes each entry through Spellbook.OnEnchantmentAdded
with the full StatMod data + bucket. EnchantmentMath.GetMod consumes
StatMod records to produce real (Multiplier, Additive) per stat key:
Bucket 1 (Multiplicative): multiplier *= val
Bucket 2 (Additive): additive += val
Bucket 8 (Vitae): multiplier *= val (applied last)
Bucket 4 (Cooldown): skipped (not a vital mod)
ActiveEnchantmentRecord extended with optional StatModType /
StatModKey / StatModValue / Bucket fields. Existing 4-arg callers
stay compatible (defaults to null / 0). New OnEnchantmentAdded
overload accepts the full record from PlayerDescription path.
Tests: 7 new (834 -> 841):
- PlayerDescriptionParserTests (2): enchantment block schema with
multiplicative + additive buckets, Vitae singleton.
- EnchantmentMathTests (5): multiplicative buffs aggregate, additive
buffs sum, stat-key mismatch filters out, Vitae applied
multiplicatively, family-stacking picks higher spell-id.
Closes#7 (parser past spells, enchantment block parsed).
Closes#12 (StatMod flow architecture — data lights up #6's
aggregator). Files #13 (remaining trailer sections: options /
shortcuts / hotbars / desired_comps / spellbook_filters / options2 /
gameplay_options / inventory / equipped — needs the heuristic
gameplay_options walker per holtburger).
Note: ParseMagicUpdateEnchantment (live-update 0x02C2) NOT yet
extended — still uses 4-field summary. PlayerDescription is the
load-bearing path for #6; live updates can be folded in separately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
Files four new issues created by the 2026-04-25 PDB-discovery sprint:
#8 (DONE 2026-04-25) — pdb-extract tool, shipped 69d884a#9 (OPEN) — function-map address-correction sweep
(Phase E will close)
#10 (DONE 2026-04-25) — wire KillerNotification (0x01AD); orphan
parser at GameEvents.ParseKillerNotification
existed but was never registered. This commit
adds CombatState.OnKillerNotification +
KillLanded event, registers the dispatcher
handler, and adds a regression test.
#11 (OPEN) — spell metadata loader (spells.csv → SpellTable)
(Phase F will close)
Code change is minimal — three lines of dispatch + a 12-line
CombatState method with a typed event for future killfeed UI.
818 tests passing (+1 KillerNotification).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
Covers the pure-logic surface of commit 1:
VitalsVMTests
* HealthPercent reads from CombatState.GetHealthPercent
* safe-default 1.0 when GUID unknown / not yet set / never updated
* SetLocalPlayerGuid reroutes lookup to the new guid (no stale cache)
* StaminaPercent / ManaPercent are null for D.2a scope
* ctor throws ArgumentNullException on null CombatState
PanelContextTests
* record-struct fields round-trip
* value-based equality
NullCommandBusTests
* Publish accepts any record type without throwing
* Instance is a true singleton
csproj template mirrors AcDream.Core.Tests (xUnit 2.9.3, Test.Sdk 17.14.1,
runner 3.1.4, coverlet 6.0.4, implicit Using Xunit). References only
AcDream.UI.Abstractions — no runtime / GL dependency, tests run fast.
Final pre-decompile-era invention cleanup. Snapshot() now returns
the keyframe's fog (color, start, end) directly in all cases.
AdminEnvirons override replaces fog COLOR only; distances stay at
the keyframe's MinWorldFog/MaxWorldFog.
Removed:
- FogForKind(kind, kf): the per-WeatherKind fog table with
invented constants (Overcast 40-150m grey, Storm 25-90m dark,
Rain 40-150m blue, Snow 60-200m white). Retail has no such
logic — Agent #3's decompile scan found zero per-Kind fog
manipulation in chunk_005* / chunk_006*. The SkyTimeOfDay
keyframe interp (FUN_00501860) does all fog value selection.
- OvercastFogStart/End, StormFogStart/End constants.
- Storm-kind random lightning timer + _strikeJitter. Retail's
lightning is server-driven via PlayScript (Phase 6), not a
client timer — Agents #3 + #5 both rule this out.
- Per-Kind cross-fade (_transitionT and TransitionSeconds-based
lerp). Retail has a different crossfade — SkyTimeOfDay step
blending via LightTickSize gating (_DAT_008427b8 + _DAT_007c7208)
— which is the deferred Phase 5c "polish" item.
Result:
- Clear: keyframe fog passthrough — unchanged behaviour.
- Overcast / Rain / Snow / Storm: now ALSO keyframe passthrough.
Previously these clobbered the keyframe with the invented
constants, producing a grey-wall sky that extended no further
than ~150m. User observation 2026-04-23: "retail sky extends
all the way into the horizon, we cap at a grey wall." Fixed.
- EnvironOverride (AdminEnvirons RedFog, BlueFog, etc):
substitutes the fog COLOR preset, keeps keyframe distances.
WeatherKind enum retained as purely informational (debug overlay,
telemetry). Internal RollKind fallback retained for offline tests
that drive Tick() directly without SetKindFromDayGroupName.
TriggerFlash()/flash decay retained as a test-only hook for the
UBO's lightning-flash channel — production flash stays 0 since
retail drives lightning visuals through particle emitters, not
through a UBO uniform.
Tests updated: `Transition_EasesAcrossTenSeconds` deleted (codified
the Storm=dense-fog invention we just removed) and replaced by
`Snapshot_AlwaysPassesKeyframeFog_RegardlessOfKind` which asserts
every WeatherKind returns the keyframe fog directly.
Build + 742 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The central runtime for every client-visible scripted effect server
triggers via PlayScript (opcode 0xF754) — spell casts, emote
gestures, combat flinches, AND lightning flashes during storms.
Previously acdream parsed PhysicsScript from the dat (via DRW) but
had no runner; the PlayScript stub in ParticleSystem.cs was a
no-op.
Decompile provenance (`docs/research/2026-04-23-physicsscript.md`,
`docs/research/2026-04-23-lightning-real.md`):
FUN_0051bed0 — play_script(scriptId) public API — resolves the
dat id, queues into the owner's ScriptManager list.
FUN_0051be40 — ScriptManager::Start — alloc 16-byte node
{startTime, script*, next}.
FUN_0051bf20 — advance one hook, schedule next fire by next
hook's StartTime.
FUN_0051bfb0 — per-frame tick: while head.NextHookAbsTime ≤
globalClock, fire via vtable dispatch.
Port choices:
- Flat List<ActiveScript> vs retail linked list — iteration is
simpler, N is small.
- Scripts keyed by (scriptId, entityId) — replay replaces instead
of stacking, matches retail's "play_script on the same obj
doesn't double-schedule".
- Anchor world pos cached at Play() time — good enough for
short-lived effects (lightning, spell casts). Callers that
need fresh positions for long emote animations can Play()
again each frame (idempotent).
- Constructor takes Func<uint, PhysicsScript?> resolver so tests
don't need DatCollection; production uses the DatCollection
overload that wraps Get<PhysicsScript> with null-on-fail.
- CallPESHook recurses Play() with Pause baked into the
sub-script's StartTimeAbs. Matches retail semantics where
nested scripts fire on the NEXT tick (list iteration order).
Diag: ACDREAM_DUMP_PLAYSCRIPT=1 logs every Play() and every fire as
[pes] lines. Use this to identify the actual script IDs your ACE
server is sending so we can confirm the lightning pipeline when the
server sends a strike.
Test coverage (9 new tests, all passing):
- unknown script returns false, zero id silent-ignore
- hooks fire in order at their scheduled times
- entityId + anchor pass through to sink
- replay same (scriptId, entityId) replaces, doesn't stack
- different entities run independently
- StopAllForEntity cancels that entity's scripts only
- CallPES nested spawn semantics (fires next tick)
- CallPES with Pause delays correctly
No GameWindow wiring yet — Phase 6b handles the 0xF754 packet
handler and Phase 6c plugs the runner into the frame loop.
Build + 742 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-observed regression 2026-04-23: acdream spawned rain particles
when retail showed no rain at the same server tick. Root cause: my
Phase 3e shortcut mapped DayGroup.Name = "Rainy" → WeatherKind.Rain →
rain particle emitter. That's not what retail does.
Parallel decompile research confirms:
- Agent A (2026-04-23-physicsscript.md): PhysicsScript runtime lives
at FUN_0051bed0 → FUN_0051bfb0, runs per PhysicsObj; sky calls it
from NOWHERE.
- Agent B (2026-04-23-sky-pes-wiring.md): FUN_00508010 (sky render
loop) never reads SkyObject.DefaultPesObjectId — the field is dead
at render time. Rain/snow particles in retail come from a separate
camera-attached weather subsystem that has NOT yet been located.
So the correct behavior is: DayGroup name should only drive
fog/ambient tone (via keyframes, already in the Snapshot path),
never spawn particle emitters. Any retail-faithful particle rain
belongs to a future phase once we find the camera-attached weather
subsystem driver.
Change: MapDayGroupNameToKind now maps all weathery substrings
(storm/snow/rain/cloud/overcast/dark/fog) → Overcast — fog-only
visuals, no particle spawn. Clear names stay Clear. The Rain, Snow,
Storm enum values remain and are still accessible via ForceWeather()
for debug overrides.
Tests updated (WeatherSystemTests): the name→kind theory now expects
Overcast for Rainy/Snowy/Stormy variants.
Also commits the four research docs from this session's parallel
hunt: PhysicsScript dat+runtime, sky↔PES wiring (negative finding),
lightning timer (negative finding — agent #3), fog on sky
(positive: retail applies fog to sky geometry).
NOTE on lightning: agent #3's research only ruled out the CLIENT-SIDE
RANDOM TIMER hypothesis for lightning. User confirms retail does have
visible lightning + thunder. A follow-up agent (#5, in flight as of
this commit) is hunting the real mechanism — PlayScript opcode,
SetLight PhysicsScript hooks, AdminEnvirons side effects, or the
weather-volume draw. This commit does NOT attempt to port lightning.
Build + 733 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
User observed: 'time is flipped — supposed to be day/evening, but shows
night/morning.' That's a ~half-day offset.
Root cause in ACE DerethDateTime.cs line 23:
private const double dayZeroTicks = 0; // Morningthaw 1, 10 P.Y. - Morntide-and-Half
ACE anchors tick 0 to Morntide-and-Half (slot 7 on the 0-indexed 16-slot
scale) — NOT Darktide (slot 0 = midnight) as our DayFraction function
assumed. Confirmed by DerethDateTime.cs:145:
private int hour = (int)Hours.Morntide_and_Half;
Fix: shift DayFraction by +7/16 * DayTicks (3333.75) so tick 0 maps to
its real calendar slot. Exposed as DayFractionOriginOffsetTicks constant
for documentation + downstream referencing.
Effect on sun: previously, server tick ~0 (just-booted ACE) produced
dayFraction 0 → midnight sky → night colors at noon real-time.
Now dayFraction 7/16 = 0.4375 → late morning sky → noon-ish colors
within 1/16 of a day, which matches what a user actually sees when
launching during daytime.
Tests updated for the corrected convention:
- DerethDateTime.DayFraction(0) = 7/16 (not 0).
- CurrentHour(0) = MorntideAndHalf (not Darktide).
- IsDaytime(0) = true.
- Midnight (Darktide, slot 0) is 9/16 of a day past tick 0.
- SkyState + WorldTimeDebug tests retargeted to the new frame.
Build green, 711 tests pass.
Ref: references/ACE/Source/ACE.Common/DerethDateTime.cs:23-25 + :145.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ships full retail-faithful sky-object rendering, 5-kind weather with
deterministic per-day roll + storm lightning, dynamic-lighting shader
UBO with retail hard-cutoff semantics, per-entity torch LightSource
registration via Setup.Lights, ParticleRenderer for rain/snow, and
TimeSync handshake wiring. F7 / F10 debug keys for time/weather
cycling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Small polish commit:
- Clamp ClearColor inputs to [0, 1] because retail keyframes store
sun/fog colors pre-multiplied by their brightness scalars, which can
exceed 1.0; some drivers treat ClearColor > 1 as a saturate-bright
hint and produce visible color shifts at the edges.
- 4 new tests cover WorldTimeService.SetDebugTime / ClearDebugTime /
SyncFromServer-clears-override / SetProvider hot-swap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>